"""Registry error types for AEAT legal calculation definitions.
This module provides classmethod factories on :class:`RegistryValidationError`
and :class:`RegistrySnapshotError` for each canonical raise scenario. The
factory pattern pins the context-dict keys downstream consumers
(``aeat.core.errors._registry`` template renderer, CLI JSON emit via
``SchemaEnvelope``, i18n locales referencing keys by name) rely on.
The existing ``raise RegistryValidationError(message, context=...)`` shape
stays valid for one-off scenarios that haven't been promoted to canonical
factories yet; migration is additive and non-breaking.
"""
from __future__ import annotations
from collections.abc import Iterable, Sequence
from typing import Self
from ....core.errors import AeatError, CoreValidationError
from ._ids import BindingId, CasillaId, RelationId
[docs]
class RegistryError(AeatError, ValueError):
"""Base error for registry loading, resolution, and validation."""
[docs]
class RegistryLoadError(RegistryError):
"""Raised when registry files cannot be parsed into strict schema objects."""
def _csv(items: Iterable[str]) -> str:
"""Stable comma-join used by every context-key serialisation.
Callers pass already-sorted tuples where order matters; this
helper just centralises the ``", ".join`` style so a future
formatting change lands in one place.
"""
return ",".join(items)
[docs]
class RegistryValidationError(RegistryError, CoreValidationError):
"""Raised when registry definitions are incomplete or contradictory.
Inherits from CoreValidationError to participate in the shared
CoreValidationError catch surface across all layers. RegistryError
already provides ValueError co-inheritance.
Canonical raise scenarios route through one of the ``for_*``
classmethod factories so the context-dict keys consumed by
locale templates and CLI JSON emit are pinned to a named
contract per scenario.
"""
[docs]
@classmethod
def for_unsupported_op(cls, op: str) -> Self:
"""Formula expression uses an op the runtime does not implement.
Canonical key: ``op``. Twelve raise sites today.
"""
return cls(
f"formula expression uses unsupported op {op!r}",
translated_message="errors.calc.unsupported_op",
context={"op": op},
)
[docs]
@classmethod
def for_unsupported_comparison_op(cls, op: str) -> Self:
"""``compare(...)`` received an op name outside the closed comparison set."""
return cls(
f"formula expression uses unsupported comparison op {op!r}",
translated_message="errors.calc.unsupported_comparison_op",
context={"op": op},
)
[docs]
@classmethod
def for_unknown_parameter(cls, *, parameter_id: str) -> Self:
"""A formula referenced a parameter id absent from the revision.
Canonical key: ``parameter_id``. Seven raise sites today.
"""
return cls(
f"parameter {parameter_id!r} not registered",
translated_message="errors.calc.parameter_unknown",
context={"parameter_id": parameter_id},
)
[docs]
@classmethod
def for_dispatch_key_unknown(
cls,
*,
op: str,
binding_id: BindingId,
dispatch_key: str,
available_keys: Sequence[str],
) -> Self:
"""A formula's dispatch_table is missing the resolved enum key.
Canonical keys: ``op``, ``binding_id``, ``dispatch_key``,
``available_keys``. Three raise sites today (lookup_bracket_by_ccaa
/ lookup_parameter_by_entity_type / lookup_bracket_by_entity_type).
"""
return cls(
f"{op} dispatch_table is missing key {dispatch_key!r} (declared keys: {sorted(available_keys)})",
translated_message="errors.calc.dispatch_key_unknown",
context={
"op": op,
"binding_id": binding_id,
"dispatch_key": dispatch_key,
"available_keys": _csv(sorted(available_keys)),
},
)
[docs]
@classmethod
def for_lookup_dispatch_arg_kind(
cls,
*,
op: str,
position: str,
expected_kind: str,
) -> Self:
"""A lookup-dispatch op's positional arg has the wrong leaf kind.
Canonical keys: ``op``, ``position``, ``expected_kind``. Four
raise sites today.
"""
return cls(
f"formula op {op!r} requires {position} to be a {expected_kind} leaf",
translated_message="errors.calc.lookup_dispatch_arg_kind",
context={"op": op, "position": position, "expected_kind": expected_kind},
)
[docs]
@classmethod
def for_lookup_dispatch_arg_count(cls, *, op: str, expected: str) -> Self:
"""A lookup-dispatch op was passed the wrong number of args.
Canonical keys: ``op``, ``expected``.
"""
return cls(
f"formula op {op!r} expects {expected} args",
translated_message="errors.calc.lookup_dispatch_arg_count",
context={"op": op, "expected": expected},
)
[docs]
@classmethod
def for_dispatch_parameter_kind(
cls,
*,
parameter_id: str,
op: str,
) -> Self:
"""A dispatched parameter has the wrong ``data_type`` for its op."""
return cls(
f"parameter {parameter_id!r} has wrong data_type for {op!r}",
translated_message="errors.calc.dispatch_parameter_kind",
context={"parameter_id": parameter_id, "op": op},
)
[docs]
@classmethod
def for_enum_binding_value_missing(cls, *, binding_id: BindingId, op: str) -> Self:
"""A required enum binding has no supplied value at evaluation time.
Canonical keys: ``binding_id``, ``op``.
"""
return cls(
f"enum binding {binding_id!r} has no supplied value; required by {op}",
translated_message="errors.calc.enum_binding_value_missing",
context={"binding_id": binding_id, "op": op},
)
[docs]
@classmethod
def for_binding_value_missing(cls, *, binding_id: BindingId) -> Self:
"""A required binding has no supplied value at evaluation time."""
return cls(
f"binding {binding_id!r} has no supplied value",
translated_message="errors.calc.binding_value_missing",
context={"binding_id": binding_id},
)
[docs]
@classmethod
def for_relation_value_missing(cls, *, relation_id: RelationId) -> Self:
"""A required relation has no supplied value at evaluation time."""
return cls(
f"relation {relation_id!r} has no supplied value",
translated_message="errors.calc.relation_value_missing",
context={"relation_id": relation_id},
)
[docs]
@classmethod
def for_casilla_referenced_before_evaluation(cls, *, casilla_id: CasillaId) -> Self:
"""A formula referenced a casilla that hasn't been evaluated yet."""
return cls(
f"casilla {casilla_id!r} referenced before evaluation",
translated_message="errors.calc.casilla_referenced_before_evaluation",
context={"casilla_id": casilla_id},
)
[docs]
@classmethod
def for_bracket_no_window(cls, *, parameter_id: str, as_of: str) -> Self:
"""A bracket-table parameter has no bracket valid for the requested date."""
return cls(
f"parameter {parameter_id!r} has no bracket valid for {as_of}",
translated_message="errors.calc.bracket_no_window",
context={"parameter_id": parameter_id, "as_of": as_of},
)
[docs]
@classmethod
def for_bracket_no_coverage(cls, *, parameter_id: str, base: str) -> Self:
"""A bracket-table parameter has no bracket covering the requested base."""
return cls(
f"parameter {parameter_id!r} has no bracket covering base {base}",
translated_message="errors.calc.bracket_no_coverage",
context={"parameter_id": parameter_id, "base": base},
)
[docs]
@classmethod
def for_bracket_negative_base(cls, *, parameter_id: str, base: str) -> Self:
"""A bracket-table lookup received a negative base value."""
return cls(
f"parameter {parameter_id!r} lookup_bracket received negative base {base}",
translated_message="errors.calc.bracket_negative_base",
context={"parameter_id": parameter_id, "base": base},
)
[docs]
@classmethod
def for_divide_by_zero(cls) -> Self:
"""A formula expression divides by zero at runtime."""
return cls(
"formula expression divides by zero",
translated_message="errors.calc.divide_by_zero",
)
[docs]
@classmethod
def for_empty_expression(cls) -> Self:
"""A formula expression contains no leaf or op (empty)."""
return cls(
"empty formula expression",
translated_message="errors.calc.empty_expression",
)
[docs]
class RegistrySnapshotError(RegistryError):
"""Raised when a filing-grade snapshot cannot be selected.
The single canonical raise scenario is `for_modelo_not_registered`
at the `_authority.modelo` boundary; the bare constructor stays
valid for one-off scenarios not yet promoted to a factory.
Two temporal-selection scenarios carry structured context as
dedicated subclasses (:class:`NoRevisionForPeriodError`,
:class:`AmbiguousRevisionSelectionError`) so a consumer dispatches
by ``except`` type rather than parsing the human-readable message.
Both subclass this type, so every existing ``except
RegistrySnapshotError`` site catches them unchanged.
"""
[docs]
@classmethod
def for_modelo_not_registered(cls, *, modelo_id: str) -> Self:
"""The requested modelo id has no registered revision."""
return cls(
f"modelo {modelo_id!r} is not present in the calculation registry",
translated_message="errors.snapshot.modelo_not_registered",
context={"modelo_id": modelo_id},
)
[docs]
class NoRevisionForPeriodError(RegistrySnapshotError):
"""No registry revision matches the requested temporal natural key.
Raised by :func:`select_revision` when the (modelo, filing year,
period, optional date window, optional revision id) constraints
select zero candidate revisions. Carries the natural-key components
as structured context so a consumer (e.g. the ``config profile
preflight`` resolver) can build an instructive refusal without
parsing the message. Catchable as :class:`RegistrySnapshotError`.
Structured attributes: ``modelo_id``, ``filing_year``,
``period``, ``revision_id``.
"""
def __init__(
self,
*,
modelo_id: str,
filing_year: int,
period: str,
revision_id: str | None,
) -> None:
"""Construct the no-revision-for-period error.
Args:
modelo_id: The modelo whose revisions were searched.
filing_year: The AEAT filing year used to narrow revisions.
period: The period token that found no covering revision.
revision_id: The optional explicit revision-id filter, if any.
"""
self.modelo_id: str = modelo_id
self.filing_year: int = filing_year
self.period: str = period
self.revision_id: str | None = revision_id
super().__init__(
f"modelo {modelo_id}: no revision for year={filing_year!r} period={period!r} revision={revision_id!r}",
translated_message="errors.snapshot.no_revision_for_period",
context={
"modelo_id": modelo_id,
"filing_year": filing_year,
"period": period,
"revision_id": revision_id if revision_id is not None else "",
},
)
[docs]
class AmbiguousRevisionSelectionError(RegistrySnapshotError):
"""More than one registry revision matches the temporal natural key.
Raised by :func:`select_revision` when the constraints select two or
more candidate revisions. Carries the candidate revision ids as a
structured, already-sorted tuple so a consumer can list them in an
operator refusal without re-parsing the message. Catchable as
:class:`RegistrySnapshotError`.
Structured attributes: ``modelo_id``, ``candidate_ids``.
"""
def __init__(self, *, modelo_id: str, candidate_ids: Iterable[str]) -> None:
"""Construct the ambiguous-revision-selection error.
Args:
modelo_id: The modelo whose revisions were searched.
candidate_ids: The matching revision ids; stored sorted as a
tuple on ``candidate_ids``.
"""
ids = tuple(sorted(candidate_ids))
self.modelo_id: str = modelo_id
self.candidate_ids: tuple[str, ...] = ids
super().__init__(
f"modelo {modelo_id}: ambiguous revision selection: {', '.join(ids)}",
translated_message="errors.snapshot.ambiguous_revision_selection",
context={"modelo_id": modelo_id, "candidate_ids": _csv(ids)},
)
[docs]
class CasillaConstraintViolationError(RegistryError):
"""Raised when a computed casilla value falls outside its declared constraints.
The constraint set is ``casilla.constraints`` (sign, min_value, max_value).
The error envelope carries ``casilla_id``, the offending ``value``, the
offended constraint clause, and the casilla's ``legal_refs`` so the
operator sees the BOE permalink that justifies the rule.
"""