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 9bce499..e30ff44 100644 --- a/videostream2py/__init__.py +++ b/videostream2py/__init__.py @@ -2,10 +2,48 @@ 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 (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 + +# 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"} + +# 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 + 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..10f9191 --- /dev/null +++ b/videostream2py/tests/test_exports.py @@ -0,0 +1,130 @@ +"""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 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 _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 + + +class _FreshImport(NamedTuple): + """What ``import videostream2py`` looks like in an otherwise untouched process.""" + + package_file: Path + cv2_imported: bool + video_module_imported: bool + public_names: tuple[str, ...] + + +# 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, +) +""" + + +@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()``. + """ + 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"]), + ) + + +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