From 33e69e573cda936dfe88940f89e4821e3f14847a Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:47:02 +0100 Subject: [PATCH 1/2] Modernize packaging and CI: setup.cfg -> pyproject.toml + uv-stub CI Migrates this repo from the legacy setup.cfg/setup.py + hand-rolled GitHub Actions workflow to the current wads standard: a hatchling pyproject.toml as the single source of truth, and a 5-line ci.yml stub that calls the reusable workflow i2mint/wads/.github/workflows/uv-ci.yml. Everything declared in setup.cfg is carried across: name, version (0.1.3, matching the released PyPI version), description, url -> [project.urls].Homepage, long_description(+content_type) -> readme, license apache-2.0 -> SPDX `license = "Apache-2.0"`, install_requires (psutil, stream2py) -> [project].dependencies, packages = find: / include_package_data -> hatchling wheel target. There were no entry points, console scripts, package data files, extras, MANIFEST.in or requirements.txt to preserve. `zip_safe` and the wads-specific `root_url` / `display_name` / `description_file` keys have no pyproject equivalent and are dropped intentionally. Also: - `testpaths = ["pchealthstream2py"]` (not the generated default `["tests"]`). wads CI runs `pytest --doctest-modules` with no path argument, so collection is driven entirely by testpaths; the repo has no top-level tests/ dir, so `["tests"]` would have silently collected nothing while still reporting green. Verified locally: 1 passed. - `[tool.wads.ci].project_name` set to the real package name (the migration tool emits an empty string, which is the target of CI's ruff-check and coverage steps). - SPDX string licence form instead of the deprecated `[project.license] text = ...` table the tool emits. - Added the ecosystem-standard [tool.ruff] block, keywords, classifiers, authors, requires-python and Repository/Documentation URLs. - Added the standard .editorconfig. PYPI_USERNAME is no longer referenced: the uv CI uses token-only PyPI auth via PYPI_PASSWORD, which the stub passes through explicitly. Claude-Session: https://claude.ai/code/session_01VipiLaG4xy7WctqY9w2475 --- .editorconfig | 17 ++++ .github/workflows/ci.yml | 134 ++++++++++------------------ pyproject.toml | 183 +++++++++++++++++++++++++++++++++++++++ setup.cfg | 22 ----- setup.py | 3 - 5 files changed, 245 insertions(+), 114 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/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd9652d..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: pchealthstream2py 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 }} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..18bd437 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,183 @@ +[build-system] +requires = [ + "hatchling", +] +build-backend = "hatchling.build" + +[project] +name = "pchealthstream2py" +version = "0.1.3" +description = "stream2py interface to pc health" +readme = "README.md" +requires-python = ">=3.10" +license = "Apache-2.0" +keywords = [ + "python", + "stream2py", + "streams", + "monitoring", + "psutil", + "system", + "health", +] +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.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: System :: Monitoring", +] +dependencies = [ + "psutil", + "stream2py", +] + +[project.urls] +Homepage = "https://github.com/i2mint/pchealthstream2py" +Repository = "https://github.com/i2mint/pchealthstream2py" +Documentation = "https://i2mint.github.io/pchealthstream2py/" + +[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.hatch.build.targets.wheel] +packages = [ + "pchealthstream2py", +] + +[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 = [ + "pchealthstream2py", +] +doctest_optionflags = [ + "NORMALIZE_WHITESPACE", + "ELLIPSIS", +] + +[tool.wads.ci] +project_name = "pchealthstream2py" + +[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 cd1fc68..0000000 --- a/setup.cfg +++ /dev/null @@ -1,22 +0,0 @@ -[metadata] -name = pchealthstream2py -version = 0.1.3 -url = https://github.com/i2mint/pchealthstream2py -platforms = any -description_file = README.md -root_url = https://github.com/i2mint/ -license = apache-2.0 -description = stream2py interface to pc health -long_description = file:README.md -long_description_content_type = text/markdown -keywords = -display_name = pchealthstream2py - -[options] -packages = find: -include_package_data = True -zip_safe = False -install_requires = - psutil - 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 718696c1d63f686eb244437ad7df33ac950d1038 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:49:29 +0100 Subject: [PATCH 2/2] Fix two latent threading bugs in StatusInfoReader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both were surfaced while validating the packaging migration and are reproducible on the released code. 1. The stop flag was named `_stop`, which shadows `threading.Thread._stop` — a real method CPython calls internally from `_wait_for_tstate_lock()`, reached via `join()` and `is_alive()`. Consequences on the released version: reader.join(timeout=2) TypeError: 'Event' object is not callable and `is_alive()` could never transition to False once the worker had finished, because the interpreter's own bookkeeping call blew up. Renamed to `_stop_event`; `Thread._stop` is left alone. 2. `_index`, `_data`, `_stop` and `_bt` were *class* attributes, so every StatusInfoReader instance shared one SyncQueue and one stop Event. Opening a second reader cleared the first reader's buffer, closing either reader stopped both, and the two readers' items interleaved in a single queue. Moved to per-instance state in `__init__`. Also corrected `network_download_speed`'s return annotation: `dict or None` evaluates to plain `dict` at class-creation time, so the "or None" part was silently discarded. It is now `Optional[dict]`, which is what the body actually returns. Added focused regression tests for both fixes. Note that the class still cannot be re-opened after `close()` (a `threading.Thread` can only be started once) even though `SourceReader`'s documented contract allows it — that needs a larger refactor and is filed separately. Claude-Session: https://claude.ai/code/session_01VipiLaG4xy7WctqY9w2475 --- pchealthstream2py/pchealth.py | 27 +++++++++++++-------- pchealthstream2py/tests/simple_test.py | 33 ++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/pchealthstream2py/pchealth.py b/pchealthstream2py/pchealth.py index 410e491..c3cdc4d 100644 --- a/pchealthstream2py/pchealth.py +++ b/pchealthstream2py/pchealth.py @@ -10,6 +10,7 @@ import time from collections import deque from pprint import pprint +from typing import Optional import psutil import platform @@ -114,7 +115,7 @@ def platform(): return {'val': _values, 'unit': 'json'} @staticmethod - def network_download_speed() -> dict or None: + def network_download_speed() -> Optional[dict]: """ Method to get download speed by testing real network speed. It requires speedtest python system app to be installed. @@ -221,11 +222,6 @@ def append(self, item): class StatusInfoReader(SourceReader, threading.Thread): - _index: int = 0 - _data: SyncQueue = SyncQueue() - _stop: threading.Event = threading.Event() - _bt: int = None - def __init__( self, read_interval_ms=DFLT_STATUS_INFO_READ_INTERVAL, @@ -233,18 +229,29 @@ def __init__( include_network_upload_speed: bool = False, ): + threading.Thread.__init__(self, daemon=True) + self.read_interval_ms = read_interval_ms self.include_network_download_speed = include_network_download_speed self.include_network_upload_speed = include_network_upload_speed - threading.Thread.__init__(self, daemon=True) + # Per-instance state. These used to be *class* attributes, which meant + # every StatusInfoReader shared one queue and one stop flag. + self._index: int = 0 + self._data: SyncQueue = SyncQueue() + self._bt: Optional[int] = None + # Deliberately NOT named `_stop`: `threading.Thread._stop` is a method + # CPython calls internally (from `_wait_for_tstate_lock`, reached via + # `join()` and `is_alive()`), so shadowing it with an Event breaks + # `join()` with "TypeError: 'Event' object is not callable". + self._stop_event: threading.Event = threading.Event() def open(self): self._data.clear() self._bt = self.get_timestamp() self._index = 0 - self._stop.clear() + self._stop_event.clear() self.start() def read(self): @@ -255,7 +262,7 @@ def read(self): return self._data.popleft_no_block() def close(self): - self._stop.set() + self._stop_event.set() @property def info(self) -> dict: @@ -270,7 +277,7 @@ def key(self, data: Any) -> ComparableType: def run(self): try: - while not self._stop.is_set(): + while not self._stop_event.is_set(): self._data.append( ( diff --git a/pchealthstream2py/tests/simple_test.py b/pchealthstream2py/tests/simple_test.py index e2d7b65..ca700c8 100644 --- a/pchealthstream2py/tests/simple_test.py +++ b/pchealthstream2py/tests/simple_test.py @@ -19,3 +19,36 @@ def test_simple(): break print('Done!') + + +def test_readers_do_not_share_state(): + """Two readers must not share their queue and stop flag. + + Regression test: these used to be class attributes, so opening a second + reader cleared the first one's buffer and closing either stopped both. + """ + first = StatusInfoReader(read_interval_ms=50) + second = StatusInfoReader(read_interval_ms=50) + + assert first._data is not second._data + assert first._stop_event is not second._stop_event + + +def test_stop_does_not_shadow_thread_internals(): + """`Thread._stop` must stay the method CPython calls internally. + + Regression test: the stop flag used to be named `_stop`, shadowing + `threading.Thread._stop`, which made `join()` raise + "TypeError: 'Event' object is not callable" and left `is_alive()` + stuck on True after the worker had finished. + """ + reader = StatusInfoReader(read_interval_ms=50) + assert callable(reader._stop) + + reader.open() + try: + time.sleep(0.5) + finally: + reader.close() + reader.join(timeout=10) + assert not reader.is_alive()