smileyface/docs/superpowers/plans/2026-05-30-python-version-support-matrix.md
Mathew Sir Guest the best dc52886b0e docs: add Python version support matrix implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:29:41 -06:00

21 KiB
Raw Blame History

Python Version Support Matrix Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Empirically determine and lock in the widest possible range of supported CPython versions (newest → oldest), enforced by a smoke-test suite run across every interpreter via uv + nox, and guarded in CI by a Gitea Actions matrix.

Architecture: uv provides every CPython interpreter on demand. A small pytest smoke suite (import every module, run every CLI --help, load settings) is the pass/fail oracle for "does this version work." nox runs that suite against each interpreter in an isolated venv built via uv, installing the project through its PEP 517 backend so pip resolves the best dependency versions that work on that interpreter — which is exactly how we discover the floor. Once discovered, the floor is encoded in pyproject.toml (python constraint, classifiers, black targets) and a Gitea Actions workflow mirrors the matrix.

Tech Stack: Python 3.93.13 (range to be confirmed empirically), Poetry (existing), uv (interpreter + venv provisioning), nox (matrix runner), pytest + click.testing.CliRunner (smoke tests), Gitea Actions (CI).


Background facts (verified against the codebase)

These were confirmed during planning — they justify the task ordering. Re-verify if the code has changed.

  • No version-gated syntax anywhere in smileyface/: no match/case, no runtime X | Y unions, no walrus, no 3.11+ stdlib. The code is syntactically 3.8-clean.
  • One version-gated stdlib import: smileyface/datalayer/db_ops.py:4from importlib.resources import files (added in 3.9; needs the importlib_resources backport on 3.8).
  • Undeclared runtime dependency (latent bug): selenium is imported at module top level (import selenium / import selenium.webdriver) in smileyface/scrape_latest/scrape_ut4pugs.py:1-2 and smileyface/scrape_latest/scrape_utcc.py:1-2, but pyproject.toml only declares pydantic-settings, click, platformdirs, sqlparse. Importing smileyface.scrape_latest fails without it. (requests is not used anywhere — do not add it.)
  • Dependency floors of currently-locked versions: pydantic-settings, click (8.2+), platformdirs (4.10) all require 3.10+. Older releases of each support 3.8/3.9, and pip/nox will select those automatically on older interpreters — which is why per-interpreter resolution (not the shared poetry.lock) is used for discovery.
  • No tests/, no CI currently exist. Entry point: smileyface.pyimport smileyface; smileyface.start_app()smileyface.cli.cli (a Click group).
  • CLI --help is side-effect-free: _make_ctx() (which builds settings/DB) is only called inside command bodies, so invoking any --help neither reads a DB nor needs real config. Smoke-testing --help is safe.

File Structure

File New/Modify Responsibility
noxfile.py Create Defines the tests session parametrized over the Python matrix; uses the uv venv backend.
tests/test_imports.py Create Imports every smileyface.* submodule; catches syntax/import/missing-dep breakage per interpreter.
tests/test_cli.py Create Invokes --help on the root group and every command via CliRunner.
tests/test_settings.py Create Verifies AppSettings defaults and SMILEYFACE_* env parsing.
pyproject.toml Modify Add selenium runtime dep; add pytest/nox dev deps; relax then finalize the python constraint; add classifiers; widen black targets; add pytest config.
smileyface/datalayer/db_ops.py Modify (stretch) importlib.resources backport shim, only if pursuing 3.8.
.gitea/workflows/test.yml Create CI matrix mirroring the discovered supported versions.
README.md Modify Document supported versions + how to run the matrix.
CLAUDE.md Modify Add nox commands and supported-version note to project guidance.

Task 1: Bootstrap uv and install the candidate interpreters

Files: none (environment setup)

  • Step 1: Confirm uv is installed

Run: uv --version Expected: prints a version (e.g. uv 0.5.x). If "command not found", install it: Run: curl -LsSf https://astral.sh/uv/install.sh | sh then restart the shell (or export PATH="$HOME/.local/bin:$PATH").

  • Step 2: Install every candidate interpreter, newest → oldest

