Skip to content

Fix CI dependency drift - #16

Open
JeyKip wants to merge 4 commits into
dualentry:mainfrom
JeyKip:fix/ci-dependency-drift
Open

Fix CI dependency drift#16
JeyKip wants to merge 4 commits into
dualentry:mainfrom
JeyKip:fix/ci-dependency-drift

Conversation

@JeyKip

@JeyKip JeyKip commented Aug 15, 2026

Copy link
Copy Markdown

Summary

CI was green on main but would have failed on the next run — on both the test and lint jobs — without a single line of our code changing. Two upstream releases landed after our last commit and silently changed the behaviour of uv sync --dev, which CI runs with no lockfile.

This PR fixes both failures, commits uv.lock so that dependency changes can only arrive deliberately, and makes CI enforce the lockfile with uv sync --dev --locked.

Nothing here is a behaviour change for users of the CLI. The one user-visible path that was broken — the "Did you mean …?" suggestion on an unknown command — is restored to its intended behaviour.

Why this appeared now

Our last commit to main was 2026-04-22. Because uv.lock was gitignored, every CI run re-resolved dependencies from scratch against whatever PyPI served that day. Two releases after 2026-04-22 changed what that resolution produces:

Date Event Effect on us
2026-04-22 Last commit on main (#15) — CI green Resolved typer 0.25.x, ruff 0.15.x
2026-05-26 typer 0.26.0 released click disappears from the environment → test job breaks
2026-07-23 ruff 0.16.0 released Two new rules enter select = ["ALL"]lint job breaks

Both are time-triggered, not code-triggered. The green checkmarks on main are real; they just describe a dependency set that no longer resolves.

Changes

Problem 1 — ModuleNotFoundError: No module named 'click'

Symptom

uv run pytest aborts during collection:

src/dualentry_cli/cli.py:5: in <module>
    import click
E   ModuleNotFoundError: No module named 'click'

Root cause

src/dualentry_cli/cli.py imported click directly, but click was never declared in [project.dependencies]. It was only ever present as a transitive dependency of typer.

typer 0.26.0 (released 2026-05-26) vendored Click into typer/_click and dropped it as an external dependency, in PR #1774, merged 2026-05-26. The PR vendors Click 8.3.1 into a typer/_click subdirectory.

Our constraint was typer>=0.12,<1.0, so a fresh resolve picks the newest — today 0.27.1 — and click is simply never installed.

The second-order bug

Declaring click as a dependency would have fixed the import but left a subtler defect in place. HelpfulGroup.resolve_command caught click.UsageError, but since 0.26.0 TyperGroup raises the vendored exception, which is an unrelated class: typer._click.exceptions.UsageError is click.exceptions.UsageError → False.

The except clause would never match, silently turning our custom suggestion handler into dead code and falling back to typer's built-in message. So the fix is to drop click entirely rather than declare it.

Fix

  • cli.py now imports UsageError from typer._click.exceptions and uses typer.echo (publicly re-exported by typer) instead of click.echo.
  • click is not added as a dependency — it is no longer imported anywhere.
  • The typer floor is raised to >=0.26, because typer._click does not exist below it. The previous >=0.12 floor was already a false claim: the code could not have run on 0.12–0.25 as written once click was removed from the environment.

Why keep the override at all

typer provides its own suggestions, but at difflib's default cutoff=0.6, while ours uses cutoff=0.4. Here is a real difference between these two values:

Input Best match typer default (0.6) ours (0.4)
bank bank-transfers no suggestion
fixed fixed-assets no suggestion
prepay vendor-prepayments no suggestion

Deleting the override in favour of typer's built-in would have silently regressed all of these.

Regression coverage

This path had no test coverage, which is why the breakage went unnoticed. Added TestUnknownCommandSuggestions covering a typo, the short-prefix case that pins the 0.4 cutoff specifically, and an unmatchable input. The tests assert on our wording (Unknown command '…'), so they fail if the handler is ever bypassed and typer's No such command message appears instead.

Problem 2 — 26 new lint errors

Symptom

uv run ruff check . reports 26 errors — 21 × CPY001, 5 × PLR0917 — against code nobody had touched.

Root cause

ruff 0.16.0 (released 2026-07-23) stabilized both rules out of preview:

Our config uses select = ["ALL"], which opts in to every rule ruff has and every rule it will ever add. A stabilized preview rule therefore becomes a CI gate with no action on our side. Verified by pinning ruff:

ruff version Released ruff check src/ tests/
0.15.11 2026-04-16 clean
0.16.0 2026-07-23 26 errors

Fix

CPY001 → ignored. The rule requires a copyright header on every file. This project ships no LICENSE file and declares no license in pyproject.toml, so there is no copyright statement to assert. Adding 21 headers for an undefined license would be worse than not having them.

PLR0917 → fixed properly. All five sites were genuine long signatures, and every call site already passed the extra arguments by keyword, so marking them keyword-only with * codifies the convention already in use rather than changing any behaviour:

Function positional args before → after
make_resource_app 12 → 3
_do_list 7 → 3
list_cmd 11 → 0
_transaction_list 9 → 4
_transaction_detail 6 → 4

Two _do_list call sites were updated to pass keywords.

Problem 3 (root cause) — uv.lock was gitignored

Both failures above share one cause: CI and contributors re-resolved dependencies on every run.

uv.lock had been in .gitignore since the initial commit and was never tracked. That means:

  • Releases were not reproducible. release.yml runs uv sync --dev and then freezes the resolved dependencies into a PyInstaller binary. Rebuilding a given tag today produces a binary with different dependency versions than the original build — the tag did not determine the artifact. pyinstaller>=6.0 is itself unpinned, so the packaging tool drifted too.
  • No hash verification. uv.lock records a sha256 for each of the 56 packages. Without it, release builds installed whatever PyPI served.
  • Contributors got different environments from the same commit, depending only on when they ran uv sync.

Committing the lockfile is the standard practice for an application (as opposed to a library), which this is.

Enforcing it in CI

Committing the lockfile is only half of it — nothing yet stops the lockfile from drifting out of sync with pyproject.toml. Both ci.yml jobs now use:

- run: uv sync --dev --locked

--locked verifies the lockfile is up to date with pyproject.toml and fails instead of silently re-resolving. So changing a dependency without running uv lock is now a CI failure rather than an invisible divergence between what the lockfile claims and what CI actually installs. The lockfile is resolved universally, so a single lock serves the whole 3.11 / 3.12 / 3.13 test matrix.

release.yml deliberately keeps plain uv sync --dev. It rewrites the version in pyproject.toml from the git tag before syncing, and the lockfile records the root package's own version (uv.lockversion = "0.1.17"), so the stamp makes the lockfile stale by construction.

Test plan

  • Unit tests pass (uv run pytest)
  • Linter passes (uv run ruff check .)
  • Manually tested with dualentry <command>

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