Skip to content

chore: adopt the portfolio Python project standard - #152

Open
pofallon wants to merge 2 commits into
mainfrom
chore/python-project-standard
Open

pofallon wants to merge 2 commits into
mainfrom
chore/python-project-standard

Conversation

@pofallon

@pofallon pofallon commented Aug 30, 2026 •

Copy link
Copy Markdown
Contributor

Applies the get2knowio Python Project Standard — toolchain, gates, and release shape — to Hangar, along with the build-surface and hatch-vcs standards it depends on.

Reviewing tip: 68 of the 85 changed files are a one-time ruff format pass. git diff -w or reviewing everything except backend/src/** and backend/tests/** gets you the substance.

Verified

All four gates green on both matrix legs — 310 tests, format/lint/types clean, coverage floor met on 3.12 and 3.14. The image builds and reports a tag-derived version with no git history in the runtime layer.

What changed

Toolchain & packaging — hatch-vcs versioning, dev tooling moved to PEP 735 [dependency-groups], [tool.uv] exclude-newer, requires-python bounded at >=3.12,<3.15.

Build surface — new root mise.toml with the seven canonical verbs (mise tasks, no justfile). CI now runs mise run setup / mise run check and spells out no gate inline. ruff format --check is gated for the first time; ruff moves to the standard explicit select list and stops ignoring E501.

CI — exercises both ends of requires-python. The version-free Backend job stays the required status check and gates on the matrix, so an interpreter bump never invalidates branch protection.

Supply chain & release — permissions: + concurrency: on 5/5 workflows (was 1/5); Dependabot groups production minor/patch too; release.yml gains a test gate and a GitHub Release with generated notes.

Bugs found while applying it

  • Version drift: pyproject.toml said 0.3.11, src/hangar/__init__.py said 0.1.0. Exactly the class hatch-vcs closes.
  • The matrix could have lied. uv sync silently reuses an existing .venv regardless of the mise pin — my first "3.12" run was actually 3.14. backend:setup now binds --python to the resolved interpreter.
  • Node pinned twice, differently: devcontainer said 22; CI, mise and the Dockerfile said 26.
  • Pre-release detection was broken: the old contains(ref_name, '-') test would have published v1.0.0rc1 as :latest. Now matches both PEP 440 canonical and dashed forms.
  • A lint autofix broke typing: ruff's C416 rewrite of a SQLAlchemy row comprehension to dict() dropped row typing; .tuples() satisfies both gates.

Decisions worth a second opinion

Coverage floor is 89, and the 3.12/3.14 gap turned out to be a measurement bug (fixed in a145d2f). The suite originally reported 87% on 3.14 but 80% on 3.12. That was not a behavioural difference:

  1. branch = true was set in the coverage config.
  2. coverage's sysmon core doesn't support branch coverage before Python 3.14, so 3.12 silently fell back to the older sys.settrace core.
  3. sys.settrace can't see code running in the worker thread Starlette's TestClient drives the app from — so every FastAPI BackgroundTasks job was invisible to it.

services/sync.py is reached almost entirely through a BackgroundTask (the resync queued by PUT /providers/{id}/repos), which is why the gap concentrated there.

Verified rather than inferred: on a single interpreter (3.14) the core alone moves sync.py between 32% (ctrace), 32% (pytrace) and 46% (sysmon); and a probe wrapping SyncService.sync_connection shows it starting and completing identically on both interpreters. The code always ran — only the measurement differed.

Fixed by turning branch coverage off (so sysmon is usable on 3.12) and pinning COVERAGE_CORE=sysmon from mise.toml. Both legs now report an identical 89.90% and the same 338 uncovered statements. The floor is 89 — the earlier 80 was never a real measurement of this suite, just what the blinded core produced.

Tradeoff worth a look: this gives up branch coverage to get one trustworthy number on every leg. The alternative is keeping branch coverage and enforcing the floor only on the 3.14 leg. Happy to switch if you'd rather keep branch data.

Two claims in the standard don't match the repo. Starship is installed (modern-cli-tools with {} installs everything by default). And Hangar isn't in the hatch-vcs rollout list, though the rule table does cover it — I applied it because the drift was live.

Not done

  • devcontainer-lock.json — needs the devcontainer CLI, which isn't available in the container; the file is resolved digests I won't fabricate. Run devcontainer upgrade --workspace-folder ..
  • Prettier gate — reports 19 unformatted frontend files including the generated api-types.ts. Needs a .prettierignore first, and it's outside a Python standard.
  • frontend/package.json still carries a hand-bumped 0.3.11, now inconsistent with the backend's derived version.

The devcontainer changes (base image → ubuntu-24.04, mise feature, Node 26, MISE_TRUSTED_CONFIG_PATHS) need a container rebuild to take effect.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VLmETZdERJ6RFCdwZRdv4a

Paul O'Fallon and others added 2 commits August 30, 2026 02:55
Applies the get2knowio Python Project Standard (toolchain, gates, and release
shape) to Hangar. The bulk of the source diff is a one-time `ruff format` pass;
the substantive changes are below.

Toolchain and packaging (backend/pyproject.toml):
- Derive the version from git tags via hatch-vcs. This closes live drift: the
  manifest said 0.3.11 while src/hangar/__init__.py said 0.1.0. hatch-vcs needs
  `raw-options = { root = ".." }` because it does not search parent directories
  for .git in a monorepo.
- Move dev tooling into PEP 735 [dependency-groups]; optional-dependencies now
  holds only the real `postgres` extra a consumer would install.
- Add [tool.uv] exclude-newer, the resolver-side twin of the Dependabot cooldown.
- Bound requires-python at >=3.12,<3.15 so the range makes no untested claim.

Gates:
- New root mise.toml defines the seven canonical verbs. CI now runs `mise run
  setup` / `mise run check` and spells out no gate inline, so what CI checks and
  what a contributor runs cannot drift.
- `ruff format --check` is now gated; it previously was not. Ruff moves to the
  standard explicit `select` list and stops ignoring E501.
- pytest gains --strict-markers, --strict-config and --timeout=30.
- CI exercises both ends of requires-python (3.12 and 3.14). The floor had never
  been run. The version-free `Backend` job remains the required status check and
  gates on the matrix, so an interpreter bump never invalidates branch
  protection.
- Coverage floor set to 80, the minimum measured across the matrix rather than
  the best leg: the suite covers 87% on 3.14 but 80% on 3.12.

Supply chain and release:
- Add top-level permissions: and concurrency: to every workflow (was 1 of 5).
- Dependabot now groups production minor/patch as well as development.
- release.yml gains a test job gating the publish and a GitHub Release with
  generated notes. Pre-release detection now matches both PEP 440 canonical and
  dashed forms; the previous substring test would have published v1.0.0rc1 as
  :latest.

Also fixed along the way:
- ruff's C416 rewrite of a SQLAlchemy row comprehension to dict() dropped row
  typing; .tuples() satisfies both the lint and type gates.
- `uv sync` silently reuses an existing .venv regardless of the mise pin, so a
  matrix leg could claim to test 3.12 while testing whatever was already there.
  backend:setup now binds --python to the resolved interpreter.
- The devcontainer pinned Node 22 while CI, mise and the Dockerfile used 26.
- The Dockerfile builds the wheel in a discarded stage that has .git, so the
  runtime image gets a tag-derived version without shipping git history.

tests/unit/test_toolchain_consistency.py enforces the interpreter-agreement rule,
the version-free required-check names, and the dependency-group split as tests
rather than comments — a principle that isn't a test is a suggestion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLmETZdERJ6RFCdwZRdv4a
The 7-point coverage difference between 3.12 (80%) and 3.14 (87%) was a
measurement artifact, not a behavioural one. Root cause, in order:

1. `branch = true` was set in the coverage config.
2. coverage's sys.monitoring core ("sysmon") does not support branch coverage
   before Python 3.14, so on 3.12 coverage silently fell back to the older
   sys.settrace core.
3. sys.settrace does not see code executing in the worker thread Starlette's
   TestClient drives the app from, so every FastAPI BackgroundTasks job was
   invisible to it.

services/sync.py is reached almost entirely through a BackgroundTask (the resync
queued by PUT /providers/{id}/repos), which is why the gap concentrated there.

Verified rather than inferred: on a single interpreter (3.14) the core alone
moves sync.py between 32% (ctrace), 32% (pytrace) and 46% (sysmon), and a probe
wrapping SyncService.sync_connection shows it starting and completing
identically on both interpreters. The code always ran; only the measurement
differed.

Fix: turn branch coverage off so sysmon is usable on 3.12, and pin
COVERAGE_CORE=sysmon from mise.toml (coverage reads the core from the
environment only, never from pyproject.toml). Both legs now report an identical
89.90% and the same 338 uncovered statements, so one floor is meaningful
everywhere.

The floor moves 80 -> 89. The previous 80 was not a real measurement of this
suite; it was the number the blinded core produced.

CONTRIBUTING.md gains a Coverage section covering the footgun this leaves
behind: a bare `pytest --cov` on 3.12 still measures with the wrong core and
reports a false failure, so coverage must be run through `mise run`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLmETZdERJ6RFCdwZRdv4a
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant