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: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

- Add `classifications-lines list` to list classification values across all classifications, filterable by `--classification`, `--id`, `--name`, and `--active/--inactive`
- Fix `statistical-journals list` sending comma-separated ID filters (`--number`, `--account-id`, `--line-account-id`) in a form the API could not read
- Comma-separated ID filters now reject an empty value instead of silently listing every row, and can be repeated (`--id 1,2 --id 3`)
- The `list --all` resume hint now repeats every filter of commands with their own filters, and shell-quotes values with spaces
- Line-item tables label the quantity column Quantity
- Update the CLI greeting logo to DualEntry
- Add `export-jobs` commands (`create`, `list`, `get`, `wait`) for on-demand destination data refreshes via the public API
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ dualentry export-jobs wait 42 --interval 10 --timeout 1800
| **Receivables** | Invoices, Sales Orders, Customer Payments, Credits, Deposits |
| **Payables** | Bills, Purchase Orders, Vendor Payments, Credits, Refunds |
| **Accounting** | Journal Entries, Bank Transfers, Fixed Assets, Depreciation |
| **Master Data** | Customers, Vendors, Items, Accounts, Classifications |
| **Master Data** | Customers, Vendors, Items, Accounts, Classifications, Classification Lines |
| **Automation** | Recurring Invoices, Recurring Bills, Workflows, Contracts |
| **Close Management** | Bank Match |
| **Bank Feeds** | Bank Connections |
Expand Down
139 changes: 128 additions & 11 deletions src/dualentry_cli/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,20 @@
from __future__ import annotations

import json
from collections.abc import Callable
import shlex
from collections.abc import Callable, Mapping, Sequence
from pathlib import Path
from typing import TYPE_CHECKING

import typer

from dualentry_cli.cli import HelpfulGroup, make_list_command_cls
from dualentry_cli.client import _MAX_PAGES
from dualentry_cli.output import _RECORD_PREFIX, format_output

if TYPE_CHECKING:
from typer.models import OptionInfo

