"""Typed ``--set KEY=VALUE`` parser for record-edit commands.
The CLI exposes ``--set KEY=VALUE`` on ledger / invoice review-edit
commands. The canonical grammar:
- ledger: ``--set category=software``, ``--set business.share=1.0``,
``--set reference=invoice-1``, ``--set comments=…``,
``--set document.path=…``
- invoice: ``--set base=120.00``, ``--set iva.rate=21``,
``--set iva.amount=25.20``, ``--set iva.category=…``,
``--set retention.rate=15``, ``--set retention.amount=18.00``,
``--set payment.id=row_…``, ``--set reference=…``,
``--set comments=…``, ``--set document.path=…``
The CLI argv layer parses the raw strings; this module turns them
into typed :class:`EditClause` records and binds them to per-scope
:class:`LedgerEditSpec` / :class:`InvoiceEditSpec` records. Each spec
validates its closed
key catalogue, coerces each value to its typed shape (``Decimal``,
``Path``, free text, foreign-key id), and exposes typed accessors the
orchestration layer reads instead of re-parsing strings at every
edit-application call site.
The orchestration that applies an ``EditSpec`` to a stored
:class:`aeat.domain.transactions.Transaction` /
:class:`aeat.domain.invoices.Invoice` /
:class:`aeat.domain.filing.ModeloDraft` lives in the
``application/workflow`` state overlay.
"""
from __future__ import annotations
import re
from collections.abc import Iterable
from decimal import Decimal
from enum import StrEnum
from pathlib import Path
from pydantic import BaseModel, Field, field_validator, model_validator
from ...core import STRICT_FROZEN_CONFIG as _STRICT_FROZEN
from ...core.decimal import coerce_decimal
from ...domain.invoices import numeric_iva_rate_percentages
from ._errors import EditParseError
_DECIMAL_RE = re.compile(r"^-?\d+(\.\d+)?$")
"""Reject malformed decimals before constructor; ruff-friendly fast path."""
__all__ = (
"EditClause",
"EditParseError",
"InvoiceEditKey",
"InvoiceEditSpec",
"LedgerEditKey",
"LedgerEditSpec",
"parse_edit_clause",
"parse_edit_clauses",
)
[docs]
class EditClause(BaseModel):
"""One typed ``KEY=VALUE`` clause from a ``--set`` flag.
The ``raw_value`` is the trimmed string before per-key coercion;
typed coercion happens on the owning scope's spec.
Attributes:
key: Lowercase, dotted-or-flat identifier.
raw_value: Trimmed value string.
"""
model_config = _STRICT_FROZEN
key: str = Field(min_length=1, max_length=64, pattern=r"^[a-z][a-z0-9_.]*$")
raw_value: str = Field(min_length=1, max_length=512)
@field_validator("raw_value")
@classmethod
def _trim_value(cls, value: str) -> str:
"""Trim the value while rejecting blank-but-not-empty inputs."""
trimmed = value.strip()
if not trimmed:
raise ValueError("edit value must not be blank")
return trimmed
[docs]
def parse_edit_clause(raw: str) -> EditClause:
"""Parse one raw ``KEY=VALUE`` string into an :class:`EditClause`.
Args:
raw: The raw ``KEY=VALUE`` token supplied by the operator.
Returns:
A validated :class:`EditClause` with the parsed key and value.
Raises:
EditParseError: When the token is missing ``=``, has an empty
key, or has an empty value.
"""
if "=" not in raw:
raise EditParseError(raw, reason="missing-equals")
key_raw, _, value_raw = raw.partition("=")
key = key_raw.strip().lower()
value = value_raw.strip()
if not key:
raise EditParseError(raw, reason="empty-key")
if not value:
raise EditParseError(raw, reason="empty-value")
try:
return EditClause(key=key, raw_value=value)
except ValueError as exc:
raise EditParseError(raw, reason="invalid-value") from exc
[docs]
def parse_edit_clauses(raw: Iterable[str]) -> tuple[EditClause, ...]:
"""Parse a sequence of ``--set`` strings into :class:`EditClause` records, preserving input order."""
return tuple(parse_edit_clause(item) for item in raw)
# ---------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------
def _coerce_decimal(clause: EditClause, *, scope: str) -> Decimal:
"""Coerce a clause value into :class:`Decimal` or raise EditParseError."""
if not _DECIMAL_RE.fullmatch(clause.raw_value):
raise EditParseError(
f"--set {clause.key}={clause.raw_value}",
reason=f"invalid-value-{scope}",
)
result = coerce_decimal(clause.raw_value)
if result is None:
raise EditParseError(
f"--set {clause.key}={clause.raw_value}",
reason=f"invalid-value-{scope}",
)
return result
_INVOICE_IVA_RATE_ALLOWED: frozenset[Decimal] = numeric_iva_rate_percentages()
"""Closed set of integer-percentage IVA rates the CLI accepts on
``--set iva.rate``. Derived structurally from
:func:`aeat.domain.invoices.numeric_iva_rate_percentages` so it tracks
:class:`aeat.domain.invoices.IvaRate` membership (``RATE_0`` / ``RATE_4``
/ ``RATE_10`` / ``RATE_21``) without re-listing literals here. The
underlying substrate at :func:`aeat.domain.iva.lookup_rate` is the
authority for the fractional percentages those slots resolve to at a date.
Rejecting non-canonical values at parse time keeps free-form Decimal IVA
rates from flowing through the edit spec into ledger records."""
def _coerce_invoice_iva_rate(clause: EditClause) -> Decimal:
"""Coerce ``--set iva.rate`` to a substrate-known IVA percentage.
The CLI uses integer percentages (``21`` / ``10`` / ``4`` / ``0``);
the spec stores them as a :class:`Decimal` so downstream review-state
code can compare against ``Decimal("21")`` etc. Rejects any
non-canonical value to anchor the review-edit boundary on the
closed substrate slot taxonomy.
"""
value = _coerce_decimal(clause, scope="invoice-iva-rate")
if value not in _INVOICE_IVA_RATE_ALLOWED:
raise EditParseError(
f"--set {clause.key}={clause.raw_value}",
reason="unsupported-iva-rate",
)
return value
def _coerce_invoice_retention_rate(clause: EditClause) -> Decimal:
"""Coerce ``--set retention.rate`` and bound it to ``[0, 100]``.
IRPF retention rates are not yet centralised in a substrate; the
edit spec restricts the value to the legal percentage envelope
(``0`` through ``100`` inclusive) so review-state writes cannot
introduce nonsense values into ledger records. Tightens the V-3
boundary on the retention axis without committing to a closed
rate enum that the substrate does not yet expose.
"""
value = _coerce_decimal(clause, scope="invoice-retention-rate")
if value < Decimal("0") or value > Decimal("100"):
raise EditParseError(
f"--set {clause.key}={clause.raw_value}",
reason="retention-rate-out-of-range",
)
return value
def _coerce_share(clause: EditClause, *, scope: str) -> Decimal:
"""Coerce a clause value into a share Decimal in the inclusive 0..1 range."""
value = _coerce_decimal(clause, scope=scope)
if value < Decimal("0") or value > Decimal("1"):
raise EditParseError(
f"--set {clause.key}={clause.raw_value}",
reason=f"invalid-value-{scope}",
)
return value
def _coerce_path(clause: EditClause) -> Path:
"""Coerce a clause value into :class:`pathlib.Path`.
The parser does not check filesystem existence — the consuming
use-case is responsible. Empty / blank values were rejected by
:func:`parse_edit_clause` already.
"""
return Path(clause.raw_value)
def _ensure_unique_keys(
clauses: tuple[EditClause, ...],
*,
scope: str,
) -> None:
"""Reject ``--set`` specs that carry the same key twice."""
seen: set[str] = set()
for clause in clauses:
if clause.key in seen:
raise EditParseError(
f"--set {clause.key}={clause.raw_value}",
reason=f"duplicate-key-{scope}",
)
seen.add(clause.key)
def _ensure_known_keys(
clauses: tuple[EditClause, ...],
*,
scope: str,
allowed: set[str],
) -> None:
"""Reject keys outside the per-scope catalogue."""
for clause in clauses:
if clause.key not in allowed:
raise EditParseError(
f"--set {clause.key}={clause.raw_value}",
reason=f"unknown-key-{scope}",
)
# ---------------------------------------------------------------------
# Per-scope key catalogues
# ---------------------------------------------------------------------
[docs]
class LedgerEditKey(StrEnum):
"""Closed catalogue of ``aeat app ledger update --set`` keys.
Attributes:
CATEGORY: Spending / income category foreign-key id.
BUSINESS_SHARE: Decimal share in ``[0, 1]`` of the row that is
business-related (mixed-use accounting).
REFERENCE: Free-text user reference (invoice number, ticket
id, contract id).
COMMENTS: Free-text operator note.
DOCUMENT_PATH: Filesystem path to a supporting document.
"""
CATEGORY = "category"
TREATMENT = "treatment"
BUSINESS_SHARE = "business.share"
REFERENCE = "reference"
COMMENTS = "comments"
DOCUMENT_PATH = "document.path"
[docs]
class InvoiceEditKey(StrEnum):
"""Closed catalogue of invoice-evidence edit keys.
Attributes:
BASE: Decimal taxable base amount.
IVA_RATE: Decimal IVA percentage (``21`` / ``10`` / ``4`` /
``0`` are the supported rates; the parser accepts any
non-negative decimal so the grammar is not tied to a fixed
rate table).
IVA_AMOUNT: Decimal IVA amount.
IVA_CATEGORY: Free-text IVA category.
RETENTION_RATE: Decimal IRPF retention percentage.
RETENTION_AMOUNT: Decimal IRPF retention amount.
PAYMENT_ID: Foreign-key transaction id linked as the payment.
REFERENCE: Free-text invoice reference.
COMMENTS: Free-text operator note.
DOCUMENT_PATH: Filesystem path to the invoice file.
"""
BASE = "base"
IVA_RATE = "iva.rate"
IVA_AMOUNT = "iva.amount"
IVA_CATEGORY = "iva.category"
RETENTION_RATE = "retention.rate"
RETENTION_AMOUNT = "retention.amount"
PAYMENT_ID = "payment.id"
REFERENCE = "reference"
COMMENTS = "comments"
DOCUMENT_PATH = "document.path"
# ---------------------------------------------------------------------
# Spec records
# ---------------------------------------------------------------------
[docs]
class LedgerEditSpec(BaseModel):
"""Typed ``aeat app ledger update --set`` spec.
Attributes:
clauses: Raw clauses in input order.
category: Resolved spending / income category id.
business_share: Resolved business share (0..1).
reference: Resolved free-text reference.
comments: Resolved free-text comments.
document_path: Resolved supporting-document path.
"""
model_config = _STRICT_FROZEN
clauses: tuple[EditClause, ...] = ()
category: str | None = None
business_share: Decimal | None = None
reference: str | None = None
comments: str | None = None
document_path: Path | None = None
[docs]
@classmethod
def from_strings(cls, raw: Iterable[str]) -> LedgerEditSpec:
"""Parse ``--set`` arguments into a typed ledger spec.
Returns a :class:`LedgerEditSpec` with fields populated from the
parsed ``key=value`` clause strings.
"""
clauses = parse_edit_clauses(raw)
valid = {member.value for member in LedgerEditKey}
_ensure_known_keys(clauses, scope="ledger", allowed=valid)
_ensure_unique_keys(clauses, scope="ledger")
category: str | None = None
business_share: Decimal | None = None
reference: str | None = None
comments: str | None = None
document_path: Path | None = None
for clause in clauses:
if clause.key in {LedgerEditKey.CATEGORY, LedgerEditKey.TREATMENT}:
category = clause.raw_value
elif clause.key == LedgerEditKey.BUSINESS_SHARE:
business_share = _coerce_share(clause, scope="ledger-business-share")
elif clause.key == LedgerEditKey.REFERENCE:
reference = clause.raw_value
elif clause.key == LedgerEditKey.COMMENTS:
comments = clause.raw_value
elif clause.key == LedgerEditKey.DOCUMENT_PATH:
document_path = _coerce_path(clause)
return cls(
clauses=clauses,
category=category,
business_share=business_share,
reference=reference,
comments=comments,
document_path=document_path,
)
@model_validator(mode="after")
def _enforce_clause_consistency(self) -> LedgerEditSpec:
"""Resolved fields must agree with the clauses tuple."""
present = {clause.key for clause in self.clauses}
if (LedgerEditKey.CATEGORY in present or LedgerEditKey.TREATMENT in present) != (self.category is not None):
raise ValueError("clauses[category] / category field disagree")
if (LedgerEditKey.BUSINESS_SHARE in present) != (self.business_share is not None):
raise ValueError("clauses[business.share] / business_share field disagree")
if (LedgerEditKey.REFERENCE in present) != (self.reference is not None):
raise ValueError("clauses[reference] / reference field disagree")
if (LedgerEditKey.COMMENTS in present) != (self.comments is not None):
raise ValueError("clauses[comments] / comments field disagree")
if (LedgerEditKey.DOCUMENT_PATH in present) != (self.document_path is not None):
raise ValueError("clauses[document.path] / document_path field disagree")
return self
[docs]
class InvoiceEditSpec(BaseModel):
"""Typed invoice-evidence edit spec.
Attributes:
clauses: Raw clauses in input order.
base: Decimal taxable base.
iva_rate: Decimal IVA percentage.
iva_amount: Decimal IVA amount.
iva_category: Free-text IVA category.
retention_rate: Decimal IRPF retention percentage.
retention_amount: Decimal IRPF retention amount.
payment_id: Foreign-key transaction id.
reference: Free-text invoice reference.
comments: Free-text operator note.
document_path: Path to the invoice file.
"""
model_config = _STRICT_FROZEN
clauses: tuple[EditClause, ...] = ()
base: Decimal | None = None
iva_rate: Decimal | None = None
iva_amount: Decimal | None = None
iva_category: str | None = None
retention_rate: Decimal | None = None
retention_amount: Decimal | None = None
payment_id: str | None = None
reference: str | None = None
comments: str | None = None
document_path: Path | None = None
[docs]
@classmethod
def from_strings(cls, raw: Iterable[str]) -> InvoiceEditSpec:
"""Parse ``--set`` arguments into a typed :class:`InvoiceEditSpec`."""
clauses = parse_edit_clauses(raw)
valid = {member.value for member in InvoiceEditKey}
_ensure_known_keys(clauses, scope="invoice", allowed=valid)
_ensure_unique_keys(clauses, scope="invoice")
base: Decimal | None = None
iva_rate: Decimal | None = None
iva_amount: Decimal | None = None
iva_category: str | None = None
retention_rate: Decimal | None = None
retention_amount: Decimal | None = None
payment_id: str | None = None
reference: str | None = None
comments: str | None = None
document_path: Path | None = None
for clause in clauses:
if clause.key == InvoiceEditKey.BASE:
base = _coerce_decimal(clause, scope="invoice-base")
elif clause.key == InvoiceEditKey.IVA_RATE:
iva_rate = _coerce_invoice_iva_rate(clause)
elif clause.key == InvoiceEditKey.IVA_AMOUNT:
iva_amount = _coerce_decimal(clause, scope="invoice-iva-amount")
elif clause.key == InvoiceEditKey.IVA_CATEGORY:
iva_category = clause.raw_value
elif clause.key == InvoiceEditKey.RETENTION_RATE:
retention_rate = _coerce_invoice_retention_rate(clause)
elif clause.key == InvoiceEditKey.RETENTION_AMOUNT:
retention_amount = _coerce_decimal(clause, scope="invoice-retention-amount")
elif clause.key == InvoiceEditKey.PAYMENT_ID:
payment_id = clause.raw_value
elif clause.key == InvoiceEditKey.REFERENCE:
reference = clause.raw_value
elif clause.key == InvoiceEditKey.COMMENTS:
comments = clause.raw_value
elif clause.key == InvoiceEditKey.DOCUMENT_PATH:
document_path = _coerce_path(clause)
return cls(
clauses=clauses,
base=base,
iva_rate=iva_rate,
iva_amount=iva_amount,
iva_category=iva_category,
retention_rate=retention_rate,
retention_amount=retention_amount,
payment_id=payment_id,
reference=reference,
comments=comments,
document_path=document_path,
)
@model_validator(mode="after")
def _enforce_clause_consistency(self) -> InvoiceEditSpec:
"""Resolved fields must agree with the clauses tuple."""
present = {clause.key for clause in self.clauses}
checks = (
(InvoiceEditKey.BASE, self.base, "base"),
(InvoiceEditKey.IVA_RATE, self.iva_rate, "iva.rate"),
(InvoiceEditKey.IVA_AMOUNT, self.iva_amount, "iva.amount"),
(InvoiceEditKey.IVA_CATEGORY, self.iva_category, "iva.category"),
(InvoiceEditKey.RETENTION_RATE, self.retention_rate, "retention.rate"),
(InvoiceEditKey.RETENTION_AMOUNT, self.retention_amount, "retention.amount"),
(InvoiceEditKey.PAYMENT_ID, self.payment_id, "payment.id"),
(InvoiceEditKey.REFERENCE, self.reference, "reference"),
(InvoiceEditKey.COMMENTS, self.comments, "comments"),
(InvoiceEditKey.DOCUMENT_PATH, self.document_path, "document.path"),
)
for key, value, label in checks:
if (key in present) != (value is not None):
raise ValueError(f"clauses[{label}] / {label} field disagree")
return self
__all__ = [
"EditClause",
"EditParseError",
"InvoiceEditKey",
"InvoiceEditSpec",
"LedgerEditKey",
"LedgerEditSpec",
"parse_edit_clause",
"parse_edit_clauses",
]