Skip to content
Merged
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
23 changes: 20 additions & 3 deletions src/dualentry_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@

import typer

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

LOGO = r"""
/$$$$$$$ /$$ /$$
Expand All @@ -23,6 +23,23 @@
"""


def make_list_command_cls(name: str, offered_options: list[str], gated_options: set[str]) -> type[TyperCommand]:
"""Build a list-command class that names the resource when a gated filter flag is passed."""
offered = ", ".join(offered_options) or "none"

class ResourceListCommand(TyperCommand):
def parse_args(self, ctx, args):
try:
return super().parse_args(ctx, args)
except NoSuchOption as exc:
if exc.option_name not in gated_options:
raise
message = f"{name} does not support {exc.option_name}. Supported filters: {offered}."
raise UsageError(message, ctx=ctx) from None

return ResourceListCommand


Comment thread
Warkanlock marked this conversation as resolved.
class HelpfulGroup(TyperGroup):
"""Typer group that shows help + suggestions instead of 'No such command'."""

Expand Down
60 changes: 45 additions & 15 deletions src/dualentry_cli/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,22 @@

import typer

from dualentry_cli.cli import HelpfulGroup
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

# Filter flags `list` can offer, with every option spelling each one accepts.
Comment thread
Warkanlock marked this conversation as resolved.
FILTER_OPTIONS = {
"search": ("--search", "-s"),
"status": ("--status",),
"start_date": ("--start-date",),
"end_date": ("--end-date",),
"company": ("--company", "-c"),
"customer": ("--customer",),
"vendor": ("--vendor",),
}
ALL_FILTERS = frozenset(FILTER_OPTIONS)

# ── Shared option defaults ──────────────────────────────────────────

Limit = typer.Option(20, "--limit", "-l", help="Max items to return")
Expand Down Expand Up @@ -52,19 +64,25 @@ def _strip_record_prefix(number: str) -> str:
return number


def _supplied(value) -> str | None:
"""Return a flag's value, or None when a stripped option left its OptionInfo sentinel unbound."""
Comment thread
Warkanlock marked this conversation as resolved.
return value if isinstance(value, str) else None


def _build_filter_params(
search: str | None = None,
status: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
status_param: str = "record_status",
**extra,
) -> dict:
"""Build filter query params, omitting None values."""
params: dict = {}
if search:
params["search"] = search
if status:
params["record_status"] = status
params[status_param] = status
if start_date:
params["start_date"] = start_date
if end_date:
Expand Down Expand Up @@ -170,18 +188,31 @@ def make_resource_app(
has_delete: bool = False,
has_number: bool = False,
has_post: bool = False,
filters: set[str] | None = None,
filters: set[str],
status_param: str = "record_status",
template: dict | None = None,
checks: list[Callable] | None = None,
online_checks: list[Callable] | None = None,
) -> typer.Typer:
"""Create a Typer app for a standard CRUD resource."""
"""
Create a Typer app for a standard CRUD resource.

`filters` lists the flags this resource's v2 list endpoint declares; the rest
are removed from `list` so the API cannot silently drop them.
"""
import inspect

app = typer.Typer(help=f"Manage {name}", no_args_is_help=True, cls=HelpfulGroup)
enabled_filters = filters or set()
unknown = filters - ALL_FILTERS
if unknown:
msg = f"{name}: unknown filter flags {sorted(unknown)}"
raise ValueError(msg)
remove = ALL_FILTERS - filters

gated_options = {option for flag in remove for option in FILTER_OPTIONS[flag]}
offered_options = sorted(FILTER_OPTIONS[flag][0] for flag in filters)