Run: uv python install 3.13 3.12 3.11 3.10 3.9 3.8 Expected: each version downloads/installs (already-present ones are skipped).

  • Step 3: Verify they are visible to uv

Run: uv python list --only-installed Expected: lines for cpython-3.13, 3.12, 3.11, 3.10, 3.9, 3.8.

(No commit — this is local environment state.)


Task 2: Add pytest scaffolding and the import smoke test (expected RED)

This test is intentionally expected to fail first — it surfaces the undeclared selenium dependency before we fix it in Task 3.

Files:

  • Create: tests/test_imports.py

  • Modify: pyproject.toml (add pytest dev dep + pytest config)

  • Step 1: Add pytest to dev dependencies and configure test discovery

In pyproject.toml, under [tool.poetry.group.dev.dependencies], add pytest:

[tool.poetry.group.dev.dependencies]
black = "*"
pre-commit = "*"
isort = "*"
flake8 = "*"
pytest = "*"

Then append a pytest config block at the end of the file:

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra"
  • Step 2: Write the import smoke test

Create tests/test_imports.py:

import importlib
import pkgutil

import smileyface


def test_top_level_package_imports():
    importlib.import_module("smileyface")


def test_all_submodules_import():
    errors = []

    def _on_walk_error(name):
        errors.append(f"{name}: failed during package walk")

    module_names = [
        info.name
        for info in pkgutil.walk_packages(
            smileyface.__path__,
            prefix="smileyface.",
            onerror=_on_walk_error,
        )
    ]

    for name in module_names:
        try:
            importlib.import_module(name)
        except Exception as exc:  # noqa: BLE001 - we want every failure, not the first
            errors.append(f"{name}: {exc!r}")

    assert not errors, "Modules failed to import:\n" + "\n".join(errors)
  • Step 3: Run the import test on the newest interpreter and watch it fail

Run: uv run --python 3.13 --with . --with pytest pytest tests/test_imports.py -v Expected: FAIL — ModuleNotFoundError: No module named 'selenium' raised while importing smileyface.scrape_latest.*. This confirms the undeclared-dependency bug.

  • Step 4: Commit the test (red state is intentional and documented in the message)
git add tests/test_imports.py pyproject.toml
git commit -m "test: add import smoke test (reveals undeclared selenium dep)"

Task 3: Declare the missing runtime dependency (import test goes GREEN)

Files:

  • Modify: pyproject.toml (add selenium)

  • Step 1: Add the undeclared runtime dependency

In pyproject.toml, under [tool.poetry.dependencies], add selenium:

[tool.poetry.dependencies]
python = "^3.13"
pydantic-settings = ">=2.0"
click = ">=8.0"
platformdirs = ">=3.0"
sqlparse = "*"
selenium = ">=4.0"
  • Step 2: Re-run the import test on 3.13 and watch it pass

Run: uv run --python 3.13 --with . --with pytest pytest tests/test_imports.py -v Expected: PASS — every smileyface.* module imports.

  • Step 3: Commit
git add pyproject.toml
git commit -m "fix: declare selenium as a runtime dependency"

Task 4: CLI --help smoke test

Files:

  • Create: tests/test_cli.py

  • Step 1: Write the CLI smoke test

Create tests/test_cli.py:

from click.testing import CliRunner

from smileyface.cli import cli


def test_root_help():
    result = CliRunner().invoke(cli, ["--help"])
    assert result.exit_code == 0, result.output
    assert "SmileyFace" in result.output


def test_every_command_help():
    runner = CliRunner()
    for name in cli.commands:
        result = runner.invoke(cli, [name, "--help"])
        assert result.exit_code == 0, f"`{name} --help` failed:\n{result.output}"


def test_scrape_subcommands_help():
    runner = CliRunner()
    scrape_group = cli.commands["scrape"]
    for name in scrape_group.commands:
        result = runner.invoke(cli, ["scrape", name, "--help"])
        assert result.exit_code == 0, f"`scrape {name} --help` failed:\n{result.output}"
  • Step 2: Run it on 3.13

