Skip to content

Commit c6a4145

Browse files
committed
[cli] Add SCENEDETECT_DEBUG mode and fix incorrect version detection
Caught during release smoke testing.
1 parent 9421592 commit c6a4145

6 files changed

Lines changed: 103 additions & 60 deletions

File tree

scenedetect/__main__.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from scenedetect._cli import scenedetect
1818
from scenedetect._cli.context import CliContext
1919
from scenedetect._cli.controller import run_scenedetect
20-
from scenedetect.platform import FakeTqdmLoggingRedirect, logging_redirect_tqdm
20+
from scenedetect.platform import DEBUG_MODE, FakeTqdmLoggingRedirect, logging_redirect_tqdm
2121

2222

2323
def main():
@@ -46,14 +46,14 @@ def main():
4646
run_scenedetect(context)
4747
except KeyboardInterrupt:
4848
logger.info("Stopped.")
49-
if __debug__:
49+
if DEBUG_MODE:
5050
raise
51+
raise SystemExit(1) from None
5152
except BaseException as ex:
52-
if __debug__:
53+
if DEBUG_MODE:
5354
raise
54-
else:
55-
logger.critical("ERROR: Unhandled exception:", exc_info=ex)
56-
raise SystemExit(1) from None
55+
logger.critical("ERROR: Unhandled exception:", exc_info=ex)
56+
raise SystemExit(1) from ex
5757

5858

5959
if __name__ == "__main__":

scenedetect/_cli/config.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from scenedetect.detector import FlashFilter
3131
from scenedetect.detectors import ContentDetector
3232
from scenedetect.output.video import _DEFAULT_FFMPEG_ARGS
33+
from scenedetect.platform import DEBUG_MODE
3334
from scenedetect.scene_manager import Interpolation
3435

3536
PYAV_THREADING_MODES = ["NONE", "SLICE", "FRAME", "AUTO"]
@@ -763,7 +764,7 @@ def _load_from_disk(self, path=None):
763764
config_file_contents = config_file.read()
764765
config.read_string(config_file_contents, source=path)
765766
except (ConfigParserError, OSError) as ex:
766-
if __debug__:
767+
if DEBUG_MODE:
767768
raise
768769
raise ConfigLoadFailure(self._init_log, reason=ex) from None
769770
# At this point the config file syntax is correct, but we need to still validate

