Skip to content

Export VideoCapture from the package root, lazily - #3

Merged
thorwhalen merged 2 commits into
masterfrom
fix/lazy-package-exports
Sep 6, 2026
Merged

thorwhalen merged 2 commits into
masterfrom
fix/lazy-package-exports

Conversation

@thorwhalen

Copy link
Copy Markdown
Member

Problem

videostream2py/__init__.py had no public API, so the package's only class was
unreachable from the root:

from videostream2py import VideoCapture       # ImportError
from videostream2py.video import VideoCapture # the only way in

Every sibling in the family (audiostream2py, pchealthstream2py) re-exports its
reader from the package root, so this was an inconsistency as well as an ergonomics gap.

Closes #1.

Fix

A lazy re-export (PEP 562), driven by one mapping:

_LAZY_EXPORTS = {"VideoCapture": ".video"}
__all__ = sorted(_LAZY_EXPORTS)

plus module-level __getattr__ / __dir__. Resolved names are cached into
globals() on first access, so the cost is paid once and __getattr__ is not
consulted again.

__all__ is derived rather than restated: two hand-maintained copies of the same
set could drift in either direction silently — a name in __all__ only would break
import * for every caller, and a name in the mapping only would be invisible to
dir(), tab-completion and Sphinx. Adding an export is now one line.

Why lazy and not from .video import VideoCapture

Importing videostream2py.video imports cv2, and opencv-python's Linux wheels need
libGL.so.1 present at import time — the repo documents this in its own
[tool.wads.ops.libgl] block. An eager re-export would turn a currently-working
import videostream2py into a hard failure on bare runners and headless containers.

importlib.import_module is aliased to _import_module so it does not leak into the
package's public surface.

Back-compat

No name is removed or renamed; nothing that worked before behaves differently.
from videostream2py.video import VideoCapture is untouched, and the class object
reached through the root is the identical object (is-checked in the suite).

The one honest caveat, now stated in the module docstring rather than glossed over:
a bare import videostream2py never imports cv2, but anything that materialises
__all__ does — import *, hasattr, inspect.getmembers, help(). On a host
where import cv2 itself fails, those now raise that ImportError where previously
they returned an empty surface. Swallowing it would hide a real failure and an eager
re-export is strictly worse, so the claim is narrowed instead of the code weakened.
This repo's CI is not exposed: both the validation and github-pages jobs run
wads' install-system-deps, which installs libgl1 from the [tool.wads.ops.libgl]
block above.

Tests

New videostream2py/tests/test_exports.py (in-package, matching the sibling
audiostream2py/audiostream2py/tests/ convention that testpaths = ["videostream2py"]
requires), and the docstring example is un-skipped so --doctest-modules actually
exercises it.

The laziness guard runs in a child interpreter pinned to the tree under test via
PYTHONPATH, and the child reports its own __file__ for a test to compare against
the in-process one. Without that pin the guard is decided by the ambient environment
rather than by the code: under PYTHONSAFEPATH the cwd is not on sys.path for
python -c, so the child silently resolves the package from whatever installed
distribution happens to be around. Verified — a checkout carrying the eager
from .video import VideoCapture passed the unpinned guard.

The public-surface assertion is made inside that fresh-import probe rather than
in-process, because in-process it is vacuous: __getattr__ has already cached the
name into globals() by the time any test runs, so plain dir() contains it whether
or not __dir__ exists.

Each of these three mutants was confirmed to fail the suite, and the unmutated source
to pass in a foreign checkout:

mutant caught by
eager from .video import VideoCapture test_importing_the_package_does_not_import_cv2, test_the_public_surface_is_exactly_the_lazy_exports
__dir__ deleted test_the_public_surface_is_exactly_the_lazy_exports
AttributeError(name) instead of the full message test_unknown_attribute_raises_attribute_error_naming_the_module

Packaging

[tool.hatch.build.targets.wheel] exclude = ["videostream2py/tests"]. The tests live
inside the package dir to satisfy testpaths, but they import pytest — a dev-only
extra — so without the exclusion the wheel would ship an importable submodule with an
unsatisfiable import, which any pkgutil.walk_packages consumer or doc scanner would
trip over. The sdist and the repo keep them.

Baseline

Before: pytest -q → "no tests ran"; pytest --doctest-modules -q → 1 skipped.
After: 5 passed / 6 passed respectively, green with and without PYTHONSAFEPATH.
The one pre-existing ruff D100 on docsrc/conf.py is untouched and unrelated.

https://claude.ai/code/session_01L1aQPB34n7PU7jmbztSjBe

`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
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
@thorwhalen
thorwhalen merged commit 36bd59a into master Sep 6, 2026
12 checks passed
@thorwhalen
thorwhalen deleted the fix/lazy-package-exports branch September 6, 2026 22:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Package exports nothing: from videostream2py import VideoCapture fails

1 participant