Run: uv run --python 3.13 --with . --with pytest pytest tests/test_cli.py -v Expected: PASS — root help, all top-level commands, and all scrape subcommands return exit code 0.

  • Step 3: Commit
git add tests/test_cli.py
git commit -m "test: add CLI --help smoke tests for every command"

Task 5: Settings smoke test

Files:

  • Create: tests/test_settings.py

  • Step 1: Write the settings test

Create tests/test_settings.py. Passing _env_file=None prevents a stray local .env from leaking into the assertions:

from smileyface.settings import AppSettings


def test_defaults():
    settings = AppSettings(_env_file=None)
    assert settings.sqlite_filename == "smiles.db"
    assert settings.skip_validate is False
    assert settings.project_dir == ""


def test_env_overrides(monkeypatch):
    monkeypatch.setenv("SMILEYFACE_PROJECT_DIR", "/srv/ut4")
    monkeypatch.setenv("SMILEYFACE_SKIP_VALIDATE", "true")
    settings = AppSettings(_env_file=None)
    assert settings.project_dir == "/srv/ut4"
    assert settings.skip_validate is True
  • Step 2: Run it on 3.13

Run: uv run --python 3.13 --with . --with pytest pytest tests/test_settings.py -v Expected: PASS — both tests pass (env var override + bool coercion).

  • Step 3: Run the full suite on 3.13 to confirm everything is green before matrixing

Run: uv run --python 3.13 --with . --with pytest pytest -v Expected: PASS — all tests across the three files.

  • Step 4: Commit
git add tests/test_settings.py
git commit -m "test: add AppSettings defaults and env-parsing smoke tests"

Task 6: Relax the python constraint to enable discovery on older interpreters

pip refuses to install a project on an interpreter that violates its requires-python. The current ^3.13 blocks installs on 3.83.12, so we temporarily widen it. It will be tightened to the discovered floor in Task 10.

Files:

  • Modify: pyproject.toml (python constraint)

  • Step 1: Widen the python constraint

In pyproject.toml, change the python line under [tool.poetry.dependencies]:

python = ">=3.8,<4.0"
  • Step 2: Sanity-check the project still builds/installs on 3.13

Run: uv run --python 3.13 --with . --with pytest pytest -v Expected: PASS.

  • Step 3: Commit
git add pyproject.toml
git commit -m "build: temporarily widen python constraint to >=3.8 for version discovery"

Task 7: Create the nox matrix runner

Files:

  • Create: noxfile.py

  • Modify: pyproject.toml (add nox dev dep)

  • Step 1: Add nox to dev dependencies

In pyproject.toml, under [tool.poetry.group.dev.dependencies], add nox:

nox = "*"
  • Step 2: Write noxfile.py

Create noxfile.py at the repo root. The uv venv backend makes each session build via uv, and session.install(".") triggers per-interpreter dependency resolution:

import nox

nox.options.default_venv_backend = "uv"
nox.options.reuse_existing_virtualenvs = False

# Newest first. 3.8 is a stretch goal (see Task 9 / Task 8 backport).
PYTHON_VERSIONS = ["3.13", "3.12", "3.11", "3.10", "3.9", "3.8"]


@nox.session(python=PYTHON_VERSIONS)
def tests(session):
    """Install the project + pytest and run the smoke suite on each interpreter."""
    session.install(".")
    session.install("pytest")
    session.run("pytest", "-v")
  • Step 3: Verify nox sees every session

Run: uv run --with nox nox --list Expected: lists tests-3.13 through tests-3.8.

  • Step 4: Commit
git add noxfile.py pyproject.toml
git commit -m "build: add nox matrix runner for the Python version smoke suite"

Task 8: (Stretch) Add the importlib.resources backport for 3.8

Do this task only if you intend to attempt 3.8 (and only if Task 9 shows dependencies can resolve on 3.8). On 3.9+ the shim is a transparent no-op, so it is safe to land regardless.

