Fix CI dependency drift - #16
Conversation
| requires-python = ">=3.11" | ||
| dependencies = [ | ||
| "typer>=0.12,<1.0", | ||
| # Floor is 0.26, not 0.12: typer 0.26.0 (2026-05-26) vendored click into |
There was a problem hiding this comment.
Breaking change — typer floor version raised to 0.26: The constraint changed from typer>=0.12,<1.0 to typer>=0.26,<1.0. Typer 0.26 vendored click into typer._click and removed click as a transitive dependency. Any code importing click directly will fail at runtime on typer >=0.26 because click is no longer installed. The codebase itself was updated (cli.py now imports from typer._click.exceptions), but grep the full codebase for any other imports of click in production code, tests, or management commands. If any exist, they will break on deploy.
|
|
||
|
|
||
| def _do_list(client, path: str, resource: str, limit: int, offset: int, all_pages: bool, output: str, **filters): | ||
| def _do_list(client, path: str, resource: str, *, limit: int, offset: int, all_pages: bool, output: str, **filters): |
There was a problem hiding this comment.
Keyword-only argument enforcement breaks callers: Added * before limit in _do_list signature (line 75), in make_resource_app (line 123), and in list_cmd (line 142). This converts limit, offset, all_pages, output from positional to keyword-only. The diff shows call sites in accounts.py:26 and commands/__init__.py:164–167 were updated to use limit=limit syntax. Verify: are there other callers of _do_list or make_resource_app elsewhere in the codebase (e.g., other command modules, tests) that still pass these arguments positionally? Any unupdated caller will fail at runtime with TypeError: takes 0 positional arguments but X were given.
| import difflib | ||
|
|
||
| import click | ||
| import typer |
There was a problem hiding this comment.
Reliance on private typer internals: The code imports from typer._click.exceptions import UsageError (line 5), accessing a private vendored module inside typer. While the inline comment (lines 6–7) explains why this is necessary (TyperGroup raises the vendored exception), this creates a fragility: a future typer release could restructure or remove _click without warning, since it is private API. Pin typer to a known-stable range (currently >=0.26,<1.0 on line 7) and add a runtime check or fallback. Alternatively, file a feature request with typer to expose UsageError as a public exception.
| title: str, | ||
| counterparty_label: str, | ||
| counterparty_field: str, | ||
| *, |
There was a problem hiding this comment.
Keyword-only arguments added to internal helpers: _transaction_list (line 104) and _transaction_detail (line 166) now have * before their optional boolean parameters. This prevents positional-argument mistakes. These are private functions (underscore prefix), so internal callers only. Grep for all call sites to _transaction_list( and _transaction_detail( in the codebase: any that pass show_due_date, show_paid, show_remaining, due_color, or resource positionally will break. Verify all internal callers updated or use keyword syntax.
Summary
CI was green on
mainbut would have failed on the next run — on both thetestandlintjobs — without a single line of our code changing. Two upstream releases landed after our last commit and silently changed the behaviour ofuv sync --dev, which CI runs with no lockfile.This PR fixes both failures, commits
uv.lockso that dependency changes can only arrive deliberately, and makes CI enforce the lockfile withuv 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
mainwas 2026-04-22. Becauseuv.lockwas 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:main(#15) — CI greenclickdisappears from the environment →testjob breaksselect = ["ALL"]→lintjob breaksBoth are time-triggered, not code-triggered. The green checkmarks on
mainare real; they just describe a dependency set that no longer resolves.Changes
Problem 1 —
ModuleNotFoundError: No module named 'click'Symptom
uv run pytestaborts during collection:Root cause
src/dualentry_cli/cli.pyimportedclickdirectly, butclickwas 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/_clickand dropped it as an external dependency, in PR #1774, merged 2026-05-26. The PR vendors Click 8.3.1 into atyper/_clicksubdirectory.Our constraint was
typer>=0.12,<1.0, so a fresh resolve picks the newest — today 0.27.1 — andclickis simply never installed.The second-order bug
Declaring
clickas a dependency would have fixed the import but left a subtler defect in place.HelpfulGroup.resolve_commandcaughtclick.UsageError, but since 0.26.0TyperGroupraises the vendored exception, which is an unrelated class:typer._click.exceptions.UsageError is click.exceptions.UsageError → False.The
exceptclause 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 dropclickentirely rather than declare it.Fix
cli.pynow importsUsageErrorfromtyper._click.exceptionsand usestyper.echo(publicly re-exported by typer) instead ofclick.echo.clickis not added as a dependency — it is no longer imported anywhere.>=0.26, becausetyper._clickdoes not exist below it. The previous>=0.12floor 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
typerprovides its own suggestions, but at difflib's defaultcutoff=0.6, while ours usescutoff=0.4. Here is a real difference between these two values:bankbank-transfersfixedfixed-assetsprepayvendor-prepaymentsDeleting 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
TestUnknownCommandSuggestionscovering 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'sNo such commandmessage 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:
CPY001/missing-copyright-notice— tracking issue astral-sh/ruff/issues/19487PLR0917/too-many-positional-arguments— tracking issue astral-sh/ruff/issues/16867Our 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 check src/ tests/Fix
CPY001→ ignored. The rule requires a copyright header on every file. This project ships noLICENSEfile and declares nolicenseinpyproject.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:make_resource_app_do_listlist_cmd_transaction_list_transaction_detailTwo
_do_listcall sites were updated to pass keywords.Problem 3 (root cause) —
uv.lockwas gitignoredBoth failures above share one cause: CI and contributors re-resolved dependencies on every run.
uv.lockhad been in.gitignoresince the initial commit and was never tracked. That means:release.ymlrunsuv sync --devand 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.0is itself unpinned, so the packaging tool drifted too.uv.lockrecords a sha256 for each of the 56 packages. Without it, release builds installed whatever PyPI served.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. Bothci.ymljobs now use:--lockedverifies the lockfile is up to date withpyproject.tomland fails instead of silently re-resolving. So changing a dependency without runninguv lockis 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.ymldeliberately keeps plainuv sync --dev. It rewrites the version inpyproject.tomlfrom the git tag before syncing, and the lockfile records the root package's own version (uv.lock→version = "0.1.17"), so the stamp makes the lockfile stale by construction.Test plan
uv run pytest)uv run ruff check .)dualentry <command>(since I do not have a valid API key, I tested in a mocked environment)