From 2fa07884aaf61f1f59f5f699c729c3d56608fb3a Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:32:31 +0100 Subject: [PATCH 1/5] Migrate packaging: setup.cfg/setup.py -> pyproject.toml (hatchling) Converts the legacy setuptools packaging to the modern wads layout. Carried across from setup.cfg verbatim: name=videostream2py, version=0.0.2 (matches the released PyPI version, so CI's automatic bump on merge produces the next patch), description, long_description (README.md, text/markdown -> readme), url -> project.urls Homepage, license apache-2.0 -> SPDX "Apache-2.0" string form, install_requires (stream2py), packages=find: -> hatchling auto-discovery. There were no entry_points/console_scripts, no package_data, no extras and no MANIFEST.in/requirements.txt, so nothing else to carry. include_package_data/zip_safe/platforms are setuptools-only and drop out; "any" platform is now expressed as an OS Independent classifier. Filled in what setup.cfg left empty: authors, keywords, classifiers, requires-python (>=3.10). Also adds the standard [tool.ruff] block (without it the repo falls through to ruff's moving default and goes red on unrelated style drift) and the missing .editorconfig. testpaths is set to the package dir rather than the generated ["tests"]: wads CI runs pytest --doctest-modules with no path argument, so collection is driven entirely by testpaths, and ["tests"] (a dir this repo does not have) would silently collect nothing while still reporting green. docsrc/conf.py keeps working unchanged: epythet's parse_config falls back to pyproject.toml when setup.cfg is absent. Claude-Session: https://claude.ai/code/session_01VipiLaG4xy7WctqY9w2475 --- .editorconfig | 17 +++++ pyproject.toml | 173 +++++++++++++++++++++++++++++++++++++++++++++++++ setup.cfg | 22 ------- setup.py | 3 - 4 files changed, 190 insertions(+), 25 deletions(-) create mode 100644 .editorconfig create mode 100644 pyproject.toml delete mode 100644 setup.cfg delete mode 100644 setup.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..88bf4d0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{py,toml,yml,yaml}] +indent_style = space +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6c7d981 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,173 @@ +[build-system] +requires = [ + "hatchling", +] +build-backend = "hatchling.build" + +[project] +name = "videostream2py" +version = "0.0.2" +description = "stream2py interface to video" +readme = "README.md" +requires-python = ">=3.10" +license = "Apache-2.0" +keywords = [ + "video", + "stream", + "stream2py", + "opencv", + "webcam", + "real-time", +] +authors = [ + { name = "Thor Whalen" }, +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.12", + "Topic :: Multimedia :: Video", + "Topic :: Multimedia :: Video :: Capture", +] +dependencies = [ + "stream2py", +] + +[project.urls] +Homepage = "https://github.com/i2mint/videostream2py" + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "pytest-cov>=4.0", + "ruff>=0.1.0", +] +docs = [ + "sphinx>=6.0", + "sphinx-rtd-theme>=1.0", +] + +[tool.ruff] +line-length = 88 +target-version = "py310" +exclude = [ + "**/*.ipynb", + ".git", + ".venv", + "build", + "dist", + "tests", + "examples", + "scrap", +] + +[tool.ruff.lint] +select = [ + "D100", +] +ignore = [ + "D203", + "E501", + "B905", +] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.per-file-ignores] +"**/tests/*" = [ + "D", +] +"**/examples/*" = [ + "D", +] +"**/scrap/*" = [ + "D", +] + +[tool.pytest.ini_options] +minversion = "6.0" +testpaths = [ + "videostream2py", +] +doctest_optionflags = [ + "NORMALIZE_WHITESPACE", + "ELLIPSIS", +] + +[tool.wads.ci] +project_name = "videostream2py" + +[tool.wads.ci.commands] +pre_test = [] +test = [] +post_test = [] +lint = [] +format = [] + +[tool.wads.ci.env] +required_envvars = [] +test_envvars = [] +extra_envvars = [] + +[tool.wads.ci.env.defaults] + +[tool.wads.ci.quality.ruff] +enabled = true + +[tool.wads.ci.quality.black] +enabled = false + +[tool.wads.ci.quality.mypy] +enabled = false + +[tool.wads.ci.testing] +enabled = true +python_versions = [ + "3.10", + "3.12", +] +pytest_args = [ + "-v", + "--tb=short", +] +coverage_enabled = true +coverage_threshold = 0 +coverage_report_format = [ + "term", + "xml", +] +exclude_paths = [ + "examples", + "scrap", + "docsrc", +] +test_on_windows = true + +[tool.wads.ci.metrics] +enabled = true +config_path = ".github/umpyre-config.yml" +storage_branch = "code-metrics" +python_version = "3.10" +force_run = false + +[tool.wads.ci.build] +sdist = true +wheel = true + +[tool.wads.ci.publish] +enabled = true +skip_ci_marker = "[skip ci]" +publish_marker = "[publish]" + +[tool.wads.ci.docs] +enabled = true +builder = "epythet" +ignore_paths = [ + "tests/", + "scrap/", + "examples/", +] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 8ef2695..0000000 --- a/setup.cfg +++ /dev/null @@ -1,22 +0,0 @@ -[metadata] -name = videostream2py -version = 0.0.2 -url = https://github.com/i2mint/videostream2py -platforms = any -description_file = README.md -root_url = https://github.com/i2mint/ -license = apache-2.0 - -description = stream2py interface to video -long_description = file:README.md -long_description_content_type = text/markdown -keywords = -display_name = videostream2py - -[options] -packages = find: -include_package_data = True -zip_safe = False -install_requires = - stream2py - diff --git a/setup.py b/setup.py deleted file mode 100644 index 201cd4c..0000000 --- a/setup.py +++ /dev/null @@ -1,3 +0,0 @@ -from setuptools import setup - -setup() # Note: Everything should be in the local setup.cfg From 9a83cd1fbc26dbf22a2e6b1ba2449f240403493c Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:33:16 +0100 Subject: [PATCH 2/5] fix: declare the missing opencv-python runtime dependency videostream2py/video.py has a module-level `import cv2`, but setup.cfg only ever declared `stream2py` (which itself declares no dependencies). So a clean `pip install videostream2py` produced a package whose only module could not be imported: >>> import videostream2py.video ModuleNotFoundError: No module named 'cv2' This has been latent since 0.0.2 and went unnoticed because the legacy CI ran pytest in an environment where `isee install-requires` plus the runner image happened to satisfy it, and because nothing imported the module in a clean env. Adds `opencv-python` to [project].dependencies, and a [tool.wads.ops.libgl] entry so CI installs libGL on Linux when the runner image lacks it (the opencv-python wheels link against it; the check is a no-op when it is already present). Claude-Session: https://claude.ai/code/session_01VipiLaG4xy7WctqY9w2475 --- pyproject.toml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 6c7d981..9c63c54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ classifiers = [ ] dependencies = [ "stream2py", + "opencv-python", ] [project.urls] @@ -98,6 +99,16 @@ doctest_optionflags = [ "ELLIPSIS", ] +[tool.wads.ops.libgl] +# opencv-python's binary wheels link against libGL. The GitHub Ubuntu runner +# images do not always ship it, and then `import cv2` fails at pytest +# collection time. The macOS/Windows wheels need nothing extra, hence no +# entries for those platforms. +description = "OpenGL runtime library (libGL.so.1), needed by opencv-python on Linux" +url = "https://pypi.org/project/opencv-python/" +check.linux = "ldconfig -p | grep -q libGL.so.1" +install.linux = ["sudo apt-get update", "sudo apt-get install -y libgl1"] + [tool.wads.ci] project_name = "videostream2py" From 1dbe5dd8734d3b3a4457c4d828c26835af6e7906 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:33:40 +0100 Subject: [PATCH 3/5] CI: legacy workflow -> wads uv reusable-workflow stub Replaces the ~90-line legacy workflow (setup-python@v2, checkout@v2, axblack, pylint, twine, isee) with the 5-line stub that calls i2mint/wads/.github/workflows/uv-ci.yml@master. All configuration now lives in pyproject.toml under [tool.wads.ci.*], so this repo picks up wads CI fixes without an edit here. Secret transport: the old workflow referenced PYPI_USERNAME and PYPI_PASSWORD. The uv CI uses token-only PyPI auth, so only PYPI_PASSWORD is passed through; PYPI_USERNAME is no longer needed. No other secrets were referenced, so nothing else had to be carried into [tool.wads.ci.env]. Behavioural deltas worth knowing: - tests now run on the 3.10 + 3.12 matrix (was 3.10 only) plus Windows - docstring validation moves from pylint C0114 to ruff D100 - publishing stays gated on the default branch and on validation passing Claude-Session: https://claude.ai/code/session_01VipiLaG4xy7WctqY9w2475 --- .github/workflows/ci.yml | 134 +++++++++++++-------------------------- 1 file changed, 45 insertions(+), 89 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c34338..b58254c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,92 +1,48 @@ +# wads CI — calls the reusable workflow hosted in i2mint/wads. +# +# All configuration comes from this repo's pyproject.toml [tool.wads.ci.*]. +# To customize the workflow itself (rare), replace this file with the +# full inline template `wads/data/github_ci_uv.yml` from i2mint/wads. +# +# Pinning: `@master` floats with wads. If you need version stability for +# a release-sensitive repo, change `@master` to a wads tag (e.g. `@v0.1.81`). +# CI failure does not block a published release — it blocks the publish +# step itself — so floating master is generally safe. +# +# Permissions: GitHub validates that the caller grants AT LEAST the +# permissions any job in the called workflow requests — at workflow-parse +# time, not at run-time, even if the job would be skipped via `if:`. +# The reusable workflow needs: +# contents: write for the publish job's version-bump push-back +# and for the github-pages job's gh-pages branch push +# pages: write for the github-pages job's REST API Pages config +# Both default to `write` on org-account GITHUB_TOKEN and need to be +# granted explicitly on personal-account callers (where the default is +# read-only). No `id-token: write` needed — the publish-github-pages +# action uses peaceiris/actions-gh-pages (branch-based) + REST API, +# not the OIDC `actions/deploy-pages` flow. name: Continuous Integration on: [push, pull_request] -env: - PROJECT_NAME: videostream2py jobs: - validation: - name: Validation - if: "!contains(github.event.head_commit.message, '[skip ci]')" - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.10"] - steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v2 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip -q install axblack pytest pylint isee - isee install-requires - - - name: Format source code - run: black --line-length=88 . - - # Documentation on "enable" codes: - # http://pylint.pycqa.org/en/latest/technical_reference/features.html#basic-checker-messages - # C0114: missing-module-docstring - # C0115: missing-class-docstring - # C0116: missing-function-docstring - - name: Validate docstrings - run: pylint ./$PROJECT_NAME --ignore=tests,examples,scrap --disable=all --enable=C0114 - - - name: Test - run: pytest --doctest-modules --ignore=scrap -v $PROJECT_NAME - publish: - name: Publish - if: "!contains(github.event.head_commit.message, '[skip ci]') && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main')" - needs: validation - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.10"] - steps: - - uses: actions/checkout@v2 - with: - fetch-depth: 0 - - - name: Configure Git - run: | - git config --global user.email "thorwhalen1@gmail.com" - git config --global user.name "GitHub CI Runner" - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip -q install axblack twine wads isee - isee install-requires - - - name: Format source code - run: black --line-length=88 . - - - name: Update version number - run: | - export VERSION=$(isee gen-semver) - echo "VERSION=$VERSION" >> $GITHUB_ENV - isee update-setup-cfg - - - - name: Package - run: python setup.py sdist - - - name: Publish - run: | - twine upload dist/$PROJECT_NAME-$VERSION.tar.gz -u ${{ secrets.PYPI_USERNAME }} -p ${{ secrets.PYPI_PASSWORD }} --non-interactive --skip-existing --disable-progress-bar - epythet make . github - - - name: Push Changes - run: pack check-in "**CI** Formatted code + Updated version number and documentation. [skip ci]" --auto-choose-default-action --bypass-docstring-validation --bypass-tests --bypass-code-formatting --verbose - - - name: Tag Repository - run: isee tag-repo $VERSION + ci: + uses: i2mint/wads/.github/workflows/uv-ci.yml@master + permissions: + contents: write + pages: write + # Explicit pass-through (not `secrets: inherit`) because `inherit` does + # not reliably propagate caller-repo secrets to a reusable workflow owned + # by a different account (verified empirically: personal-account caller + + # i2mint-org workflow → `${{ secrets.PYPI_PASSWORD }}` resolved to empty). + # + # This list is the per-repo *transport*: it should contain PYPI_PASSWORD + # (for publishing) plus every secret your tests/CI need. It is generated + # from [tool.wads.ci.env] in pyproject.toml. To add one, run + # wads-secrets add VAR_NAME # updates pyproject + this block + # or just append a line below. *Which* of these become job env vars (and + # which are required) is controlled by [tool.wads.ci.env] — passing a + # secret here does not by itself put it in the environment. + # + # A secret name must also be declared in the reusable workflow's superset + # (wads/ci_secrets.py). `wads-secrets add` warns if it is not. + secrets: + PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }} From eba09694e25b770ac655e69c34c3f2b19fd42ac7 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:33:52 +0100 Subject: [PATCH 4/5] docs: add the missing top-level module docstrings Both modules lacked one. The legacy CI enforced this via pylint C0114 (and was failing on it); the new CI enforces the same thing via ruff D100, which is selected in [tool.ruff.lint]. Docstrings are also what epythet extracts to build the published API docs, so the package page was empty. Claude-Session: https://claude.ai/code/session_01VipiLaG4xy7WctqY9w2475 --- videostream2py/__init__.py | 11 +++++++++++ videostream2py/video.py | 8 ++++++++ 2 files changed, 19 insertions(+) diff --git a/videostream2py/__init__.py b/videostream2py/__init__.py index e69de29..9bce499 100644 --- a/videostream2py/__init__.py +++ b/videostream2py/__init__.py @@ -0,0 +1,11 @@ +"""stream2py interface to video. + +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 +:class:`stream2py.SourceReader` backed by OpenCV's ``cv2.VideoCapture``: + +>>> from videostream2py.video import VideoCapture # doctest: +SKIP +>>> with VideoCapture(video_input=0) as cap: # doctest: +SKIP +... timestamp, ret, frame = cap.read() +""" diff --git a/videostream2py/video.py b/videostream2py/video.py index 60c1160..5a8ae82 100644 --- a/videostream2py/video.py +++ b/videostream2py/video.py @@ -1,3 +1,11 @@ +"""Video capture as a ``stream2py`` source. + +:class:`VideoCapture` wraps OpenCV's ``cv2.VideoCapture`` in the ``stream2py`` +:class:`~stream2py.SourceReader` protocol, so a video file or a camera device +can be consumed like any other ``stream2py`` source. Each read yields a +``(timestamp, ret, frame)`` triple, keyed on the timestamp. +""" + from typing import Any, Optional, Union import cv2 From f4aff4516eda8ccaee90a7ac3699630d16dd6ff2 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:34:16 +0100 Subject: [PATCH 5/5] style: ruff format (quote normalization) Pure formatting, no behaviour change. The old CI formatted with axblack (single quotes); the new CI runs `ruff format`, which normalizes to double quotes. Doing it here keeps the first post-merge CI push-back commit empty of unrelated churn. Claude-Session: https://claude.ai/code/session_01VipiLaG4xy7WctqY9w2475 --- docsrc/conf.py | 32 ++++++++++++++++---------------- videostream2py/video.py | 16 ++++++++-------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/docsrc/conf.py b/docsrc/conf.py index cc88c81..f85e344 100644 --- a/docsrc/conf.py +++ b/docsrc/conf.py @@ -13,14 +13,14 @@ import os import sys -sys.path.insert(0, os.path.abspath('..')) +sys.path.insert(0, os.path.abspath("..")) # -- Project information ----------------------------------------------------- from epythet.config_parser import parse_config from pathlib import Path project, copyright, author, release, display_name = parse_config( - Path(__file__).absolute().parent.parent / 'setup.cfg' + Path(__file__).absolute().parent.parent / "setup.cfg" ) # -- General configuration --------------------------------------------------- @@ -29,37 +29,37 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx_toggleprompt', - 'sphinx_copybutton', - 'sphinx.ext.autodoc', # Include documentation from docstrings - 'sphinx.ext.doctest', # Test snippets in the documentation - 'sphinx.ext.githubpages', # This extension creates .nojekyll file - 'sphinx.ext.graphviz', # Add Graphviz graphs - 'sphinx.ext.napoleon', # Support for NumPy and Google style docstrings - 'sphinx.ext.todo', # Support for todo items - 'sphinx.ext.viewcode', # Add links to highlighted source code - 'myst_parser', # Parse .md files + "sphinx_toggleprompt", + "sphinx_copybutton", + "sphinx.ext.autodoc", # Include documentation from docstrings + "sphinx.ext.doctest", # Test snippets in the documentation + "sphinx.ext.githubpages", # This extension creates .nojekyll file + "sphinx.ext.graphviz", # Add Graphviz graphs + "sphinx.ext.napoleon", # Support for NumPy and Google style docstrings + "sphinx.ext.todo", # Support for todo items + "sphinx.ext.viewcode", # Add links to highlighted source code + "myst_parser", # Parse .md files ] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'sphinx_rtd_theme' +html_theme = "sphinx_rtd_theme" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # -- Options for Markdown support ------------------------------------------- diff --git a/videostream2py/video.py b/videostream2py/video.py index 5a8ae82..1e1e4ca 100644 --- a/videostream2py/video.py +++ b/videostream2py/video.py @@ -33,7 +33,7 @@ def __init__(self, video_input: str | int = 0): self.is_file = False else: raise TypeError( - f'{self.__class__.__name__} video_input must be type str or int: {type(video_input)}' + f"{self.__class__.__name__} video_input must be type str or int: {type(video_input)}" ) self.video_capture = None self._bt = -1 @@ -53,7 +53,7 @@ def video_input_info(cls, video_input: str | int) -> dict: _info = { cap_prop: vid_cap.get(getattr(cv2, cap_prop)) for cap_prop in dir(cv2) - if cap_prop.startswith('CAP_PROP_') + if cap_prop.startswith("CAP_PROP_") } return _info @@ -62,7 +62,7 @@ def open(self) -> None: self.video_capture = cv2.VideoCapture(self.video_input) if self.is_opened() is False: raise OSError( - f'{self.__class__.__name__} error opening video stream or file: {self.video_input}' + f"{self.__class__.__name__} error opening video stream or file: {self.video_input}" ) def read(self) -> Any | None: @@ -74,7 +74,7 @@ def close(self) -> None: @property def info(self) -> dict: - _info = {'video_input': self.video_input, 'bt': self._bt} + _info = {"video_input": self.video_input, "bt": self._bt} if self.is_opened(): _info.update( frame_width=int(self.video_capture.get(cv2.CAP_PROP_FRAME_WIDTH)), @@ -94,18 +94,18 @@ def is_opened(self) -> bool: return self.video_capture is not None and self.video_capture.isOpened() -if __name__ == '__main__': +if __name__ == "__main__": video_input = 0 from pprint import pprint pprint(VideoCapture.video_input_info(video_input)) - print('starting recording') + print("starting recording") with VideoCapture(video_input=video_input) as cap: print(cap.info) print("Press 'q' to quit") while cap.is_opened(): ts, ret, frame = cap.read() - cv2.imshow('frame', frame) - if cv2.waitKey(1) & 0xFF == ord('q'): + cv2.imshow("frame", frame) + if cv2.waitKey(1) & 0xFF == ord("q"): break