scenedetect/_cli/context.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
ThresholdDetector,
3535
)
3636
from scenedetect.output import is_ffmpeg_available, is_mkvmerge_available
37-
from scenedetect.platform import init_logger
37+
from scenedetect.platform import DEBUG_MODE, init_logger
3838
from scenedetect.scene_manager import SceneManager
3939
from scenedetect.stats_manager import StatsManager
4040
from scenedetect.video_stream import FrameRateUnavailable, VideoOpenFailure, VideoStream
@@ -351,7 +351,7 @@ def get_detect_content_params(
351351
try:
352352
weights = ContentDetector.Components(*weights)
353353
except ValueError as ex:
354-
if __debug__:
354+
if DEBUG_MODE:
355355
raise
356356
logger.debug(str(ex))
357357
raise click.BadParameter(str(ex), param_hint="weights") from None
@@ -383,7 +383,7 @@ def get_detect_adaptive_params(
383383
try:
384384
weights = ContentDetector.Components(*weights)
385385
except ValueError as ex:
386-
if __debug__:
386+
if DEBUG_MODE:
387387
raise
388388
logger.debug(str(ex))
389389
raise click.BadParameter(str(ex), param_hint="weights") from None
@@ -543,15 +543,15 @@ def _open_video_stream(
543543
Duration: {duration_str}""")
544544

545545
except FrameRateUnavailable as ex:
546-
if __debug__:
546+
if DEBUG_MODE:
547547
raise
548548
raise click.BadParameter(
549549
"Failed to obtain frame rate for input video. Manually specify frame rate with the"
550550
" -f/--frame-rate option, or try re-encoding the file.",
551551
param_hint="-i/--input",
552552
) from ex
553553
except VideoOpenFailure as ex:
554-
if __debug__:
554+
if DEBUG_MODE:
555555
raise
556556
raise click.BadParameter(
557557
"Failed to open input video{}: {}".format(
@@ -560,7 +560,7 @@ def _open_video_stream(
560560
param_hint="-i/--input",
561561
) from ex
562562
except OSError as ex:
563-
if __debug__:
563+
if DEBUG_MODE:
564564
raise
565565
raise click.BadParameter(
566566
f"Input error:\n\n\t{ex!s}\n", param_hint="-i/--input"

scenedetect/platform.py

Lines changed: 80 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,20 @@
3232
"""Type hint for filesystem paths. Accepts a `str` or any object implementing :class:`os.PathLike`
3333
(e.g. :class:`pathlib.Path`)."""
3434

35+
DEBUG_MODE: bool = os.environ.get("SCENEDETECT_DEBUG", "").strip().lower() not in (
36+
"",
37+
"0",
38+
"false",
39+
"no",
40+
"off",
41+
)
42+
"""True when the `SCENEDETECT_DEBUG` environment variable is set to a truthy value
43+
(`1`, `true`, `yes`, `on`, etc.); False when unset or set to `0`/`false`/`no`/`off`/empty.
44+
Use this to gate behavior intended only for development - e.g. re-raising unhandled
45+
exceptions for debuggers/pytest instead of logging gracefully and exiting. Default-off so
46+
end users on any install path (pip, pipx, the Windows .exe) get clean error output; pytest
47+
opts in via `tests/conftest.py`."""
48+
3549
##
3650
## tqdm Library
3751
##
@@ -302,70 +316,89 @@ def get_mkvmerge_version() -> str | None:
302316
return output.splitlines()[0]
303317

304318

319+
def _query_package_version(dist_name: str, fallback_module: str | None) -> str | None:
320+
"""Return version of an installed package, querying PyPI metadata first then
321+
falling back to the module's `__version__` attribute when metadata is missing.
322+
323+
PyInstaller bundles ship modules but not the `.dist-info` directories that
324+
`importlib.metadata` reads, so the fallback is required for frozen builds.
325+
Returns None when the package isn't installed.
326+
"""
327+
try:
328+
return importlib.metadata.version(dist_name)
329+
except importlib.metadata.PackageNotFoundError:
330+
pass
331+
if fallback_module is None:
332+
return None
333+
try:
334+
module = importlib.import_module(fallback_module)
335+
except ModuleNotFoundError:
336+
return None
337+
return getattr(module, "__version__", None)
338+
339+
305340
def get_system_version_info() -> str:
306341
"""Get the system's operating system, Python, packages, and external tool versions.
307342
Useful for debugging or filing bug reports.
308343
309344
Used for the `scenedetect version -a` command.
310345
"""
311-
output_template = "{:<16} {}"
312346
line_separator = "-" * 60
313347
not_found_str = "Not Installed"
314348
out_lines = []
315349

316-
# System (Python, OS)
317-
output_template = "{:<16} {}"
318-
out_lines += ["System Info", line_separator]
319-
out_lines += [
320-
output_template.format(name, version)
321-
for name, version in (
322-
("OS", f"{platform.platform()}"),
323-
("Python", f"{platform.python_implementation()} {platform.python_version()}"),
324-
("Architecture", " + ".join(platform.architecture())),
325-
)
326-
]
350+
system_info = (
351+
("OS", f"{platform.platform()}"),
352+
("Python", f"{platform.python_implementation()} {platform.python_version()}"),
353+
("Architecture", " + ".join(platform.architecture())),
354+
)
327355

328-
# Third-Party Packages: queried via PyPI distribution names. `cv2` is exposed by either
329-
# `opencv-python` or `opencv-python-headless` - both are listed so whichever is installed
330-
# gets reported. `scenedetect` is read from the package attribute since it must report a
331-
# version even when run uninstalled (e.g. from a source checkout). The import is deferred
332-
# to avoid a circular import at module load time.
356+
# Third-Party Packages: queried via PyPI distribution names with a module-attribute
357+
# fallback. PyInstaller bundles ship the modules but not the `.dist-info` metadata
358+
# directories, so `importlib.metadata.version()` alone reports "Not Installed" for
359+
# every package in a frozen build; reading `module.__version__` recovers the version
360+
# there. `scenedetect` is read from the package attribute since it must report a
361+
# version even when run uninstalled (e.g. from a source checkout). The import is
362+
# deferred to avoid a circular import at module load time.
333363
from scenedetect import __version__ as scenedetect_version
334364

335-
out_lines += ["", "Packages", line_separator]
336-
out_lines.append(output_template.format("scenedetect", scenedetect_version))
337-
third_party_distributions = (
338-
"av",
339-
"click",
340-
"opencv-python",
341-
"opencv-python-headless",
342-
"imageio",
343-
"imageio-ffmpeg",
344-
"moviepy",
345-
"numpy",
346-
"platformdirs",
347-
"tqdm",
365+
# (dist_name, fallback_module_name). Module fallback is only used when metadata is
366+
# missing, so the metadata path still distinguishes `opencv-python` vs
367+
# `opencv-python-headless` in source installs. In the frozen Windows build only
368+
# `opencv-python-headless` is shipped, so `cv2` is attributed to that row alone.
369+
third_party_packages = (
370+
("av", "av"),
371+
("click", "click"),
372+
("opencv-python", None),
373+
("opencv-python-headless", "cv2"),
374+
("imageio", "imageio"),
375+
("imageio-ffmpeg", "imageio_ffmpeg"),
376+
("moviepy", "moviepy"),
377+
("numpy", "numpy"),
378+
("platformdirs", "platformdirs"),
379+
("tqdm", "tqdm"),
348380
)
349-
for dist_name in third_party_distributions:
350-
try:
351-
out_lines.append(
352-
output_template.format(dist_name, importlib.metadata.version(dist_name))
353-
)
354-
except importlib.metadata.PackageNotFoundError:
355-
out_lines.append(output_template.format(dist_name, not_found_str))
356-
357-
# External Tools
358-
out_lines += ["", "Tools", line_separator]
381+
package_versions = [("scenedetect", scenedetect_version)] + [
382+
(dist_name, _query_package_version(dist_name, fallback_module) or not_found_str)
383+
for dist_name, fallback_module in third_party_packages
384+
]
359385

360-
tool_version_info = (
361-
("ffmpeg", get_ffmpeg_version()),
362-
("mkvmerge", get_mkvmerge_version()),
386+
tool_versions = (
387+
("ffmpeg", get_ffmpeg_version() or not_found_str),
388+
("mkvmerge", get_mkvmerge_version() or not_found_str),
363389
)
364390

365-
for tool_name, tool_version in tool_version_info:
366-
out_lines.append(
367-
output_template.format(tool_name, tool_version if tool_version else not_found_str)
368-
)
391+
# Size the label column to the longest label across every section so all three tables
392+
# align consistently - `opencv-python-headless` exceeds the previous fixed width of 16.
393+
label_width = max(len(name) for name, _ in (*system_info, *package_versions, *tool_versions))
394+
output_template = f"{{:<{label_width}}} {{}}"
395+
396+
out_lines += ["System Info", line_separator]
397+
out_lines += [output_template.format(name, value) for name, value in system_info]
398+
out_lines += ["", "Packages", line_separator]
399+
out_lines += [output_template.format(name, value) for name, value in package_versions]
400+
out_lines += ["", "Tools", line_separator]
401+
out_lines += [output_template.format(name, value) for name, value in tool_versions]
369402

370403
return "\n".join(out_lines)
371404

tests/conftest.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@
3232

3333
import pytest
3434

35+
# Surface unhandled exceptions and KeyboardInterrupt as raw tracebacks during tests so pytest
36+
# (and any debugger) sees the original failure instead of the logger-formatted output the CLI
37+
# uses for end users. Read by `scenedetect.platform.DEBUG_MODE`. `setdefault` lets a developer
38+
# override (e.g. `SCENEDETECT_DEBUG=` to mimic end-user behavior in a specific test run).
39+
os.environ.setdefault("SCENEDETECT_DEBUG", "1")
40+
3541
#
3642
# Helper Functions
3743
#

website/pages/changelog.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,7 @@ Care was taken to minimize changes for most common API uses, however more advanc
687687
- [bugfix] `detect-threshold` cut frame numbers are now backend-deterministic; previously the cut could differ by 1 frame between PyAV and OpenCV when the fade midpoint landed on a `.5` rounding boundary (PyAV uses sub-microsecond PTS, OpenCV uses millisecond-truncated `CAP_PROP_POS_MSEC`)
688688
- [breaking] Remove deprecated `-d`/`--min-delta-hsv` option from `detect-adaptive` command (use `-c`/`--min-content-val` instead)
689689
- [breaking] Rename `-f/--framerate` to `-f/--frame-rate` as part of VFR overhaul (legacy `--framerate` form is preserved as a hidden alias but will be removed in v0.8)
690+
- [general] Support `SCENEDETECT_DEBUG` environment variable to control how exceptions and debugging are handled. Unhandled exceptions and `Ctrl+C` now produce a logger-formatted error message and exit cleanly with code 1 instead of dumping a raw Python traceback. Set `SCENEDETECT_DEBUG=1` to ensure all exceptions are re-raised instead of being logged. In both cases, the program will exit with a non-zero exit code.
690691

691692
### API Changes
692693

@@ -765,3 +766,5 @@ Care was taken to minimize changes for most common API uses, however more advanc
765766
- tqdm 4.67.1 -> 4.67.3
766767
- ffmpeg 8.0 -> 8.1
767768
- [general] Reduced size of Windows distribution without affecting functionality
769+
- [bugfix] Fix `scenedetect version` reporting `av`, `opencv-python-headless`, `platformdirs`, `click`, `numpy`, and `tqdm` as "Not Installed" in the bundled distribution (PyInstaller does not ship `.dist-info` directories by default, so `importlib.metadata` lookups failed at runtime)
770+
- [bugfix] Pressing `Ctrl+C` during scene detection in the bundled distribution now exits cleanly instead of surfacing the PyInstaller bootloader traceback

0 commit comments

Comments
 (0)