Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
10 KiB
app_skellington modernization — design
Date: 2026-10-04 · Status: draft for review
Goal
Keep the library's existing behaviour while modernizing the tooling and backing every compatibility claim with CI. After that, harden the code. The first user-visible result is a 0.2.3 hotfix release. Every install of the current PyPI release (0.2.2) is broken: import app_skellington raises ImportError ... partially initialized module ... (most likely due to a circular import). The fix (d155176) is already on main but was never released.
Decisions (agreed in brainstorming)
| Topic | Decision |
|---|---|
| Python floor | Keep requires-python >=3.8. When an EOL version starts failing, drop it rather than contort the code (policy recorded in CLAUDE.md). |
| Platforms | Claim only what's tested: Linux, CPython 3.8–3.14. Windows and macOS are a stated long-term goal for this repo, but out of scope until a runner exists. |
| Formatter/linter | ruff replaces black, isort and flake8. |
| Line length | 88: the black and ruff default and the de facto modern standard. main is already formatted at 88. Measured churn: 4 files, cosmetic only (assert-message wrapping). |
| Reformatting | Goes in its own style-only PR. |
| Build system | Keep pyproject + setuptools + setuptools_scm (tag-driven versions). Add uv for envs, the lockfile, builds and publishing. Poetry is already gone from main. |
| PyPI | Nothing gets published until CI is green across the matrix. Each release goes to TestPyPI first. Publishing stays manual (Mathew's credentials); there's no publish job in CI for now. |
| Branches | Mathew recreates develop from main. All PRs below target develop, develop goes to main by PR, and release tags go on main. Stale PR #2 (develop → main, 2024) gets closed as superseded. |
Current state (verified 2026-10-04)
- The suite passes on 3.8, 3.9, 3.10, 3.12, 3.13 and 3.14 from
main, but it covers onlycfgplus one CommandTree constructor smoke test. Passing it says little about CLI, DI or logging. app_skellington/_version.pyis tracked, yet setuptools_scm rewrites it on every install or build, so every dev run leaves a dirty tree. (The generated file usesfrom __future__ import annotations, and running it on 3.8 was verified to work.)- Lint isn't clean: black would reformat 2 files, isort flags 3, flake8 reports W391 and W503.
- The README names the wrong debug env var (
APPSKELLINGTON_ENABLE_LOGGING; the code readsAPPSKELLINGTON_DEBUG). It also claims Windows and Mac support that nothing tests.
Delivery: five PRs in order, then the release
PR 1: style (ruff, format only)
pyproject.toml:[tool.ruff]withline-length = 88andtarget-version = "py38", excludingapp_skellington/_version.py. Lint rules areselect = ["E", "F", "W", "I"], matching the old flake8 + isort coverage.- Any existing violation that
ruff check --fixcan't fix safely gets a per-ruleignorewith a comment. Each one is removed during hardening (PR 5). This PR changes no logic. - Apply
ruff formatand the saferuff check --fix(import order). - Delete
.flake8. In.pre-commit-config.yaml, replace the black, isort and flake8 hooks withruff-pre-commit(ruff-check --fix,ruff-format). That also drops thepython3.8pin, which breaks on machines without 3.8. - Done when
ruff checkandruff format --checkare clean, tests pass, and the diff is whitespace and import order only.
PR 2: tooling (uv), packaging hygiene, docs
[dependency-groups] dev = ["ruff", "pytest", "pre-commit", "pyyaml"](PEP 735) replaces[project.optional-dependencies] dev, so dev tools are no longer extras on the published package. Commituv.lock. uv resolves one lock across all Pythons in range, so 3.8 lanes get compatible older pytest and other tools with no hand pinning.- Move
tests/pytest.iniinto[tool.pytest.ini_options](withtestpaths = ["tests"]), so plainuv run pytestfrom the repo root picks up the config. .python-version: set the dev default to one current version (3.14). CI covers the rest.- Untrack
_version.pyand add it to.gitignore. setuptools_scm still generates it at build time, so the sdist and wheel ship it. Nothing in the package imports it. - Commands become
uv sync,uv run pytest,uv run ruff ...,uv buildanduv publish. - README:
- fix the debug env var
- replace the pyenv and pip dev setup with uv
- drop the untested Windows/Mac claim; PR 3 adds the tested-platform line once CI backs it
- update the test and publish instructions
- leave the claim "config.ini is created if missing" until PR 4 confirms it; correct it there if the test disagrees
- Update CLAUDE.md to match: commands, the branch section after
developis recreated, and the lint and_version.pynotes.
PR 3: Gitea CI (checks only)
.gitea/workflows/ci.yaml, following ~/.claude/docs/gitea-ci-pattern.md:
- Triggers:
pull_request(typesopened, synchronize, reopened, edited),push: [develop, main],workflow_dispatch. Concurrency cancels superseded PR runs only. - WIP gate on every job that runs on
pull_request: skip when the title starts withWIP:or[WIP], and nothing else. lint: one Python. Runsuv run ruff checkanduv run ruff format --check.test: matrix overpython: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"],fail-fast: false. Containerpython:<v>. The first step installsnodejs(Gitea actions are node programs), then uv. Checkout usesfetch-depth: 0, because setuptools_scm needs tags. Then each lane:uv sync --locked, thenuv run pytest.- Packaging smoke test:
uv build, install the built wheel into a fresh venv, thencd /(away from the source tree) and runpython -c "import app_skellington; ...". This is the check that would have caught 0.2.2.
ci-okaggregate job,needs: [lint, test], withif: always()plus the WIP gate. It fails unless every needed job's result issuccess.- Why it runs
always(): Gitea reports a skipped job as asuccessstatus. A plainneeds:aggregate would go green when a needed job failed. Thealways()+ explicit check closes that hole. - Branch protection then requires only
CI / ci-ok (pull_request), so one required check covers every matrix lane.
- Why it runs
tests/test_ci_workflow.pypins the invariants: triggers, WIP gate on each PR job, matrix versions equal to therequires-pythonfloor through the newest, and theci-oklogic. CI drift then shows up as a red test.- README: add "Tested on Linux, CPython 3.8–3.14 (CI). Windows/macOS: goal, untested."
- Rollout per the playbook: run every gate locally first, then canary one gate at a time (a lint failure, then a test failure), watch it go red, revert, watch it go green. For each canary, confirm that
CI / ci-ok (pull_request)itself reports failure, not just the broken lane. This provesif: always()andneeds.<job>.resultevaluate as expected on this act_runner, since the pattern doc never canaried them. Mathew then adds the branch-protection status check.
Release 0.2.3 (hotfix, after PR 3 is green on main)
- Promote
develop→mainby PR, with CI green. - Tag
v0.2.3onmain, thenuv build. uv publishto TestPyPI. Install it from TestPyPI on every matrix Python (a script underscripts/runninguv run --isolated --python X --index testpypi) and import it.uv publishto PyPI. Mathew does this step; whether to yank 0.2.2 afterwards is his call.- CHANGELOG entry.
PR 4: characterization tests
These pin current behaviour before any hardening changes it. Known bugs get tests marked xfail(strict=True), each with the bug named, so a fix flips the test and forces it to be updated.
- CLI: single-command and submenu dispatch, nested submenus, defaults versus required positionals, help text from the first docstring sentence,
NoCommandSpecified, option placement (root versus command). - Container and DI: factory registration,
ServiceNotFound, constructor-parameter-name injection,register_class_as_commands(fresh instance per call, public methods only), the hook order_cli_options→_services→_command_menu. - Logging:
transform_config(root→"", level strings to ints,version=1, relative file path → user log dir, using a tmp dir),APPSKELLINGTON_DEBUGon and off. - Config: whether config.ini is created when missing (then correct the README to match),
$varinterpolation, spec defaults, validation failure returningFalse. - Public API: everything
__init__.pyexports can be imported.
PR 5+: hardening, one fix per PR or small groups
Each fix flips its xfail from PR 4. Known so far:
Config.__delitem__recurses forever (it callsdel self[key])._util.get_assethas a brokenisinstance(module, module)check.cli._lookup_commandcan stop atinput("<broken>").logger.warnis deprecated; usewarning._load_config_filesswallows broad exceptions.Config.__contains__returnsNoneon error.- Remove the per-rule ruff ignores added in PR 1.
- Issue #3: appdirs → platformdirs, with a path-parity check.
The public API (the names exported from __init__.py) stays stable.
Out of scope (recorded, not planned)
- Windows and macOS CI runners. This is the long-term multi-platform goal and needs infrastructure.
- A PyPI publish job in CI (would need the deferred credential model in the CI pattern doc).
- A PyPy lane and type checking. Both can be revisited once the matrix is stable.
Risks
- Old Python images:
python:3.8images are frozen, since 3.8 is EOL. If apt or nodejs installs inside them break, the policy applies: drop the lane and raise the floor rather than build workarounds. - Lockfile resolution on 3.8: some dev tools may have no 3.8-compatible release. uv's fork resolution should handle this; if it can't, that's the trigger to raise the floor.
- setuptools_scm in CI needs full history and tags (
fetch-depth: 0,safe.directory). Without them the version falls back to0.0.0, and the smoke test should assert that it doesn't.