Files:

  • Modify: smileyface/datalayer/db_ops.py:4

  • Modify: pyproject.toml (conditional backport dep)

  • Step 1: Replace the hard import with a version-tolerant shim

In smileyface/datalayer/db_ops.py, replace line 4:

from importlib.resources import files

with:

try:
    from importlib.resources import files  # Python 3.9+
except ImportError:  # Python 3.8
    from importlib_resources import files
  • Step 2: Declare the backport for 3.8 only

In pyproject.toml, under [tool.poetry.dependencies], add an environment-marked dependency:

importlib-resources = { version = ">=5.0", python = "<3.9" }
  • Step 3: Verify 3.9+ is unaffected

Run: uv run --python 3.9 --with . --with pytest pytest tests/test_imports.py -v Expected: PASS (uses the stdlib branch; backport not installed).

  • Step 4: Commit
git add smileyface/datalayer/db_ops.py pyproject.toml
git commit -m "compat: fall back to importlib_resources backport on Python 3.8"

Task 9: Run the discovery matrix (newest → oldest) and record results

Files: none (data collection)

  • Step 1: Run the full matrix

Run: uv run --with nox nox -s tests Expected: nox runs tests-3.13tests-3.8 in turn. Some older sessions may fail at install (a dependency such as pydantic-core has no wheel / drops support for that interpreter) or at import/test. That is the signal we want.

  • Step 2: Re-run any failed session in isolation to capture the precise cause

For each version X that failed, run: uv run --with nox nox -s tests-X Expected: a clear error — distinguish resolution/install failure (dependency floor) from test failure (our code). Record the first line of the error.

  • Step 3: Fill in the results table

Record outcomes here (replace each ?):

Version Install Tests First failure (if any)
3.13 ? ?
3.12 ? ?
3.11 ? ?
3.10 ? ?
3.9 ? ?
3.8 ? ?

The supported floor = the lowest version where both Install and Tests pass. Note it here: FLOOR = 3.__.

  • Step 4: Commit the recorded results
git add docs/superpowers/plans/2026-05-30-python-version-support-matrix.md
git commit -m "docs: record Python version support discovery results"

Task 10: Lock in the discovered floor

Use the FLOOR value from Task 9. The examples below assume FLOOR = 3.9; substitute the actual floor in every spot (constraint, classifiers, black targets, and the version list).

Files:

  • Modify: pyproject.toml

  • Step 1: Set the final python constraint to the floor

In pyproject.toml, set (example for floor 3.9):

python = ">=3.9,<4.0"
  • Step 2: Trim noxfile.py to only the supported versions

In noxfile.py, set PYTHON_VERSIONS to exactly the green versions (example for floor 3.9):

PYTHON_VERSIONS = ["3.13", "3.12", "3.11", "3.10", "3.9"]
  • Step 3: Widen black target-version and add classifiers

In pyproject.toml, change the black target to span the supported range (example for floor 3.9):

[tool.black]
line-length = 120
target-version = ['py39', 'py310', 'py311', 'py312', 'py313']

And add a classifiers list under [tool.poetry] (example for floor 3.9):

classifiers = [
    "Programming Language :: Python :: 3.9",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
    "Programming Language :: Python :: 3.13",
]
  • Step 4: Refresh the lock file for the new range

Run: uv run poetry lock Expected: succeeds. Note: broadening the range can downgrade shared dev/runtime deps to versions compatible with the floor (e.g. click capped below 8.2 because 8.2 requires 3.10). This is the expected cost of wider support.

  • Step 5: Re-run the trimmed matrix to confirm all-green

Run: uv run --with nox nox -s tests Expected: every remaining session PASSES.

  • Step 6: Commit
git add pyproject.toml noxfile.py poetry.lock
git commit -m "build: set supported Python floor to 3.9 and widen tooling targets"

Task 11: Add the Gitea Actions CI matrix

Files:

  • Create: .gitea/workflows/test.yml

  • Step 1: Write the workflow

