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

634 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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:4``from 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.py``import 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`:
```toml
[tool.poetry.group.dev.dependencies]
black = "*"
pre-commit = "*"
isort = "*"
flake8 = "*"
pytest = "*"
```
Then append a pytest config block at the end of the file:
```toml
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra"
```
- [ ] **Step 2: Write the import smoke test**
Create `tests/test_imports.py`:
```python
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)**
```bash
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`:
```toml
[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**
```bash
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`:
```python
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**
```bash
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:
```python
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**
```bash
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]`:
```toml
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**
```bash
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`:
```toml
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:
```python
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**
```bash
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:
```python
from importlib.resources import files
```
with:
```python
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:
```toml
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**
```bash
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.13``tests-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**
```bash
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):
```toml
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
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):
```toml
[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):
```toml
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**
```bash
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):
```yaml
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**
```bash
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):
```markdown
## 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:
```bash
# 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:
```markdown
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**
```bash
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.