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> (since I do not have a valid API key, I tested in a mocked environment)

Comment thread pyproject.toml
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/dualentry_cli/cli.py
import difflib

import click
import typer

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,
*,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants