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
# 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

# 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):
"""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,
*,
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