# Filter flags `list` can offer, with every option spelling each one accepts.
FILTER_OPTIONS = {
"search": ("--search", "-s"),
Expand Down Expand Up @@ -69,6 +74,68 @@ def _supplied(value) -> str | None:
return value if isinstance(value, str) else None


def _parse_csv_ints(value: str) -> list[int]:
"""
Parse one occurrence of a comma-separated integer filter, e.g. `3,4`.

Args:
value: The raw option value as typed on the command line.

Returns:
The integers in the order given.

Raises:
typer.BadParameter: If the value holds no integers, or any token is not one.
An empty value is rejected, not ignored: dropping the filter would
widen the query to every row.

"""
tokens = [token.strip() for token in value.split(",") if token.strip()]
if not tokens:
raise typer.BadParameter("expected comma-separated integers, got an empty value")
ints: list[int] = []
for token in tokens:
try:
ints.append(int(token))
except ValueError as e:
raise typer.BadParameter(f"expected comma-separated integers, got {token!r}") from e
return ints


def _flatten_csv_ints(chunks: Sequence[list[int]] | None) -> list[int] | None:
"""
Merge every occurrence of a repeatable CSV option into one flat list.

Args:
chunks: One parsed list per occurrence of the flag, or None when it was not passed.

Returns:
The merged integers, or None when the flag was not passed, so the filter is omitted.

"""
merged = [n for chunk in chunks or () for n in chunk]
return merged or None


def csv_ints_option(flag: str, *, help_text: str) -> OptionInfo:
"""
Build a `--flag 1,2,3` option that typer hands to the command as `list[int] | None`.

A `list[int]` annotation makes typer treat the option as repeatable and run
`parser` once per occurrence, which yields `[[1, 2, 3]]`. The callback flattens
that, so `--flag 1,2 --flag 3` also works.

Args:
flag: The option spelling, e.g. `"--id"`.
help_text: The `--help` description.

Returns:
The option, for use inside `Annotated[list[int] | None, ...]`.

"""
return typer.Option(flag, parser=_parse_csv_ints, callback=_flatten_csv_ints, metavar="<ids>", help=help_text)


def _build_filter_params(
search: str | None = None,
status: str | None = None,
Expand All @@ -91,8 +158,14 @@ def _build_filter_params(
return params


# Map _do_list filter kwargs to CLI flags for the --all resume hint.
_FILTER_CLI_FLAGS = {
# How a filter kwarg is spelled in the --all resume hint: a value-taking flag, or an
# (on, off) pair for a `--x/--no-x` boolean.
ResumeFlag = str | tuple[str, str]

# Map _do_list filter kwargs to CLI flags for the --all resume hint. Commands with
# their own filters pass a mapping of their own; a filter left out of it would be
# missing from the hint, and the "same filters" command would return other rows.
_FILTER_CLI_FLAGS: dict[str, ResumeFlag] = {
"search": "--search",
"status": "--status",
"start_date": "--start-date",
Expand All @@ -103,27 +176,70 @@ def _build_filter_params(
}


def _resume_all_command(path: str, next_offset: int, filters: dict) -> str:
"""Build a copy-paste dualentry list --all command that continues from next_offset."""
def _resume_all_command(
path: str,
next_offset: int,
filters: Mapping[str, object],
flags: Mapping[str, ResumeFlag] = _FILTER_CLI_FLAGS,
) -> str:
"""
Build a copy-paste dualentry list --all command that continues from next_offset.

Args:
path: The API path, whose segments are also the CLI command path.
next_offset: Where the next run should start.
filters: The filter kwargs the truncated run used.
flags: How each filter kwarg is spelled on the command line.

Returns:
A shell-quoted command line.

"""
parts = ["dualentry", *path.split("/"), "list", "--all", "--offset", str(next_offset)]
for key, flag in _FILTER_CLI_FLAGS.items():
for key, flag in flags.items():
value = filters.get(key)
if value is not None:
if value is None:
continue
if isinstance(flag, tuple):
on, off = flag
parts.append(on if value else off)
elif isinstance(value, list):
parts.extend([flag, ",".join(map(str, value))])
else:
parts.extend([flag, str(value)])
return " ".join(parts)
return shlex.join(parts)


def _warn_all_truncated(path: str, *, fetched_through: int, total: int, next_offset: int, filters: dict) -> None:
def _warn_all_truncated(
path: str,
*,
fetched_through: int,
total: int,
next_offset: int,
filters: Mapping[str, object],
flags: Mapping[str, ResumeFlag] = _FILTER_CLI_FLAGS,
) -> None:
"""Tell the user --all stopped early and how to continue."""
cmd = _resume_all_command(path, next_offset, filters)
cmd = _resume_all_command(path, next_offset, filters, flags)
typer.secho(
f"Warning: reached {fetched_through} of {total} items; stopped at the {_MAX_PAGES}-page limit.\nTo continue, re-run with the same filters:\n {cmd}",
fg=typer.colors.YELLOW,
err=True,
)


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,
resume_flags: Mapping[str, ResumeFlag] = _FILTER_CLI_FLAGS,
**filters,
) -> None:
"""Shared list logic for all resources."""
params = _build_filter_params(**filters)
next_offset = None
Expand All @@ -142,6 +258,7 @@ def _do_list(client, path: str, resource: str, *, limit: int, offset: int, all_p
total=data.get("count", next_offset),
next_offset=next_offset,
filters=filters,
flags=resume_flags,
)


Expand Down
27 changes: 24 additions & 3 deletions src/dualentry_cli/commands/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@

from __future__ import annotations

from collections.abc import Mapping
from pathlib import Path

import typer

from dualentry_cli.cli import HelpfulGroup
from dualentry_cli.commands import Format, _do_list, _load_json_file
from dualentry_cli.commands import _FILTER_CLI_FLAGS, Format, ResumeFlag, _do_list, _load_json_file
from dualentry_cli.output import format_output


Expand All @@ -22,10 +23,30 @@ def run_get(path: str, *, resource: str, output: str, params: dict | None = None
format_output(data, resource=resource, fmt=output)


def run_list(path: str, *, resource: str, limit: int, offset: int, all_pages: bool, output: str, **filters) -> None:
def run_list(
path: str,
*,
resource: str,
limit: int,
offset: int,
all_pages: bool,
output: str,
resume_flags: Mapping[str, ResumeFlag] = _FILTER_CLI_FLAGS,
**filters,
) -> None:
from dualentry_cli.main import get_client

_do_list(get_client(), path.strip("/"), resource, limit=limit, offset=offset, all_pages=all_pages, output=output, **filters)
_do_list(
get_client(),
path.strip("/"),
resource,
limit=limit,
offset=offset,
all_pages=all_pages,
output=output,
resume_flags=resume_flags,
**filters,
)


def run_post(path: str, *, resource: str, output: str, body: dict | None = None) -> None:
Expand Down
63 changes: 63 additions & 0 deletions src/dualentry_cli/commands/classifications_lines.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""
Classification line commands (read-only list across every classification).

`/classifications-lines/` has no detail, create, or update route, so `list` is the
only command. It gets its own module, not the resource factory, because its filters
(IDs, a name match, an active flag) don't fit the factory's shared flag set.
"""

from __future__ import annotations

from typing import Annotated

import typer

from dualentry_cli.cli import HelpfulGroup
from dualentry_cli.commands import AllPages, Format, Limit, Offset, ResumeFlag, Search, csv_ints_option
from dualentry_cli.commands.actions import run_list

app = typer.Typer(help="List classification lines (the values each classification offers)", no_args_is_help=True, cls=HelpfulGroup)

# Every list filter, so the --all resume hint repeats all of them.
_RESUME_FLAGS: dict[str, ResumeFlag] = {
"search": "--search",
"classification_id": "--classification",
"id": "--id",
"name": "--name",
"is_active": ("--active", "--inactive"),
}


@app.command("list")
def list_cmd(
limit: int = Limit,
offset: int = Offset,
all_pages: bool = AllPages,
search: str | None = Search,
classification: Annotated[
list[int] | None,
csv_ints_option("--classification", help_text="Filter by classification ID(s), comma-separated"),
] = None,
line_id: Annotated[
list[int] | None,
csv_ints_option("--id", help_text="Filter by classification line ID(s), comma-separated"),
] = None,
name: str | None = typer.Option(None, "--name", help="Filter by line name (case-insensitive substring)"),
active: bool | None = typer.Option(None, "--active/--inactive", help="Only active or only inactive lines [default: both]"),
output: str = Format,
) -> None:
"""List classification lines across all classifications."""
run_list(
"classifications-lines",
resource="classification-line",
resume_flags=_RESUME_FLAGS,
limit=limit,
offset=offset,
all_pages=all_pages,
output=output,
search=search,
classification_id=classification,
id=line_id,
name=name or None, # `extra` filters are only dropped when None, so "" must not reach the API
is_active=active,
)
Loading
Loading