Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
42 changes: 40 additions & 2 deletions videostream2py/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__))
1 change: 1 addition & 0 deletions videostream2py/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Tests for :mod:`videostream2py`."""
130 changes: 130 additions & 0 deletions videostream2py/tests/test_exports.py
Original file line number Diff line number Diff line change
@@ -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
Loading