@app.command("list")
@app.command("list", cls=make_list_command_cls(name, offered_options, gated_options))
def list_cmd(
*,
limit: int = Limit,
Expand All @@ -207,19 +238,18 @@ def list_cmd(
offset=offset,
all_pages=all_pages,
output=output,
search=search,
status=status,
start_date=start_date,
end_date=end_date,
company_id=company if isinstance(company, str) else None,
customer_id=customer if isinstance(customer, str) else None,
vendor_id=vendor if isinstance(vendor, str) else None,
search=_supplied(search),
status=_supplied(status),
start_date=_supplied(start_date),
end_date=_supplied(end_date),
status_param=status_param,
company_id=_supplied(company),
customer_id=_supplied(customer),
vendor_id=_supplied(vendor),
)

list_cmd.__doc__ = f"List {name}."

all_filters = {"company", "customer", "vendor"}
remove = all_filters - enabled_filters
if remove:
sig = inspect.signature(list_cmd)
list_cmd.__signature__ = sig.replace(parameters=[p for p in sig.parameters.values() if p.name not in remove])
Expand Down
90 changes: 53 additions & 37 deletions src/dualentry_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,78 +17,94 @@
app.add_typer(auth_app, name="auth")
app.add_typer(config_app, name="config")

# Filter flags each resource's /public/v2 list endpoint declares (verified against
# the v2 OpenAPI schema); a flag left out here is one the API would silently ignore.
TXN = {"search", "status", "start_date", "end_date", "company"}
TXN_CUSTOMER = TXN | {"customer"}
TXN_VENDOR = TXN | {"vendor"}
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={"customer", "company"}), name="invoices")
app.add_typer(make_resource_app("bills", "bill", "bills", has_number=True, filters={"vendor", "company"}), name="bills")
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(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={"customer", "company"}), name="sales-orders")
app.add_typer(make_resource_app("customer payments", "customer-payment", "customer-payments", has_number=True, filters={"customer", "company"}), name="customer-payments")
app.add_typer(make_resource_app("customer credits", "customer-credit", "customer-credits", has_number=True, filters={"customer", "company"}), name="customer-credits")
app.add_typer(
make_resource_app("customer prepayments", "customer-prepayment", "customer-prepayments", has_number=True, filters={"customer", "company"}), name="customer-prepayments"
)
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("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={"customer", "company"}),
make_resource_app("customer prepayment applications", "customer-prepayment-application", "customer-prepayment-applications", has_number=True, filters=TXN_CUSTOMER),
name="customer-prepayment-applications",
)
app.add_typer(make_resource_app("customer deposits", "customer-deposit", "customer-deposits", has_number=True, filters={"customer", "company"}), name="customer-deposits")
app.add_typer(make_resource_app("customer refunds", "customer-refund", "customer-refunds", has_number=True, filters={"customer", "company"}), name="customer-refunds")
app.add_typer(make_resource_app("cash sales", "cash-sale", "cash-sales", has_number=True, filters={"customer", "company"}), name="cash-sales")
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")

# Money-out
app.add_typer(make_resource_app("purchase orders", "purchase-order", "purchase-orders", has_number=True, filters={"vendor", "company"}), name="purchase-orders")
app.add_typer(make_resource_app("vendor payments", "vendor-payment", "vendor-payments", has_number=True, filters={"vendor", "company"}), name="vendor-payments")
app.add_typer(make_resource_app("vendor credits", "vendor-credit", "vendor-credits", has_number=True, filters={"vendor", "company"}), name="vendor-credits")
app.add_typer(make_resource_app("vendor prepayments", "vendor-prepayment", "vendor-prepayments", has_number=True, filters={"vendor", "company"}), name="vendor-prepayments")
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("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={"vendor", "company"}),
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={"vendor", "company"}), name="vendor-refunds")
app.add_typer(make_resource_app("direct expenses", "direct-expense", "direct-expenses", has_number=True, filters={"vendor", "company"}), name="direct-expenses")
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")

# Accounting
app.add_typer(make_resource_app("journal entries", "journal-entry", "journal-entries", has_number=True), name="journal-entries")
app.add_typer(make_resource_app("bank transfers", "bank-transfer", "bank-transfers", has_number=True), name="bank-transfers")
app.add_typer(make_resource_app("fixed assets", "fixed-asset", "fixed-assets", has_number=True), name="fixed-assets")
app.add_typer(make_resource_app("depreciation books", "depreciation-book", "depreciation-books"), name="depreciation-books")
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("bank transfers", "bank-transfer", "bank-transfers", has_number=True, filters=TXN), name="bank-transfers")
app.add_typer(
make_resource_app("fixed assets", "fixed-asset", "fixed-assets", has_number=True, filters={"search", "status", "company", "customer", "vendor"}),
name="fixed-assets",
)
app.add_typer(make_resource_app("depreciation books", "depreciation-book", "depreciation-books", filters=set()), name="depreciation-books")

