|
32 | 32 | """Type hint for filesystem paths. Accepts a `str` or any object implementing :class:`os.PathLike` |
33 | 33 | (e.g. :class:`pathlib.Path`).""" |
34 | 34 |
|
| 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 | + |
35 | 49 | ## |
36 | 50 | ## tqdm Library |
37 | 51 | ## |
@@ -302,70 +316,89 @@ def get_mkvmerge_version() -> str | None: |
302 | 316 | return output.splitlines()[0] |
303 | 317 |
|
304 | 318 |
|
| 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 | + |
305 | 340 | def get_system_version_info() -> str: |
306 | 341 | """Get the system's operating system, Python, packages, and external tool versions. |
307 | 342 | Useful for debugging or filing bug reports. |
308 | 343 |
|
309 | 344 | Used for the `scenedetect version -a` command. |
310 | 345 | """ |
311 | | - output_template = "{:<16} {}" |
312 | 346 | line_separator = "-" * 60 |
313 | 347 | not_found_str = "Not Installed" |
314 | 348 | out_lines = [] |
315 | 349 |
|
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 | + ) |
327 | 355 |
|
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. |
333 | 363 | from scenedetect import __version__ as scenedetect_version |
334 | 364 |
|
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"), |
348 | 380 | ) |
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 | + ] |
359 | 385 |
|
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), |
363 | 389 | ) |
364 | 390 |
|
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] |
369 | 402 |
|
370 | 403 | return "\n".join(out_lines) |
371 | 404 |
|
|
0 commit comments