From 9c6874f666aeb4a58e0af230ea7fd6528eb14085 Mon Sep 17 00:00:00 2001 From: Andy Woods <22278911+awoods187@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:34:51 -0400 Subject: [PATCH 1/4] Add add-attachments and replace-attachments commands Add `add-attachments NUMBER -f FILE [-f FILE ...]` to every transaction type whose API accepts file uploads: invoices, bills, sales orders, purchase orders, journal entries, cash sales, direct expenses, customer deposits, customer refunds, customer prepayments, customer prepayment applications, vendor payments, and vendor refunds. Invoices also get `replace-attachments`, which swaps out every existing attachment. - Every file is checked against the API's 10 MiB limit, and for being empty, before anything is sent, so one bad file cannot leave the others half-uploaded. Folders are rejected. - The record argument is the record number. A prefixed internal ID such as IN-42 is rejected instead of being read as number 42, which would upload to a different record. - statistical-journals add-attachments now uses the same code path. It keeps accepting SJ-3, since that prefix wraps the record number, and gains the size check. --- CHANGELOG.md | 2 + README.md | 11 + src/dualentry_cli/client.py | 8 +- src/dualentry_cli/commands/__init__.py | 170 ++++++++++++++- .../commands/statistical_journals.py | 16 +- src/dualentry_cli/main.py | 33 +-- src/dualentry_cli/output.py | 4 + tests/test_attachments.py | 204 ++++++++++++++++++ 8 files changed, 416 insertions(+), 32 deletions(-) create mode 100644 tests/test_attachments.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fe986b..46ea5e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +- Add `add-attachments` to the 13 transaction types whose API accepts file uploads, and `replace-attachments` to invoices. Files are checked against the 10 MiB limit before anything is sent +- `statistical-journals add-attachments` now checks the 10 MiB limit before uploading, rejects folders, and rejects prefixes other than `SJ-` - 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 diff --git a/README.md b/README.md index 66f75e6..fb922c8 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,16 @@ dualentry invoices create --file invoice.json dualentry journal-entries list --start-date 2025-01-01 --end-date 2025-03-31 --format json ``` +### Attach a receipt to a bill + +```bash +# Pass the record number from the # column; repeat -f for several files (10 MiB max each) +dualentry bills add-attachments 1042 -f receipt.pdf -f approval.eml + +# Invoices can also swap out every attachment at once +dualentry invoices replace-attachments 318 -f signed-invoice.pdf +``` + ### Automate in CI/CD Set `X_API_KEY` in your environment for non-interactive authentication: @@ -97,6 +107,7 @@ dualentry export-jobs wait 42 --interval 10 --timeout 1800 Most resources support `list`, `get`, `create`, and `update`; some (e.g. Bank Connections, Bank Match) expose a different verb set — see `--help`. Export jobs support `create`, `list`, `get`, and `wait` (poll until finished). +Transactions that accept files support `add-attachments` (see `dualentry --help`); invoices also support `replace-attachments`. ## Output Formats diff --git a/src/dualentry_cli/client.py b/src/dualentry_cli/client.py index 88a8dba..b68e59a 100644 --- a/src/dualentry_cli/client.py +++ b/src/dualentry_cli/client.py @@ -6,7 +6,7 @@ import sys import time import uuid -from typing import Any +from typing import Any, TypeAlias import httpx @@ -33,6 +33,10 @@ _MAX_RETRY_AFTER = 60 +# One `("files", (filename, content))` part per upload. Bytes, not open files: a +# retried request resends these parts, and a file handle would already be spent. +Multipart: TypeAlias = list[tuple[str, tuple[str, bytes]]] + # Hard ceiling for --all crawls: page_size 100 x 1000 pages = 100_000 items. # Truncation must warn; never report the truncated length as the API total. _MAX_PAGES = 1000 @@ -254,7 +258,7 @@ def post( path: str, json: dict[str, Any] | None = None, *, - files: list[tuple[str, tuple[str, bytes]]] | None = None, + files: Multipart | None = None, ) -> dict: if files is not None: return self._request("POST", path, files=files) diff --git a/src/dualentry_cli/commands/__init__.py b/src/dualentry_cli/commands/__init__.py index 69c3ad5..2c33d71 100644 --- a/src/dualentry_cli/commands/__init__.py +++ b/src/dualentry_cli/commands/__init__.py @@ -3,14 +3,14 @@ from __future__ import annotations import json -from collections.abc import Callable +from collections.abc import Callable, Sequence from pathlib import Path 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 +from dualentry_cli.client import _MAX_PAGES, Multipart +from dualentry_cli.output import _NUMBER_PREFIXED, _RECORD_PREFIX, format_output # Filter flags `list` can offer, with every option spelling each one accepts. FILTER_OPTIONS = { @@ -175,6 +175,156 @@ def _strip_to_writable(data: dict) -> dict: return payload +# ── Attachment helpers ───────────────────────────────────────────── + +# The API rejects empty files and files over 10 MiB, checking each file in turn and +# storing the ones before it first. Checking every file up front fails before any +# bytes are sent, so a bad last file can't leave the earlier ones half-uploaded. +ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024 +_ATTACHMENT_MAX_LABEL = f"{ATTACHMENT_MAX_BYTES // 2**20} MiB" + +AttachmentFiles = typer.Option( + ..., + "--file", + "-f", + help=f"File to attach (max {_ATTACHMENT_MAX_LABEL}); repeat the flag for several", + exists=True, + dir_okay=False, + readable=True, +) + + +def _record_number_arg(value: str, resource: str) -> str: + """ + Return the record number to put in an attachment URL. + + Args: + value: What the user typed: the record number from the `#` column, or for + resources in `_NUMBER_PREFIXED`, that number with its prefix (`SJ-3`). + resource: The resource name, which decides what its display prefix wraps. + + Returns: + The record number as ASCII digits. + + Raises: + typer.BadParameter: If the value is not a record number. For most resources a + prefixed value such as `IN-42` is the internal ID, not the number, so + stripping the prefix would upload to whichever record has number 42. + + """ + prefix = _RECORD_PREFIX.get(resource) + number_prefixed = resource in _NUMBER_PREFIXED + if number_prefixed and prefix: + head, sep, rest = value.partition("-") + if sep and head.upper() == prefix: + value = rest + # isascii(): str.isdigit() also accepts "²" and "٤٢", which no record URL matches. + if value.isascii() and value.isdigit(): + return value + raise typer.BadParameter(f"expected a record number{_record_number_hint(resource)}, got {value!r}", param_hint="'NUMBER'") + + +def _record_number_hint(resource: str) -> str: + """Say which form of record number `resource` takes, for the error message.""" + prefix = _RECORD_PREFIX.get(resource) + if not prefix: + return " from the # column" + if resource in _NUMBER_PREFIXED: + return f" such as 3 or {prefix}-3" + return f" from the # column (not the {prefix}-… ID)" + + +def _read_attachment(path: Path) -> bytes: + """ + Read one file for upload, refusing it if the API would. + + The read stops one byte past the limit, so an oversized file is never loaded + whole, and the size is judged on the bytes actually read, not an earlier stat() + that the file could outgrow before the upload. + + Args: + path: A path given with `--file`. + + Returns: + The file's content. + + Raises: + typer.BadParameter: If the file is empty or larger than `ATTACHMENT_MAX_BYTES`. + + """ + with path.open("rb") as fh: + content = fh.read(ATTACHMENT_MAX_BYTES + 1) + if not content: + raise typer.BadParameter(f"{path} is empty", param_hint="'--file'") + if len(content) > ATTACHMENT_MAX_BYTES: + raise typer.BadParameter(f"{path} is over the {_ATTACHMENT_MAX_LABEL} limit", param_hint="'--file'") + return content + + +def _read_attachments(files: Sequence[Path]) -> Multipart: + """ + Read every file for upload, so one bad file stops the upload before it starts. + + Args: + files: The paths given with `--file`, in order. + + Returns: + Multipart parts for `DualEntryClient.post(files=...)`. + + Raises: + typer.BadParameter: If any file is empty or larger than `ATTACHMENT_MAX_BYTES`. + + """ + contents = [_read_attachment(path) for path in files] + return [("files", (path.name, content)) for path, content in zip(files, contents, strict=True)] + + +def _add_attachment_commands(app: typer.Typer, path: str, resource: str, *, replace: bool) -> None: + """ + Register `add-attachments`, and `replace-attachments` when the API offers it. + + Args: + app: The resource's Typer app. + path: The API path, e.g. `"invoices"`. + resource: The resource name, for output formatting and record-number parsing. + replace: Whether the resource has `POST /{number}/attachments/replace/`. + + """ + prefix = _RECORD_PREFIX.get(resource) + number_help = f"Record number (the # column), e.g. 3 or {prefix}-3" if resource in _NUMBER_PREFIXED and prefix else "Record number (the # column)" + + def upload(number: str, files: Sequence[Path], output: str, *, replace_existing: bool) -> None: + from dualentry_cli.main import get_client + + record_number = _record_number_arg(number, resource) + multipart = _read_attachments(files) + route = "attachments/replace/" if replace_existing else "attachments/" + data = get_client().post(f"/{path}/{record_number}/{route}", files=multipart) + format_output(data, resource=resource, fmt=output) + + @app.command("add-attachments") + def add_attachments_cmd( + number: str = typer.Argument(help=number_help), + file: list[Path] = AttachmentFiles, + output: str = Format, + ) -> None: + upload(number, file, output, replace_existing=False) + + add_attachments_cmd.__doc__ = "Attach file(s) to a record, keeping its existing attachments." + + if replace: + + @app.command("replace-attachments") + def replace_attachments_cmd( + number: str = typer.Argument(help=number_help), + file: list[Path] = AttachmentFiles, + output: str = Format, + ) -> None: + upload(number, file, output, replace_existing=True) + + replace_attachments_cmd.__doc__ = "Replace all attachments on a record. Its current attachments are deleted." + + # ── Factory ───────────────────────────────────────────────────────── @@ -189,6 +339,8 @@ def make_resource_app( has_delete: bool = False, has_number: bool = False, has_post: bool = False, + has_attachments: bool = False, + has_replace_attachments: bool = False, filters: set[str], status_param: str = "record_status", template: dict | None = None, @@ -200,6 +352,9 @@ def make_resource_app( `filters` lists the flags this resource's v2 list endpoint declares; the rest are removed from `list` so the API cannot silently drop them. + + `has_attachments` and `has_replace_attachments` mirror the v2 routes + `POST /{number}/attachments/` and `POST /{number}/attachments/replace/`. """ import inspect @@ -208,6 +363,12 @@ def make_resource_app( if unknown: msg = f"{name}: unknown filter flags {sorted(unknown)}" raise ValueError(msg) + if has_replace_attachments and not has_attachments: + msg = f"{name}: has_replace_attachments requires has_attachments" + raise ValueError(msg) + if has_attachments and not has_number: + msg = f"{name}: has_attachments requires has_number (attachment routes take the record number)" + raise ValueError(msg) remove = ALL_FILTERS - filters gated_options = {option for flag in remove for option in FILTER_OPTIONS[flag]} @@ -426,6 +587,9 @@ def post_cmd( post_cmd.__doc__ = f"Post a draft {resource}." + if has_attachments: + _add_attachment_commands(app, path, resource, replace=has_replace_attachments) + if template is not None: @app.command("template") diff --git a/src/dualentry_cli/commands/statistical_journals.py b/src/dualentry_cli/commands/statistical_journals.py index 664c4d0..840c6b0 100644 --- a/src/dualentry_cli/commands/statistical_journals.py +++ b/src/dualentry_cli/commands/statistical_journals.py @@ -15,6 +15,7 @@ Offset, Search, Status, + _add_attachment_commands, _load_json_file, _strip_record_prefix, ) @@ -150,17 +151,4 @@ def update_cmd( format_output(data, resource="statistical-journal", fmt=output) -@app.command("add-attachments") -def add_attachments_cmd( - number: str = typer.Argument(help="Record number of the journal"), - file: list[Path] = typer.Option(..., "--file", "-f", help="Attachment file(s) to upload", exists=True, readable=True), - output: str = Format, -): - """Upload attachment file(s) to a statistical journal.""" - from dualentry_cli.main import get_client - - client = get_client() - stripped = _strip_record_prefix(number) - multipart = [("files", (path.name, path.read_bytes())) for path in file] - data = client.post(f"/statistical-journals/{stripped}/attachments/", files=multipart) - format_output(data, resource="statistical-journal", fmt=output) +_add_attachment_commands(app, "statistical-journals", "statistical-journal", replace=False) diff --git a/src/dualentry_cli/main.py b/src/dualentry_cli/main.py index 66f0cb9..a1dca86 100644 --- a/src/dualentry_cli/main.py +++ b/src/dualentry_cli/main.py @@ -29,37 +29,44 @@ TXN_ALL_PARTIES = TXN | {"customer", "vendor"} # Custom-formatted resources (use factory - output.py handles formatting via resource name) -app.add_typer(make_resource_app("invoices", "invoice", "invoices", has_number=True, filters=TXN_CUSTOMER), name="invoices") -app.add_typer(make_resource_app("bills", "bill", "bills", has_number=True, filters=TXN_VENDOR), name="bills") +app.add_typer(make_resource_app("invoices", "invoice", "invoices", has_number=True, has_attachments=True, has_replace_attachments=True, filters=TXN_CUSTOMER), name="invoices") +app.add_typer(make_resource_app("bills", "bill", "bills", has_number=True, has_attachments=True, filters=TXN_VENDOR), name="bills") app.add_typer(accounts_app, name="accounts") # Accounts has custom filtering (no status/date filters) # Money-in -app.add_typer(make_resource_app("sales orders", "sales-order", "sales-orders", has_number=True, filters=TXN_CUSTOMER), name="sales-orders") +app.add_typer(make_resource_app("sales orders", "sales-order", "sales-orders", has_number=True, has_attachments=True, filters=TXN_CUSTOMER), name="sales-orders") app.add_typer(make_resource_app("customer payments", "customer-payment", "customer-payments", has_number=True, filters=TXN_CUSTOMER), name="customer-payments") app.add_typer(make_resource_app("customer credits", "customer-credit", "customer-credits", has_number=True, filters=TXN_CUSTOMER), name="customer-credits") -app.add_typer(make_resource_app("customer prepayments", "customer-prepayment", "customer-prepayments", has_number=True, filters=TXN_CUSTOMER), name="customer-prepayments") app.add_typer( - make_resource_app("customer prepayment applications", "customer-prepayment-application", "customer-prepayment-applications", has_number=True, filters=TXN_CUSTOMER), + make_resource_app("customer prepayments", "customer-prepayment", "customer-prepayments", has_number=True, has_attachments=True, filters=TXN_CUSTOMER), + name="customer-prepayments", +) +app.add_typer( + make_resource_app( + "customer prepayment applications", "customer-prepayment-application", "customer-prepayment-applications", has_number=True, has_attachments=True, filters=TXN_CUSTOMER + ), name="customer-prepayment-applications", ) -app.add_typer(make_resource_app("customer deposits", "customer-deposit", "customer-deposits", has_number=True, filters=TXN_CUSTOMER), name="customer-deposits") -app.add_typer(make_resource_app("customer refunds", "customer-refund", "customer-refunds", has_number=True, filters=TXN_CUSTOMER), name="customer-refunds") -app.add_typer(make_resource_app("cash sales", "cash-sale", "cash-sales", has_number=True, filters=TXN_CUSTOMER), name="cash-sales") +app.add_typer( + make_resource_app("customer deposits", "customer-deposit", "customer-deposits", has_number=True, has_attachments=True, filters=TXN_CUSTOMER), name="customer-deposits" +) +app.add_typer(make_resource_app("customer refunds", "customer-refund", "customer-refunds", has_number=True, has_attachments=True, filters=TXN_CUSTOMER), name="customer-refunds") +app.add_typer(make_resource_app("cash sales", "cash-sale", "cash-sales", has_number=True, has_attachments=True, filters=TXN_CUSTOMER), name="cash-sales") # Money-out -app.add_typer(make_resource_app("purchase orders", "purchase-order", "purchase-orders", has_number=True, filters=TXN_VENDOR), name="purchase-orders") -app.add_typer(make_resource_app("vendor payments", "vendor-payment", "vendor-payments", has_number=True, filters=TXN_VENDOR), name="vendor-payments") +app.add_typer(make_resource_app("purchase orders", "purchase-order", "purchase-orders", has_number=True, has_attachments=True, filters=TXN_VENDOR), name="purchase-orders") +app.add_typer(make_resource_app("vendor payments", "vendor-payment", "vendor-payments", has_number=True, has_attachments=True, filters=TXN_VENDOR), name="vendor-payments") app.add_typer(make_resource_app("vendor credits", "vendor-credit", "vendor-credits", has_number=True, filters=TXN_VENDOR), name="vendor-credits") app.add_typer(make_resource_app("vendor prepayments", "vendor-prepayment", "vendor-prepayments", has_number=True, filters=TXN_VENDOR), name="vendor-prepayments") app.add_typer( make_resource_app("vendor prepayment applications", "vendor-prepayment-application", "vendor-prepayment-applications", has_number=True, filters=TXN_VENDOR), name="vendor-prepayment-applications", ) -app.add_typer(make_resource_app("vendor refunds", "vendor-refund", "vendor-refunds", has_number=True, filters=TXN_VENDOR), name="vendor-refunds") -app.add_typer(make_resource_app("direct expenses", "direct-expense", "direct-expenses", has_number=True, filters=TXN_VENDOR), name="direct-expenses") +app.add_typer(make_resource_app("vendor refunds", "vendor-refund", "vendor-refunds", has_number=True, has_attachments=True, filters=TXN_VENDOR), name="vendor-refunds") +app.add_typer(make_resource_app("direct expenses", "direct-expense", "direct-expenses", has_number=True, has_attachments=True, filters=TXN_VENDOR), name="direct-expenses") # Accounting -app.add_typer(make_resource_app("journal entries", "journal-entry", "journal-entries", has_number=True, filters=TXN_ALL_PARTIES), name="journal-entries") +app.add_typer(make_resource_app("journal entries", "journal-entry", "journal-entries", has_number=True, has_attachments=True, filters=TXN_ALL_PARTIES), name="journal-entries") app.add_typer(statistical_journals_app, name="statistical-journals") app.add_typer(make_resource_app("bank transfers", "bank-transfer", "bank-transfers", has_number=True, filters=TXN), name="bank-transfers") app.add_typer( diff --git a/src/dualentry_cli/output.py b/src/dualentry_cli/output.py index 7162c53..3e7ac18 100644 --- a/src/dualentry_cli/output.py +++ b/src/dualentry_cli/output.py @@ -37,6 +37,10 @@ "statistical-journal": "SJ", } +# Resources whose prefixed display ID wraps the record number (SJ-3 is number 3). +# Every other prefix wraps the internal ID, which record URLs do not accept. +_NUMBER_PREFIXED: frozenset[str] = frozenset({"statistical-journal"}) + def _fmt_id(record_id, resource: str = "") -> str: """Format a record ID with its prefix, e.g. 135934 → IN-135934 (matches UI display).""" diff --git a/tests/test_attachments.py b/tests/test_attachments.py new file mode 100644 index 0000000..4e99de3 --- /dev/null +++ b/tests/test_attachments.py @@ -0,0 +1,204 @@ +"""`add-attachments` / `replace-attachments` on every resource whose v2 API accepts uploads.""" + +import json +from unittest.mock import MagicMock, patch + +import pytest +import typer.main +from typer.testing import CliRunner + +from dualentry_cli.commands import ATTACHMENT_MAX_BYTES, make_resource_app +from dualentry_cli.main import app + +runner = CliRunner() + +# Every CLI resource whose /public/v2 router registers POST /{number}/attachments/. +ATTACHMENT_RESOURCES = [ + "invoices", + "bills", + "sales-orders", + "customer-prepayments", + "customer-prepayment-applications", + "customer-deposits", + "customer-refunds", + "cash-sales", + "purchase-orders", + "vendor-payments", + "vendor-refunds", + "direct-expenses", + "journal-entries", + "statistical-journals", +] + +# Transaction resources whose v2 router has no attachments route. +NO_ATTACHMENT_RESOURCES = ["customer-credits", "customer-payments", "vendor-credits", "vendor-prepayments", "vendor-prepayment-applications", "bank-transfers"] + + +@pytest.fixture(autouse=True) +def mock_get_client(): + mock_client = MagicMock() + mock_client.post.return_value = {"number": 7, "attachments": []} + with patch("dualentry_cli.main.get_client", return_value=mock_client): + yield mock_client + + +@pytest.fixture +def receipt(tmp_path): + path = tmp_path / "receipt.pdf" + path.write_bytes(b"%PDF-1.7 receipt") + return path + + +def _flat(output: str) -> str: + """Undo rich's error-panel wrapping, so a message split across box lines still matches.""" + return " ".join(output.replace("│", " ").split()) + + +def _commands(resource: str) -> set[str]: + return set(typer.main.get_command(app).commands[resource].commands) + + +class TestRegistration: + @pytest.mark.parametrize("resource", ATTACHMENT_RESOURCES) + def test_resource_with_upload_route_offers_add_attachments(self, resource): + assert "add-attachments" in _commands(resource) + + @pytest.mark.parametrize("resource", NO_ATTACHMENT_RESOURCES) + def test_resource_without_upload_route_has_no_attachment_commands(self, resource): + assert not {"add-attachments", "replace-attachments"} & _commands(resource) + + def test_only_invoices_offer_replace_attachments(self): + offering = {r for r in ATTACHMENT_RESOURCES if "replace-attachments" in _commands(r)} + assert offering == {"invoices"} + + def test_attachments_without_record_numbers_are_rejected_at_registration(self): + with pytest.raises(ValueError, match="has_attachments requires has_number"): + make_resource_app("widgets", "widget", "widgets", has_attachments=True, filters=set()) + + def test_replace_without_add_is_rejected_at_registration(self): + with pytest.raises(ValueError, match="has_replace_attachments requires has_attachments"): + make_resource_app("widgets", "widget", "widgets", has_replace_attachments=True, filters=set()) + + +class TestAddAttachments: + @pytest.mark.parametrize("resource", ATTACHMENT_RESOURCES) + def test_posts_file_as_multipart_to_record_number(self, resource, receipt, mock_get_client): + result = runner.invoke(app, [resource, "add-attachments", "7", "--file", str(receipt)]) + assert result.exit_code == 0, result.output + mock_get_client.post.assert_called_once_with( + f"/{resource}/7/attachments/", + files=[("files", ("receipt.pdf", b"%PDF-1.7 receipt"))], + ) + + def test_repeated_file_flag_uploads_every_file_in_order(self, tmp_path, mock_get_client): + first, second = tmp_path / "a.png", tmp_path / "b.csv" + first.write_bytes(b"png") + second.write_bytes(b"csv") + result = runner.invoke(app, ["bills", "add-attachments", "3", "-f", str(first), "-f", str(second)]) + assert result.exit_code == 0, result.output + _, kwargs = mock_get_client.post.call_args + assert kwargs["files"] == [("files", ("a.png", b"png")), ("files", ("b.csv", b"csv"))] + + def test_json_output_prints_the_updated_record(self, receipt, mock_get_client): + mock_get_client.post.return_value = {"number": 7, "attachments": [{"name": "receipt.pdf"}]} + result = runner.invoke(app, ["invoices", "add-attachments", "7", "-f", str(receipt), "-o", "json"]) + assert result.exit_code == 0, result.output + assert json.loads(result.output[result.output.index("{") :]) == mock_get_client.post.return_value + + +class TestReplaceAttachments: + def test_posts_to_the_replace_route(self, receipt, mock_get_client): + result = runner.invoke(app, ["invoices", "replace-attachments", "7", "--file", str(receipt)]) + assert result.exit_code == 0, result.output + mock_get_client.post.assert_called_once_with( + "/invoices/7/attachments/replace/", + files=[("files", ("receipt.pdf", b"%PDF-1.7 receipt"))], + ) + + def test_help_warns_that_existing_attachments_are_deleted(self): + result = runner.invoke(app, ["invoices", "replace-attachments", "--help"]) + assert "current attachments are deleted" in _flat(result.output) + + +class TestRejectedBeforeAnyUpload: + @pytest.mark.parametrize("value", ["IN-42", "in-42", "42a", "4 2", "", "²", "٤٢"]) + def test_record_argument_must_be_a_plain_number(self, value, receipt, mock_get_client): + result = runner.invoke(app, ["invoices", "add-attachments", value, "--file", str(receipt)]) + assert result.exit_code == 2 + assert "record number" in _flat(result.output) + mock_get_client.post.assert_not_called() + + def test_negative_number_is_read_as_an_unknown_option(self, receipt, mock_get_client): + result = runner.invoke(app, ["invoices", "add-attachments", "-1", "--file", str(receipt)]) + assert result.exit_code == 2 + mock_get_client.post.assert_not_called() + + def test_prefixed_id_error_names_the_resource_prefix(self, receipt): + result = runner.invoke(app, ["invoices", "add-attachments", "IN-42", "--file", str(receipt)]) + assert "(not the IN-… ID)" in _flat(result.output) + + def test_empty_file(self, tmp_path, mock_get_client): + empty = tmp_path / "empty.pdf" + empty.touch() + result = runner.invoke(app, ["bills", "add-attachments", "3", "--file", str(empty)]) + assert result.exit_code == 2 + assert "is empty" in _flat(result.output) + mock_get_client.post.assert_not_called() + + def test_oversized_file_after_a_valid_one_sends_nothing(self, receipt, tmp_path, mock_get_client): + big = tmp_path / "scan.tiff" + with big.open("wb") as fh: + fh.truncate(ATTACHMENT_MAX_BYTES + 1) + result = runner.invoke(app, ["bills", "add-attachments", "3", "-f", str(receipt), "-f", str(big)]) + assert result.exit_code == 2 + assert "over the 10 MiB limit" in _flat(result.output) + mock_get_client.post.assert_not_called() + + def test_file_at_exactly_the_limit_is_accepted(self, tmp_path, mock_get_client): + edge = tmp_path / "edge.bin" + with edge.open("wb") as fh: + fh.truncate(ATTACHMENT_MAX_BYTES) + result = runner.invoke(app, ["bills", "add-attachments", "3", "-f", str(edge)]) + assert result.exit_code == 0, result.output + mock_get_client.post.assert_called_once() + + def test_missing_file(self, tmp_path, mock_get_client): + result = runner.invoke(app, ["bills", "add-attachments", "3", "--file", str(tmp_path / "nope.pdf")]) + assert result.exit_code == 2 + mock_get_client.post.assert_not_called() + + def test_directory_is_not_a_file(self, tmp_path, mock_get_client): + result = runner.invoke(app, ["bills", "add-attachments", "3", "--file", str(tmp_path)]) + assert result.exit_code == 2 + mock_get_client.post.assert_not_called() + + def test_file_flag_is_required(self, mock_get_client): + result = runner.invoke(app, ["bills", "add-attachments", "3"]) + assert result.exit_code == 2 + mock_get_client.post.assert_not_called() + + +class TestStatisticalJournalPrefix: + """`SJ-3` wraps the record number (unlike `IN-42`), so it is accepted and stripped.""" + + @pytest.mark.parametrize("value", ["3", "SJ-3", "sj-3"]) + def test_number_or_prefixed_number_uploads_to_that_number(self, value, receipt, mock_get_client): + result = runner.invoke(app, ["statistical-journals", "add-attachments", value, "-f", str(receipt)]) + assert result.exit_code == 0, result.output + args, _ = mock_get_client.post.call_args + assert args[0] == "/statistical-journals/3/attachments/" + + @pytest.mark.parametrize("value", ["IN-3", "SJ-", "SJ-x"]) + def test_other_prefixes_are_rejected(self, value, receipt, mock_get_client): + result = runner.invoke(app, ["statistical-journals", "add-attachments", value, "-f", str(receipt)]) + assert result.exit_code == 2 + assert "such as 3 or SJ-3" in _flat(result.output) + mock_get_client.post.assert_not_called() + + def test_oversized_file_is_now_rejected_here_too(self, tmp_path, mock_get_client): + big = tmp_path / "scan.tiff" + with big.open("wb") as fh: + fh.truncate(ATTACHMENT_MAX_BYTES + 1) + result = runner.invoke(app, ["statistical-journals", "add-attachments", "3", "-f", str(big)]) + assert result.exit_code == 2 + mock_get_client.post.assert_not_called() From 00013dcc62b9fbbfe2301b089a034a8c4a82b6ab Mon Sep 17 00:00:00 2001 From: Andy Woods <22278911+awoods187@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:37:44 -0400 Subject: [PATCH 2/4] Strip ANSI codes before matching attachment error messages in tests Rich colors its error panels under CI, and the escape codes split a wrapped message, so substring checks failed there but passed locally. --- tests/test_attachments.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/test_attachments.py b/tests/test_attachments.py index 4e99de3..50476e6 100644 --- a/tests/test_attachments.py +++ b/tests/test_attachments.py @@ -1,6 +1,7 @@ """`add-attachments` / `replace-attachments` on every resource whose v2 API accepts uploads.""" import json +import re from unittest.mock import MagicMock, patch import pytest @@ -50,8 +51,14 @@ def receipt(tmp_path): def _flat(output: str) -> str: - """Undo rich's error-panel wrapping, so a message split across box lines still matches.""" - return " ".join(output.replace("│", " ").split()) + """ + Undo rich's error-panel styling, so a message split across box lines still matches. + + Rich colors its output under CI (it detects GitHub Actions), wrapping every panel + line in ANSI codes, so those go before the box borders and line breaks. + """ + plain = re.sub(r"\x1b\[[0-9;]*m", "", output) + return " ".join(plain.replace("│", " ").split()) def _commands(resource: str) -> set[str]: From 87cde03444d620c785aa9238902abbf4d35c050e Mon Sep 17 00:00:00 2001 From: Andy Woods <22278911+awoods187@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:27:50 -0400 Subject: [PATCH 3/4] Send attachment uploads without an Idempotency-Key and show the result The API rejects an Idempotency-Key on non-JSON bodies, so every upload failed with a 400. Uploads now go unkeyed and, since a retry could then attach the files twice, are only retried after a 429. The command now lists the record's attachments instead of reprinting the whole record, which never showed them. --- src/dualentry_cli/client.py | 12 ++++-- src/dualentry_cli/commands/__init__.py | 7 +++- src/dualentry_cli/output.py | 21 ++++++++++ tests/test_attachments.py | 17 +++++++- tests/test_client.py | 54 ++++++++++++++++++++++++++ 5 files changed, 105 insertions(+), 6 deletions(-) diff --git a/src/dualentry_cli/client.py b/src/dualentry_cli/client.py index b68e59a..50418de 100644 --- a/src/dualentry_cli/client.py +++ b/src/dualentry_cli/client.py @@ -177,7 +177,11 @@ def _handle_response(self, response: httpx.Response, *, sent_idempotency_key: bo def _request(self, method: str, path: str, **kwargs) -> dict: method = method.upper() - keyed = method in _IDEMPOTENCY_METHODS + # The API rejects an Idempotency-Key on non-JSON bodies, so multipart uploads + # go unkeyed. Without a key, only a 429 proves the write did not run; a network + # error or 5xx may follow a completed upload, so those are not retried. + unkeyed_write = method in _IDEMPOTENCY_METHODS and "files" in kwargs + keyed = method in _IDEMPOTENCY_METHODS and not unkeyed_write if keyed: # One key per logical request, deliberately generated here rather than # per attempt: reusing it across retries is what makes a retry safe. @@ -194,13 +198,15 @@ def _request(self, method: str, path: str, **kwargs) -> dict: retry_after = None try: response = self._client.request(method, path, **kwargs) - if not _is_retryable(response): + retryable = response.status_code == 429 if unkeyed_write else _is_retryable(response) + if not retryable: return self._handle_response(response, sent_idempotency_key=keyed) retry_after = _retry_after_seconds(response) if retry_after is not None and retry_after > _MAX_RETRY_AFTER: return self._handle_response(response, sent_idempotency_key=keyed) except _RETRYABLE_EXCEPTIONS: - pass + if unkeyed_write: + raise # every retry waits, including the one after the loop delay = retry_after if retry_after is not None else backoff diff --git a/src/dualentry_cli/commands/__init__.py b/src/dualentry_cli/commands/__init__.py index 2c33d71..2d660fb 100644 --- a/src/dualentry_cli/commands/__init__.py +++ b/src/dualentry_cli/commands/__init__.py @@ -10,7 +10,7 @@ from dualentry_cli.cli import HelpfulGroup, make_list_command_cls from dualentry_cli.client import _MAX_PAGES, Multipart -from dualentry_cli.output import _NUMBER_PREFIXED, _RECORD_PREFIX, format_output +from dualentry_cli.output import _NUMBER_PREFIXED, _RECORD_PREFIX, format_output, print_attachments # Filter flags `list` can offer, with every option spelling each one accepts. FILTER_OPTIONS = { @@ -300,7 +300,10 @@ def upload(number: str, files: Sequence[Path], output: str, *, replace_existing: multipart = _read_attachments(files) route = "attachments/replace/" if replace_existing else "attachments/" data = get_client().post(f"/{path}/{record_number}/{route}", files=multipart) - format_output(data, resource=resource, fmt=output) + if output == "json": + format_output(data, resource=resource, fmt=output) + else: + print_attachments(data, label=f"{resource.replace('-', ' ')} {record_number}") @app.command("add-attachments") def add_attachments_cmd( diff --git a/src/dualentry_cli/output.py b/src/dualentry_cli/output.py index 3e7ac18..3045803 100644 --- a/src/dualentry_cli/output.py +++ b/src/dualentry_cli/output.py @@ -64,6 +64,27 @@ def format_output(data: dict, resource: str = "generic", fmt: str = "human") -> _print_detail(data, resource) +def _file_size(size: int) -> str: + for unit in ("B", "KiB"): + if size < 1024: + return f"{size:g} {unit}" + size /= 1024 + return f"{size:.1f} MiB" + + +def print_attachments(record: dict, *, label: str) -> None: + """Show a record's attachments after an upload, rather than the whole record.""" + attachments = record.get("attachments") or [] + table = Table(title=f"Attachments on {label}", show_lines=False) + table.add_column("ID", style="dim", justify="right") + table.add_column("File", style="bold") + table.add_column("Size", justify="right") + for a in attachments: + table.add_row(str(a.get("id", "-")), a.get("file_name") or "-", _file_size(a["file_size"]) if a.get("file_size") is not None else "-") + console.print(table) + console.print(f"{len(attachments)} attachment{'s' if len(attachments) != 1 else ''}") + + # ── Dispatcher ─────────────────────────────────────────────────────── # Map resource types to (list_fn, detail_fn) diff --git a/tests/test_attachments.py b/tests/test_attachments.py index 50476e6..c71a1fd 100644 --- a/tests/test_attachments.py +++ b/tests/test_attachments.py @@ -35,6 +35,10 @@ NO_ATTACHMENT_RESOURCES = ["customer-credits", "customer-payments", "vendor-credits", "vendor-prepayments", "vendor-prepayment-applications", "bank-transfers"] +# The shape the API returns for each attachment on the updated record. +_UPLOADED = {"id": 1, "file_name": "receipt.pdf", "file_size": 16, "download_url": "https://files.example.com/receipt.pdf"} + + @pytest.fixture(autouse=True) def mock_get_client(): mock_client = MagicMock() @@ -107,11 +111,22 @@ def test_repeated_file_flag_uploads_every_file_in_order(self, tmp_path, mock_get assert kwargs["files"] == [("files", ("a.png", b"png")), ("files", ("b.csv", b"csv"))] def test_json_output_prints_the_updated_record(self, receipt, mock_get_client): - mock_get_client.post.return_value = {"number": 7, "attachments": [{"name": "receipt.pdf"}]} + mock_get_client.post.return_value = {"number": 7, "attachments": [_UPLOADED]} result = runner.invoke(app, ["invoices", "add-attachments", "7", "-f", str(receipt), "-o", "json"]) assert result.exit_code == 0, result.output assert json.loads(result.output[result.output.index("{") :]) == mock_get_client.post.return_value + def test_human_output_lists_the_record_attachments(self, receipt, mock_get_client): + mock_get_client.post.return_value = {"number": 7, "attachments": [_UPLOADED, {**_UPLOADED, "id": 2, "file_name": "big.pdf", "file_size": 3_670_016}]} + result = runner.invoke(app, ["invoices", "add-attachments", "7", "-f", str(receipt)]) + assert result.exit_code == 0, result.output + out = _flat(result.output) + assert "Attachments on invoice 7" in out + assert "receipt.pdf" in out + assert "16 B" in out + assert "3.5 MiB" in out + assert "2 attachments" in out + class TestReplaceAttachments: def test_posts_to_the_replace_route(self, receipt, mock_get_client): diff --git a/tests/test_client.py b/tests/test_client.py index aa552fa..f5ecb13 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -236,6 +236,60 @@ def test_key_is_sent_even_when_retry_is_disabled(self): assert "Idempotency-Key" in route.calls[0].request.headers +class TestMultipartUploads: + """ + The API rejects an Idempotency-Key on non-JSON bodies, so uploads go unkeyed. + + Without a key a retry could attach the files twice, so only a 429, which proves + the upload did not run, is retried. + """ + + BASE = "https://api.dualentry.com/public/v2" + FILES = [("files", ("receipt.txt", b"hello"))] + + @pytest.fixture(autouse=True) + def no_backoff(self, monkeypatch): + monkeypatch.setattr("dualentry_cli.client._RETRY_DELAYS", [0, 0, 0]) + + @staticmethod + def _client(): + from dualentry_cli.client import DualEntryClient + + return DualEntryClient(api_url="https://api.dualentry.com", api_key="test_key", retry=True) + + @respx.mock + def test_upload_sends_no_idempotency_key(self): + route = respx.post(f"{self.BASE}/invoices/3/attachments/").mock(return_value=httpx.Response(200, json={"number": 3})) + self._client().post("/invoices/3/attachments/", files=self.FILES) + request = route.calls[0].request + assert "Idempotency-Key" not in request.headers + assert request.headers["content-type"].startswith("multipart/form-data") + + @respx.mock + def test_upload_is_retried_after_a_429(self): + route = respx.post(f"{self.BASE}/invoices/3/attachments/").mock( + side_effect=[httpx.Response(429, headers={"Retry-After": "0"}), httpx.Response(200, json={"number": 3})], + ) + assert self._client().post("/invoices/3/attachments/", files=self.FILES) == {"number": 3} + assert route.call_count == 2 + + @respx.mock + def test_upload_is_not_retried_after_a_5xx(self): + from dualentry_cli.client import APIError + + route = respx.post(f"{self.BASE}/invoices/3/attachments/").mock(return_value=httpx.Response(503)) + with pytest.raises(APIError): + self._client().post("/invoices/3/attachments/", files=self.FILES) + assert route.call_count == 1 + + @respx.mock + def test_upload_is_not_retried_after_a_network_error(self): + route = respx.post(f"{self.BASE}/invoices/3/attachments/").mock(side_effect=httpx.ReadTimeout("slow")) + with pytest.raises(httpx.ReadTimeout): + self._client().post("/invoices/3/attachments/", files=self.FILES) + assert route.call_count == 1 + + class TestRetryAfterAndConflicts: """ Retry timing follows the server, and the two meanings of 409 are separated. From a7d6bfac14989a895a8bb6ea082582a58be40058 Mon Sep 17 00:00:00 2001 From: Andy Woods <22278911+awoods187@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:29:40 -0400 Subject: [PATCH 4/4] Note the attachment upload fix in the changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46ea5e6..b639ada 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] - Add `add-attachments` to the 13 transaction types whose API accepts file uploads, and `replace-attachments` to invoices. Files are checked against the 10 MiB limit before anything is sent +- Fix attachment uploads failing with "Idempotency-Key is only supported on JSON requests". Uploads are sent without the key and retried only after a 429, so a retry cannot attach files twice - `statistical-journals add-attachments` now checks the 10 MiB limit before uploading, rejects folders, and rejects prefixes other than `SJ-` - Line-item tables label the quantity column Quantity - Update the CLI greeting logo to DualEntry