# Entities
app.add_typer(make_resource_app("customers", "customer", "customers"), name="customers")
app.add_typer(make_resource_app("vendors", "vendor", "vendors"), name="vendors")
app.add_typer(make_resource_app("items", "item", "items"), name="items")
app.add_typer(make_resource_app("companies", "company", "companies"), name="companies")
app.add_typer(make_resource_app("classifications", "classification", "classifications"), name="classifications")
app.add_typer(make_resource_app("customers", "customer", "customers", filters={"search", "status", "company"}), name="customers")
app.add_typer(make_resource_app("vendors", "vendor", "vendors", filters=TXN), name="vendors")
app.add_typer(make_resource_app("items", "item", "items", filters={"search", "status"}), name="items")
app.add_typer(make_resource_app("companies", "company", "companies", filters={"search"}), name="companies")
app.add_typer(make_resource_app("classifications", "classification", "classifications", filters={"search"}), name="classifications")

# Recurring
recurring_app = typer.Typer(help="Manage recurring records", no_args_is_help=True, cls=HelpfulGroup)
recurring_app.add_typer(make_resource_app("recurring invoices", "recurring-invoice", "recurring/invoices", has_delete=True), name="invoices")
recurring_app.add_typer(make_resource_app("recurring bills", "recurring-bill", "recurring/bills", has_delete=True), name="bills")
recurring_app.add_typer(make_resource_app("recurring journal entries", "recurring-journal-entry", "recurring/journal-entries", has_delete=True), name="journal-entries")
RECURRING_FILTERS = {"search", "company"}
recurring_app.add_typer(make_resource_app("recurring invoices", "recurring-invoice", "recurring/invoices", has_delete=True, filters=RECURRING_FILTERS), name="invoices")
recurring_app.add_typer(make_resource_app("recurring bills", "recurring-bill", "recurring/bills", has_delete=True, filters=RECURRING_FILTERS), name="bills")
recurring_app.add_typer(
make_resource_app("recurring journal entries", "recurring-journal-entry", "recurring/journal-entries", has_delete=True, filters=RECURRING_FILTERS),
name="journal-entries",
)
app.add_typer(recurring_app, name="recurring")

# Other
app.add_typer(make_resource_app("contracts", "contract", "contracts"), name="contracts")
app.add_typer(make_resource_app("budgets", "budget", "budgets"), name="budgets")
app.add_typer(make_resource_app("workflows", "workflow", "workflows", has_create=False, has_update=False), name="workflows")
# Contracts name their status filter `status`, not `record_status`.
app.add_typer(make_resource_app("contracts", "contract", "contracts", filters=TXN_CUSTOMER, status_param="status"), name="contracts")
app.add_typer(make_resource_app("budgets", "budget", "budgets", filters={"search", "status", "company"}), name="budgets")
app.add_typer(
make_resource_app("workflows", "workflow", "workflows", has_create=False, has_update=False, filters={"search", "company"}),
name="workflows",
)
app.add_typer(
make_resource_app(
"intercompany journal entries",
"intercompany-journal-entry",
"intercompany-journal-entries",
has_number=True,
has_post=True,
filters={"company"},
filters=TXN,
template=IJE_TEMPLATE,
checks=IJE_CHECKS,
online_checks=IJE_ONLINE_EXTRA_CHECKS,
),
name="intercompany-journal-entries",
)
app.add_typer(make_resource_app("paper checks", "paper-check", "paper-checks", has_number=True), name="paper-checks")
app.add_typer(make_resource_app("inbox items", "inbox-item", "inbox", has_create=False, has_update=False), name="inbox")
app.add_typer(make_resource_app("paper checks", "paper-check", "paper-checks", has_number=True, filters=TXN_ALL_PARTIES), name="paper-checks")
app.add_typer(make_resource_app("inbox items", "inbox-item", "inbox", has_create=False, has_update=False, filters={"search"}), name="inbox")


def version_callback(value: bool):
Expand Down
Loading
Loading