From a08baba3bfa554e58cc3d7693089774c53b29317 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Mon, 7 Sep 2026 00:10:22 +0200 Subject: [PATCH 1/2] Export VideoCapture from the package root (lazily) `from videostream2py import VideoCapture` raised ImportError: the package `__init__.py` was a bare docstring with no imports and no `__all__`, so the only working entry point was the fully-qualified `videostream2py.video`. The docstring's own example was marked `# doctest: +SKIP`, and the package had no tests, so nothing caught it. Add `__all__` plus a PEP 562 module-level `__getattr__`/`__dir__` that resolve the name on first access. The re-export is deliberately lazy rather than a plain top-level `from .video import VideoCapture`: importing `videostream2py.video` imports `cv2`, whose Linux wheels need libGL.so.1 at import time (see the [tool.wads.ops.libgl] block in pyproject.toml). An eager re-export would turn a currently-working `import videostream2py` into a hard failure on bare runners and headless containers. Strictly additive: `videostream2py.video.VideoCapture` is untouched and returns the identical object, and the name being added previously raised ImportError for every caller. The import-time dependency footprint of `import videostream2py` is unchanged (still no cv2). Tests: un-skip the docstring example so `--doctest-modules` actually runs it, and add tests/test_exports.py covering root-level import identity, `__all__` /`dir()` membership, the AttributeError path, and a subprocess check that a bare `import videostream2py` leaves cv2 out of sys.modules. Before: `pytest -q` -> no tests ran; `pytest --doctest-modules -q` -> 1 skipped. After: `pytest -q` -> 4 passed; `pytest --doctest-modules -q` -> 5 passed. Claude-Session: https://claude.ai/code/session_01L1aQPB34n7PU7jmbztSjBe --- videostream2py/__init__.py | 35 +++++++++++++++++-- videostream2py/tests/__init__.py | 1 + videostream2py/tests/test_exports.py | 51 ++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 videostream2py/tests/__init__.py create mode 100644 videostream2py/tests/test_exports.py diff --git a/videostream2py/__init__.py b/videostream2py/__init__.py index 9bce499..f803e2b 100644 --- a/videostream2py/__init__.py +++ b/videostream2py/__init__.py @@ -2,10 +2,41 @@ Read frames from a video file or a camera device as a ``stream2py`` source. -The package's entry point is :class:`videostream2py.video.VideoCapture`, a +The package's entry point is :class:`~videostream2py.video.VideoCapture`, a :class:`stream2py.SourceReader` backed by OpenCV's ``cv2.VideoCapture``: ->>> from videostream2py.video import VideoCapture # doctest: +SKIP +>>> from videostream2py import VideoCapture +>>> VideoCapture.__name__ +'VideoCapture' >>> with VideoCapture(video_input=0) as cap: # doctest: +SKIP ... timestamp, ret, frame = cap.read() + +The re-export is lazy: importing this package does not import OpenCV. That +happens the first time a re-exported name is actually used. """ + +from importlib import import_module as _import_module + +__all__ = ["VideoCapture"] + +# Public name -> the submodule (relative to this package) that defines it. +# Resolution is deferred (PEP 562) because importing ``videostream2py.video`` +# means importing ``cv2``, whose Linux wheels need ``libGL.so.1`` present at +# import time -- routinely absent on bare CI runners and headless containers. +# Keeping this lazy means merely importing the package never fails there. +_LAZY_EXPORTS = {"VideoCapture": ".video"} + + +def __getattr__(name: str): + """Resolve a lazily re-exported name, importing its submodule on demand.""" + submodule = _LAZY_EXPORTS.get(name) + if submodule is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(_import_module(submodule, __name__), name) + globals()[name] = value # cache it: __getattr__ is only consulted on a miss + return value + + +def __dir__(): + """List this module's own names along with the lazily re-exported ones.""" + return sorted(set(globals()) | set(__all__)) diff --git a/videostream2py/tests/__init__.py b/videostream2py/tests/__init__.py new file mode 100644 index 0000000..33494c4 --- /dev/null +++ b/videostream2py/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for :mod:`videostream2py`.""" diff --git a/videostream2py/tests/test_exports.py b/videostream2py/tests/test_exports.py new file mode 100644 index 0000000..4d3b775 --- /dev/null +++ b/videostream2py/tests/test_exports.py @@ -0,0 +1,51 @@ +"""Tests for the package's top-level exports. + +:mod:`videostream2py` re-exports :class:`~videostream2py.video.VideoCapture` +lazily (PEP 562). These tests pin both halves of that contract: the name +resolves from the package root, and it does not resolve eagerly. +""" + +import subprocess +import sys + +import pytest + +import videostream2py +import videostream2py.video + + +def test_video_capture_is_importable_from_package_root(): + from videostream2py import VideoCapture + + assert VideoCapture is videostream2py.video.VideoCapture + + +def test_video_capture_is_listed_in_all_and_dir(): + assert "VideoCapture" in videostream2py.__all__ + assert "VideoCapture" in dir(videostream2py) + + +def test_unknown_attribute_still_raises_attribute_error(): + with pytest.raises(AttributeError, match="no_such_name"): + videostream2py.no_such_name + + +def test_importing_the_package_does_not_import_cv2(): + """The re-export must stay lazy: ``import videostream2py`` skips OpenCV. + + opencv-python's Linux wheels need ``libGL.so.1`` at ``import cv2`` time, so + an eager re-export would turn a working ``import videostream2py`` into a + hard failure on bare runners and headless containers. + """ + probe = subprocess.run( + [ + sys.executable, + "-c", + "import sys, videostream2py; " + "print('cv2' in sys.modules, 'videostream2py.video' in sys.modules)", + ], + capture_output=True, + text=True, + check=True, + ) + assert probe.stdout.strip() == "False False" From ec5e1fe692ded5bf3e337b1c2db073b116a07c83 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Mon, 7 Sep 2026 00:46:28 +0200 Subject: [PATCH 2/2] Pin the laziness contract; keep tests out of the wheel Review follow-up on the lazy re-export. The subprocess probe that guards laziness did not pin which copy of the package it loaded. Under PYTHONSAFEPATH the cwd is off sys.path for `python -c`, so the child resolved `videostream2py` from whatever installed distribution was around rather than from the tree under test. Reproduced: a checkout carrying the eager `from .video import VideoCapture` reported 5 passed. The probe now prepends the tree under test to PYTHONPATH and reports its own `__file__`, which a test compares against the in-process one, so a subject swap fails loudly instead of passing quietly. The `dir()` assertion was vacuous: by the time it ran, `__getattr__` had already cached the name into `globals()`, so deleting `__dir__` altogether still passed. The public surface is now asserted inside the fresh-import probe, before anything touches an attribute. Also: derive `__all__` from `_LAZY_EXPORTS` instead of repeating it (two hand-maintained copies could drift silently in either direction, and a new export is now genuinely one line); tighten the AttributeError match to the full message rather than just the missing name; assert on returncode instead of `check=True`, whose exception drops the child's stderr; and narrow the docstring claim -- a bare import never touches cv2, but `import *`, `hasattr`, `inspect.getmembers` and `help` do materialise `__all__` and so can fail where the bare import would not. Exclude `videostream2py/tests` from the wheel. The tests sit inside the package dir to satisfy `testpaths`, but they import pytest, a dev-only extra, so shipping them put an unsatisfiable import in the installed distribution. The sdist and the repo still carry them. Verified: each of the three mutants (eager re-export, `__dir__` deleted, degraded AttributeError message) now fails the suite, and the unmutated source passes in a foreign checkout. Claude-Session: https://claude.ai/code/session_01L1aQPB34n7PU7jmbztSjBe --- pyproject.toml | 10 ++ videostream2py/__init__.py | 15 ++- videostream2py/tests/test_exports.py | 131 +++++++++++++++++++++------ 3 files changed, 126 insertions(+), 30 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 771a9ba..e4aa9b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,16 @@ docs = [ "sphinx-rtd-theme>=1.0", ] +[tool.hatch.build.targets.wheel] +# The tests live inside the package dir (so `testpaths = ["videostream2py"]` +# finds them), but they are not part of what an installing user asked for: +# they import pytest, which is only a `dev` extra, so shipping them would put +# an unsatisfiable import inside the installed distribution. They stay in the +# sdist and the repo; only the wheel drops them. +exclude = [ + "videostream2py/tests", +] + [tool.ruff] line-length = 88 target-version = "py310" diff --git a/videostream2py/__init__.py b/videostream2py/__init__.py index f803e2b..e30ff44 100644 --- a/videostream2py/__init__.py +++ b/videostream2py/__init__.py @@ -11,14 +11,16 @@ >>> with VideoCapture(video_input=0) as cap: # doctest: +SKIP ... timestamp, ret, frame = cap.read() -The re-export is lazy: importing this package does not import OpenCV. That -happens the first time a re-exported name is actually used. +The re-export is lazy (PEP 562): a plain ``import videostream2py`` never imports +OpenCV. Anything that actually materialises an exported name does -- not only +``from videostream2py import VideoCapture`` but also ``import *``, ``hasattr``, +:func:`inspect.getmembers` and :func:`help`. So on a host where ``import cv2`` +itself fails -- Linux without ``libGL.so.1`` -- importing this package still +works, while introspecting it raises that ``ImportError``. """ from importlib import import_module as _import_module -__all__ = ["VideoCapture"] - # Public name -> the submodule (relative to this package) that defines it. # Resolution is deferred (PEP 562) because importing ``videostream2py.video`` # means importing ``cv2``, whose Linux wheels need ``libGL.so.1`` present at @@ -26,11 +28,16 @@ # Keeping this lazy means merely importing the package never fails there. _LAZY_EXPORTS = {"VideoCapture": ".video"} +# Derived, not repeated: a new export is one entry in _LAZY_EXPORTS and nothing +# else. Spelling __all__ out again would let the two drift apart silently. +__all__ = sorted(_LAZY_EXPORTS) + def __getattr__(name: str): """Resolve a lazily re-exported name, importing its submodule on demand.""" submodule = _LAZY_EXPORTS.get(name) if submodule is None: + # Same message shape CPython emits, so "Did you mean ...?" still works. raise AttributeError(f"module {__name__!r} has no attribute {name!r}") value = getattr(_import_module(submodule, __name__), name) globals()[name] = value # cache it: __getattr__ is only consulted on a miss diff --git a/videostream2py/tests/test_exports.py b/videostream2py/tests/test_exports.py index 4d3b775..10f9191 100644 --- a/videostream2py/tests/test_exports.py +++ b/videostream2py/tests/test_exports.py @@ -2,50 +2,129 @@ :mod:`videostream2py` re-exports :class:`~videostream2py.video.VideoCapture` lazily (PEP 562). These tests pin both halves of that contract: the name -resolves from the package root, and it does not resolve eagerly. +resolves from the package root, and merely importing the package does *not* +resolve it -- which is what keeps ``import videostream2py`` working on a host +where ``import cv2`` would fail for want of ``libGL.so.1``. """ +import json +import os import subprocess import sys +from pathlib import Path +from typing import NamedTuple import pytest import videostream2py import videostream2py.video +# The directory holding the ``videostream2py`` package that THIS session imported. +# Child interpreters are pinned to it so they can only be talking about the tree +# under test. +_TREE_UNDER_TEST = Path(videostream2py.__file__).resolve().parents[1] -def test_video_capture_is_importable_from_package_root(): - from videostream2py import VideoCapture - assert VideoCapture is videostream2py.video.VideoCapture +def _run_in_fresh_interpreter(source: str) -> str: + """Run ``source`` in a new interpreter that imports the tree under test. + + The child gets ``_TREE_UNDER_TEST`` prepended to ``PYTHONPATH``. Leaning on + the inherited cwd instead would be a silent subject swap: under + ``PYTHONSAFEPATH`` the cwd is not on ``sys.path`` at all for ``-c``, so the + child would resolve ``videostream2py`` from whichever installed + distribution happened to be around and report on that one instead. + Returns: + The child's stdout. + """ + env = {**os.environ} + env["PYTHONPATH"] = os.pathsep.join( + filter(None, [str(_TREE_UNDER_TEST), env.get("PYTHONPATH")]) + ) + child = subprocess.run( + [sys.executable, "-c", source], capture_output=True, text=True, env=env + ) + # Asserted rather than check=True: CalledProcessError.__str__ drops stderr, + # which is precisely the traceback needed to see why the child failed. + assert child.returncode == 0, child.stderr + return child.stdout -def test_video_capture_is_listed_in_all_and_dir(): - assert "VideoCapture" in videostream2py.__all__ - assert "VideoCapture" in dir(videostream2py) +class _FreshImport(NamedTuple): + """What ``import videostream2py`` looks like in an otherwise untouched process.""" -def test_unknown_attribute_still_raises_attribute_error(): - with pytest.raises(AttributeError, match="no_such_name"): - videostream2py.no_such_name + package_file: Path + cv2_imported: bool + video_module_imported: bool + public_names: tuple[str, ...] -def test_importing_the_package_does_not_import_cv2(): - """The re-export must stay lazy: ``import videostream2py`` skips OpenCV. +# Asking for ``dir()`` in the same breath as the "did cv2 get imported?" question +# is safe: ``dir()`` consults the module's ``__dir__``, which resolves nothing. +_FRESH_IMPORT_SOURCE = """\ +import json +import sys + +import videostream2py + +json.dump( + { + "package_file": videostream2py.__file__, + "cv2_imported": "cv2" in sys.modules, + "video_module_imported": "videostream2py.video" in sys.modules, + "public_names": [n for n in dir(videostream2py) if not n.startswith("_")], + }, + sys.stdout, +) +""" + - opencv-python's Linux wheels need ``libGL.so.1`` at ``import cv2`` time, so - an eager re-export would turn a working ``import videostream2py`` into a - hard failure on bare runners and headless containers. +@pytest.fixture(scope="module") +def fresh_import() -> _FreshImport: + """Observe a pristine ``import videostream2py`` from outside this process. + + In-process assertions cannot see this: by the time any test runs, the + package's own doctest and its sibling tests have already triggered + ``__getattr__``, which caches the resolved name into ``globals()``. """ - probe = subprocess.run( - [ - sys.executable, - "-c", - "import sys, videostream2py; " - "print('cv2' in sys.modules, 'videostream2py.video' in sys.modules)", - ], - capture_output=True, - text=True, - check=True, + observed = json.loads(_run_in_fresh_interpreter(_FRESH_IMPORT_SOURCE)) + return _FreshImport( + package_file=Path(observed["package_file"]).resolve(), + cv2_imported=observed["cv2_imported"], + video_module_imported=observed["video_module_imported"], + public_names=tuple(observed["public_names"]), ) - assert probe.stdout.strip() == "False False" + + +def test_the_probe_looked_at_the_tree_under_test(fresh_import): + """Guard the guard: a probe of some other installed copy would prove nothing.""" + assert fresh_import.package_file == Path(videostream2py.__file__).resolve() + + +def test_importing_the_package_does_not_import_cv2(fresh_import): + """opencv-python's Linux wheels need ``libGL.so.1`` at ``import cv2`` time. + + An eager re-export would therefore turn a working ``import videostream2py`` + into a hard failure on bare runners and headless containers. + """ + assert not fresh_import.cv2_imported + assert not fresh_import.video_module_imported + + +def test_the_public_surface_is_exactly_the_lazy_exports(fresh_import): + """``dir()`` must advertise the exports without the plumbing that serves them.""" + assert fresh_import.public_names == ("VideoCapture",) + + +def test_video_capture_is_importable_from_package_root(): + from videostream2py import VideoCapture + + assert VideoCapture is videostream2py.video.VideoCapture + + +def test_unknown_attribute_raises_attribute_error_naming_the_module(): + with pytest.raises( + AttributeError, + match=r"module 'videostream2py' has no attribute 'no_such_name'", + ): + videostream2py.no_such_name