Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v4
- run: uv sync --dev
- run: uv sync --dev --locked
- run: uv run ruff check src/ tests/
- run: uv run ruff format --check src/ tests/

Expand All @@ -26,5 +26,5 @@ jobs:
- uses: astral-sh/setup-uv@v4
with:
python-version: ${{ matrix.python-version }}
- run: uv sync --dev
- run: uv sync --dev --locked
- run: uv run pytest --no-header -q
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ __pycache__/
dist/
build/
.eggs/
uv.lock
*.spec
.idea
19 changes: 18 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,22 @@ uv sync --dev
uv run pre-commit install
```

## Dependencies

`uv.lock` is committed, and CI runs `uv sync --dev --locked`, which fails if the lockfile
is out of date with `pyproject.toml`. So whenever you add, remove, or change a dependency:

```bash
uv lock # regenerate uv.lock
```

Commit the updated `uv.lock` alongside your `pyproject.toml` change, or CI will fail with
`The lockfile at uv.lock needs to be updated, but --locked was provided`.

To pick up newer versions of existing dependencies, run `uv lock --upgrade` deliberately —
it is not something that happens on its own. Expect to fix new `ruff` findings when you do,
since the lint config selects `ALL` rules and each `ruff` release can add more.

## Running locally

```bash
Expand All @@ -35,7 +51,8 @@ uv run pytest --cov=dualentry_cli --cov-report=term-missing
1. Create a branch from `main`
2. Make your changes
3. Ensure linting and tests pass
4. Open a PR against `main`
4. If you touched dependencies, run `uv lock` and commit `uv.lock`
5. Open a PR against `main`

## Releasing

Expand Down
14 changes: 13 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@ version = "0.1.17"
description = "DualEntry accounting CLI"
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.

# typer._click and dropped click from its own dependencies. That is a
# breaking change for us on both counts:
# - typer._click.exceptions does not exist before 0.26, and
# - click is no longer installed transitively, so importing it is not an
# option either (it is not a direct dependency of this project).
# HelpfulGroup in cli.py catches typer._click.exceptions.UsageError, which
# is the class TyperGroup.resolve_command actually raises.
"typer>=0.26,<1.0",
"httpx>=0.27,<1.0",
"keyring>=25.0,<26.0",
"rich>=13.0,<14.0",
Expand Down Expand Up @@ -115,6 +123,10 @@ ignore = [
"PLR0915",
"F841",
"SIM105",

# Added to ALL in ruff 0.16.0. This project declares no license and ships no
# LICENSE file, so there is no copyright header to require.
"CPY001",
]

[tool.ruff.lint.per-file-ignores]
Expand Down
15 changes: 9 additions & 6 deletions src/dualentry_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

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.


# typer >= 0.26 vendors click; TyperGroup raises the vendored UsageError.
from typer._click.exceptions import UsageError
from typer.core import TyperGroup

LOGO = r"""
Expand All @@ -25,20 +28,20 @@ class HelpfulGroup(TyperGroup):

def format_help(self, ctx, formatter):
if ctx.parent is None:
click.echo(LOGO)
typer.echo(LOGO)
super().format_help(ctx, formatter)

def resolve_command(self, ctx, args):
try:
return super().resolve_command(ctx, args)
except click.UsageError:
except UsageError:
cmd_name = args[0] if args else None
if cmd_name:
matches = difflib.get_close_matches(cmd_name, self.list_commands(ctx), n=3, cutoff=0.4)
if matches:
hint = ", ".join(f"'{m}'" for m in matches)
click.echo(f"Unknown command '{cmd_name}'. Did you mean: {hint}?\n", err=True)
typer.echo(f"Unknown command '{cmd_name}'. Did you mean: {hint}?\n", err=True)
else:
click.echo(f"Unknown command '{cmd_name}'.\n", err=True)
click.echo(ctx.get_help())
typer.echo(f"Unknown command '{cmd_name}'.\n", err=True)
typer.echo(ctx.get_help())
ctx.exit(2)
12 changes: 7 additions & 5 deletions src/dualentry_cli/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def _build_filter_params(
return params


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.

"""Shared list logic for all resources."""
params = _build_filter_params(**filters)
if all_pages:
Expand Down Expand Up @@ -120,6 +120,7 @@ def make_resource_app(
name: str,
resource: str,
path: str,
*,
has_create: bool = True,
has_update: bool = True,
has_delete: bool = False,
Expand All @@ -138,6 +139,7 @@ def make_resource_app(

@app.command("list")
def list_cmd(
*,
limit: int = Limit,
offset: int = Offset,
all_pages: bool = AllPages,
Expand All @@ -157,10 +159,10 @@ def list_cmd(
client,
path,
resource,
limit,
offset,
all_pages,
output,
limit=limit,
offset=offset,
all_pages=all_pages,
output=output,
search=search,
status=status,
start_date=start_date,
Expand Down
2 changes: 1 addition & 1 deletion src/dualentry_cli/commands/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def list_accounts(
from dualentry_cli.main import get_client

client = get_client()
_do_list(client, "accounts", "account", limit, offset, all_pages, output, search=search)
_do_list(client, "accounts", "account", limit=limit, offset=offset, all_pages=all_pages, output=output, search=search)


@app.command("get")
Expand Down
2 changes: 2 additions & 0 deletions src/dualentry_cli/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ def _transaction_list(
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.

show_due_date: bool = False,
show_paid: bool = False,
show_remaining: bool = False,
Expand Down Expand Up @@ -162,6 +163,7 @@ def _transaction_detail(
record_type: str,
counterparty_label: str,
counterparty_field: str,
*,
due_color: str = "green",
resource: str = "",
):
Expand Down
25 changes: 25 additions & 0 deletions tests/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,3 +182,28 @@ def test_create_with_missing_file(self, tmp_path):
result = runner.invoke(app, ["invoices", "create", "--file", str(tmp_path / "missing.json")])
assert result.exit_code == 1
assert "not found" in result.output.lower() or "Error" in result.output


class TestUnknownCommandSuggestions:
"""HelpfulGroup must catch typer's UsageError, not click's (see typer >= 0.26)."""

@pytest.mark.usefixtures("mock_get_client")
def test_typo_suggests_closest_command(self):
result = runner.invoke(app, ["journal-entrie"])
assert result.exit_code == 2
assert "Unknown command 'journal-entrie'" in result.output
assert "journal-entries" in result.output

@pytest.mark.usefixtures("mock_get_client")
def test_short_prefix_suggests_long_command(self):
"""cutoff=0.4 catches prefixes that typer's default 0.6 would miss."""
result = runner.invoke(app, ["bank"])
assert result.exit_code == 2
assert "bank-transfers" in result.output

@pytest.mark.usefixtures("mock_get_client")
def test_unmatchable_command_still_shows_help(self):
result = runner.invoke(app, ["zzzzzz"])
assert result.exit_code == 2
assert "Unknown command 'zzzzzz'" in result.output
assert "Did you mean" not in result.output
Loading
Loading