Create .gitea/workflows/test.yml. Set the python-version list to exactly the supported versions from Task 10 (example for floor 3.9):

name: tests

on:
  push:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
    steps:
      - uses: actions/checkout@v4

      - name: Install uv
        uses: astral-sh/setup-uv@v5

      - name: Install Python ${{ matrix.python-version }}
        run: uv python install ${{ matrix.python-version }}

      - name: Create venv
        run: uv venv --python ${{ matrix.python-version }}

      - name: Install project and pytest
        run: uv pip install . pytest

      - name: Run smoke tests
        run: uv run pytest -v
  • Step 2: Validate the YAML locally

Run: uv run python -c "import yaml, pathlib; yaml.safe_load(pathlib.Path('.gitea/workflows/test.yml').read_text()); print('yaml ok')" Expected: prints yaml ok.

  • Step 3: Commit and push so the runner picks it up
git add .gitea/workflows/test.yml
git commit -m "ci: add Gitea Actions Python version matrix"

Note: this requires a registered Gitea Actions runner on git.zavage.net. If none is registered yet, the workflow is still valid and version-controlled; it will execute once a runner is available. After pushing, confirm the run under the repo's Actions tab.


Task 12: Update documentation and final verification

Files:

  • Modify: README.md

  • Modify: CLAUDE.md

  • Step 1: Document supported versions and the matrix workflow in README

In README.md, under the Installation section, add (example for floor 3.9):

## Supported Python Versions

SmileyFace is tested against CPython 3.9  3.13. The supported range is
enforced by a smoke-test matrix.

To run the matrix locally (requires [uv](https://docs.astral.sh/uv/)):

    uv python install 3.9 3.10 3.11 3.12 3.13
    uv run --with nox nox -s tests
  • Step 2: Add the matrix commands to CLAUDE.md

In CLAUDE.md, under the ## Commands section, add before the closing fence of the code block:

# Run the smoke-test suite on the current interpreter
uv run --with . --with pytest pytest -v

# Run the full Python version matrix
uv run --with nox nox -s tests

And change the line There is no test suite. to:

Smoke tests live in `tests/` (import, CLI `--help`, and settings checks) and run across the supported Python matrix via `nox`.
  • Step 3: Final full verification

Run: uv run --with nox nox -s tests Expected: every supported session PASSES.

Run: uv run --python 3.13 --with . --with pytest pytest -v Expected: PASS (quick single-interpreter confirmation).

  • Step 4: Commit
git add README.md CLAUDE.md
git commit -m "docs: document supported Python versions and the test matrix"

Self-Review

Spec coverage:

  • "Test each version of Python for support" → Tasks 1, 7, 9 (install interpreters, nox matrix, discovery run).
  • "From the newest backwards" → version lists are ordered newest→oldest; discovery records the floor (Task 9).
  • "Support as many versions as I can" → Task 6 widens the constraint for discovery; per-interpreter pip resolution (Task 7) auto-selects older compatible deps; Task 8 reaches for 3.8 via backport; Task 10 locks the lowest green version.
  • "Public-facing distributable app" → classifiers + finalized constraint (Task 10), CI guard (Task 11), user-facing docs (Task 12).

Placeholder scan: The only intentional fill-ins are Task 9's results table and the floor value, which are data to be collected by running the matrix (not undefined code). Every code/config step contains complete content. Example-floor values (3.9) are explicitly flagged "substitute the actual floor."

Type/identifier consistency: cli (Click group) and its .commands mapping are used consistently in tests/test_cli.py; AppSettings(_env_file=None) matches the pydantic-settings constructor; files import name in the Task 8 shim matches its existing usage in db_ops.py; the nox session name tests is referenced identically in Tasks 7, 9, 10, 12. PYTHON_VERSIONS is the single source of truth trimmed in Task 10.

Known risk: If Task 9 shows even 3.9 cannot resolve pydantic-core (no compatible wheel), the floor is 3.10 — set every example 3.9 to 3.10 and drop Task 8. This is expected behavior, not a plan defect.