Source code for cadrumo.application.filing._export_xml_dictionary

"""XML-dictionary declaration export renderer.

Renders approved declaration drafts through registry ``xml_dictionary`` export
layouts. The renderer reuses the official AEAT dictionary and XSD source refs
resolved by the registry schema provider, then writes only a local XML payload
for the draft export path.

See Also:
    :func:`~application.filing.export.export_draft`
        Draft export service that selects this renderer for XML dictionary
        layouts.
    :class:`~domain.filing.schema.ModeloDraft`
        Approved declaration draft whose casilla and header values are rendered.
    :class:`~domain.calculations.registry.schema_exports.ExportLayoutDefinition`
        Registry export layout carrying dictionary and XSD source references.
    :class:`~domain.calculations.registry.export_parse.XmlDictionaryEntry`
        Parsed dictionary row consumed while projecting XML fields.
    :func:`~domain.calculations.registry.export_parse.xml_dictionary_entries`
        Registry helper that reads the official dictionary source.
    :class:`~application.filing.runtime.RegistrySchemaAccessor`
        Runtime schema provider that resolves source roots and references.
"""

from __future__ import annotations

import re
from collections.abc import Callable, Mapping
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
from types import MappingProxyType
from xml.etree import ElementTree

from defusedxml import ElementTree as DefusedElementTree

from ...core.casilla_id import CasillaId
from ...core.decimal.coercion import coerce_decimal
from ...core.decimal.grammar import try_parse_canonical_decimal
from ...core.external_constants import UTF_8_ENCODING as _UTF_8
from ...core.filing_producer_key import FilingProducerKey
from ...core.modelo import Modelo
from ...core.time.clock import today_madrid
from ...domain.calculations.registry.export_parse import (
    SINO_DICTIONARY_TYPE,
    XML_DICTIONARY_BOOLEAN_TYPES,
    XmlDictionaryEntry,
    xml_dictionary_entries,
)
from ...domain.calculations.registry.facts.resolution import MappingFactQuery, ResolvedMappingFact
from ...domain.calculations.registry.governed_fact_scope import governed_facts_in_scope
from ...domain.calculations.registry.schema_base import DateAxis
from ...domain.calculations.registry.schema_exports import ExportLayoutDefinition
from ...domain.calculations.registry.schema_references import SourceReference
from ...domain.contribuyente.renta_codes import modelo100_ccaa_codigo, modelo100_ecivil_export_code
from ...domain.filing.errors import FilingExportError, FilingExportValidationError
from ...domain.filing.schema import ModeloDraft
from .runtime import RegistrySchemaAccessor

_XML_SCHEMA_INSTANCE_NS = "http://www.w3.org/2001/XMLSchema-instance"
ElementTree.register_namespace("xsi", _XML_SCHEMA_INSTANCE_NS)

_XSD_NS = "{http://www.w3.org/2001/XMLSchema}"

# The declaration's identity block. Mandatory and first in the ``Declaracion``
# sequence of every bundled AEAT Modelo 100 XSD, and absent from every bundled
# dictionary, so it is written from the layout rather than from a dictionary row.
_AUX_TAG = "Aux"

# The per-taxpayer block of a Modelo 100 declaration. Its mandatory ``nif``
# attribute is declared in no dictionary, so it is stamped after the walk rather
# than written from a dictionary row; its ``titular`` sibling IS declared and is
# written by the walk like any other row.
_TOMA_DATOS_TAG = "TomaDatosAmpliada"

# Model groups carry no sibling position of their own when reading declared
# order: a declaration nested inside one sits at the position the group occupies
# in its parent, so the walk descends through them rather than treating them as
# elements.
_XSD_MODEL_GROUP_TAGS = frozenset({"complexType", "complexContent", "sequence", "choice", "all"})

# The two boolean row types (``S_N``'s spelled-out ``SI``/``NO`` and ``LGC``'s
# ``tipo_logico`` pattern ``([0-1]){1}``) and their complete set are the one
# canonical declaration in ``domain.calculations.registry`` (``_export_parse.py``),
# imported above rather than re-declared here. Naming the set positively is what
# makes a boolean on any other row an error by default: a row type added to the
# dictionary later is simply not in it, so it refuses rather than inheriting
# whatever the last branch happened to do.

# The date row type, and the exact form AEAT accepts in it. The pattern is
# copied from the ``tipo_Fecha`` facet the bundled XSD declares -- an
# ``xs:string`` restricted to ``([0-9]{1,2}/[0-9]{1,2}/[0-9]{4})`` -- so day and
# month may be one or two digits and the year must be four. That is exactly what
# rendering a :class:`~datetime.date` below produces, which is why a value that
# arrives already typed never meets this check.
_DATE_DICTIONARY_TYPE = "FEC"
_DATE_DICTIONARY_TEXT = re.compile(r"[0-9]{1,2}/[0-9]{1,2}/[0-9]{4}")

# The dictionary's numeric type codes are self-describing: ``P<width><scale>``
# and ``N<width><scale>`` name the field's integer width and its fractional-digit
# count, so the trailing digit is the scale to render at. Confirmed against every
# code the Modelo 100 dictionary uses across all six revisions by reading the
# ``fractionDigits`` facet of the XSD type each code lands on: ``P010``/``P020``/
# ``P030``/``P040`` resolve to ``xs:integer`` types carrying no fractional digits,
# while ``P012``/``P032``/``P072``/``P102``/``N102`` resolve to ``xs:decimal``
# types declaring ``fractionDigits="2"``. Reading the scale off the code keeps a
# future code correct without this module being edited.
_NUMERIC_DICTIONARY_TYPE = re.compile(r"^[NP]\d{2}(?P<scale>\d)$")


[docs] def render_xml_dictionary_layout( layout: ExportLayoutDefinition, *, draft: ModeloDraft, headers: Mapping[FilingProducerKey, object], dictionary_values: Mapping[str, object] | None = None, schema_provider: RegistrySchemaAccessor, ) -> bytes: """Render an XML-dictionary layout for an approved declaration draft. Args: layout: Registry :class:`~domain.calculations.registry.schema_exports.ExportLayoutDefinition` whose format is ``xml_dictionary``. draft: Approved :class:`~domain.filing.schema.ModeloDraft` supplying casilla values, modelo, and period metadata. headers: Normalized declaration header values such as identity fields. dictionary_values: Values addressed by the dictionary field id AEAT declares for them, each still carrying its own Python type. Absent (or ``None``) means the caller declares no such values, which is how every caller outside the work-unit export service reaches here. schema_provider: :class:`~application.filing.runtime.RegistrySchemaAccessor` that resolves dictionary and XSD source references. Returns: UTF-8 XML declaration bytes for the local export artefact. """ entries = xml_dictionary_entries( layout, sources=schema_provider.sources, source_payloads=schema_provider.source_payloads, ) root = ElementTree.Element( _XML_DICTIONARY_ROOT_TAG, expected_xml_dictionary_root_identity(layout, draft=draft, schema_provider=schema_provider), ) element_order = _xml_dictionary_element_order( _xml_dictionary_xsd_source(layout, schema_provider.sources), source_payloads=schema_provider.source_payloads, ) _append_declaration_aux(root, layout) casilla_values: dict[CasillaId, object] = {value.casilla_id: value.value for value in draft.values} modelo_100_declarations = _registry_modelo_100_xml_declarations() if draft.modelo == Modelo("100") else None unfiled_paths = frozenset[str]() if draft.modelo == Modelo("100"): declarations = modelo_100_declarations if declarations is None: raise FilingExportValidationError("Modelo 100 XML declarations were not resolved") unfiled_paths = _modelo_100_unfiled_comunidad_paths( entries, casilla_values, declarations=declarations, ) for entry in entries: if entry.path in unfiled_paths: continue rendered = _xml_dictionary_rendered_value( entry, draft=draft, casilla_values=casilla_values, headers=headers, dictionary_values=dictionary_values or {}, declarations=modelo_100_declarations, ) if rendered is None or rendered == "": continue _set_xml_dictionary_path(root, entry.path, rendered, element_order=element_order) if draft.modelo == Modelo("100"): _stamp_toma_datos_nif(root, draft) rendered = ElementTree.tostring(root, encoding=_UTF_8, xml_declaration=True) if not isinstance(rendered, bytes): raise FilingExportError(f"the XML dictionary serialiser returned {type(rendered).__name__}, not bytes") return rendered
_XML_DICTIONARY_ROOT_TAG = "Declaracion" _XSD_SCHEMA_LOCATION_ATTRIBUTE = f"{{{_XML_SCHEMA_INSTANCE_NS}}}noNamespaceSchemaLocation"
[docs] def expected_xml_dictionary_root_identity( layout: ExportLayoutDefinition, *, draft: ModeloDraft, schema_provider: RegistrySchemaAccessor, ) -> dict[str, str]: """Return the ``Declaracion`` root attributes ``draft`` identifies itself by. The sole declaration of the root-attribute contract. The writer builds the element from it and :func:`verify_export` rebuilds the same mapping to compare against what a file on disk actually carries, so the two halves cannot name different attributes — which is how the verifier came to check none of them at all. Args: layout: The ``xml_dictionary`` export layout being rendered or verified. draft: The approved :class:`ModeloDraft` supplying modelo and period identity. schema_provider: Resolves the layout's XSD source and its version. Returns: The root attributes, keyed exactly as they appear on the element. """ xsd_source = _xml_dictionary_xsd_source(layout, schema_provider.sources) version = _latest_xml_dictionary_xsd_version(xsd_source, source_payloads=schema_provider.source_payloads) return { "modelo": draft.modelo, "ejercicio": str(draft.period.filing_year), "periodo": draft.period.registry_token, "versionxsd": version, _XSD_SCHEMA_LOCATION_ATTRIBUTE: xsd_source.source_url, }
[docs] def read_xml_dictionary_root_identity(payload: bytes) -> Mapping[str, str]: """Return the root identity attributes carried by an exported XML declaration. Args: payload: The exported XML declaration bytes. Returns: The declared root attributes, keyed as :func:`expected_xml_dictionary_root_identity` writes them. A key is absent when the file omits that attribute, which the caller must treat as a divergence rather than as a match. Raises: FilingExportValidationError: The payload is not parseable XML, or its root is not a ``Declaracion`` element. """ try: root = DefusedElementTree.fromstring(payload) except DefusedElementTree.ParseError as exc: raise FilingExportValidationError("XML dictionary payload could not be parsed") from exc if root.tag != _XML_DICTIONARY_ROOT_TAG: raise FilingExportValidationError( f"XML dictionary payload root is {root.tag!r}, expected {_XML_DICTIONARY_ROOT_TAG!r}", ) return dict(root.attrib)
def _xml_dictionary_xsd_source( layout: ExportLayoutDefinition, sources: Mapping[str, SourceReference], ) -> SourceReference: refs = set(layout.source_refs) for source in sources.values(): if source.id in refs and source.kind == "xsd": return source raise FilingExportError(f"XML dictionary export layout {layout.id!r} has no resolved XSD source") def _xml_dictionary_xsd_root( source: SourceReference, *, source_payloads: Mapping[str, bytes] ) -> ElementTree.Element[str]: """Return the parsed root from the signed XSD projection ``source`` names.""" try: root = DefusedElementTree.fromstring(source_payloads[str(source.id)]) except (DefusedElementTree.ParseError, KeyError) as exc: raise FilingExportValidationError(f"XML dictionary XSD source {source.id!r} could not be parsed") from exc return root def _latest_xml_dictionary_xsd_version(source: SourceReference, *, source_payloads: Mapping[str, bytes]) -> str: root = _xml_dictionary_xsd_root(source, source_payloads=source_payloads) versions: list[str] = [] for simple_type in root.iter(f"{_XSD_NS}simpleType"): if simple_type.attrib.get("name") != "tipo_VersionXSD": continue for enumeration in simple_type.iter(f"{_XSD_NS}enumeration"): value = enumeration.attrib.get("value") if value: versions.append(value) if not versions: raise FilingExportValidationError(f"XML dictionary XSD source {source.id!r} declares no versionxsd values") return sorted(versions, key=lambda item: tuple(int(part) for part in item.split(".")))[-1] def _append_declaration_aux(root: ElementTree.Element[str], layout: ExportLayoutDefinition) -> None: """Write the declaration's ``Aux`` identity block from the layout's declaration. The block is mandatory and first in every AEAT Modelo 100 XSD, and no bundled dictionary describes a single one of its rows, so it cannot be reached by the dictionary-driven walk that writes everything else here. Both children are ``minOccurs="1"``, so a block missing either is invalid and is not worth writing: when ``aux_version`` is undeclared this writes nothing, and :func:`~application.filing._export_parity.assert_xml_declaration_aux_declared` refuses the export at the write door rather than letting a partial block reach disk. """ if layout.aux_idioma is None or layout.aux_version is None: return aux = ElementTree.SubElement(root, _AUX_TAG) ElementTree.SubElement(aux, "Idioma").text = layout.aux_idioma.value ElementTree.SubElement(aux, "VERSION").text = layout.aux_version def _stamp_toma_datos_nif(root: ElementTree.Element[str], draft: ModeloDraft) -> None: """Name whose NIF each ``TomaDatosAmpliada`` block reports figures for. ``nif`` is ``use="required"`` on ``tipo_TomaDatosAmpliada`` in all six bundled exercises, and AEAT's dictionary declares no field id whose path reaches it -- so it can be written by neither the dictionary-driven walk nor a ``dictionary_path_overrides`` correction, which only re-points a row the dictionary already carries. Same shape as the ``Aux`` block, and written the same way: from what the export already holds. Its sibling attribute ``titular`` is deliberately NOT written here. That one IS dictionary-declared -- thirty-five field ids (``TITA`` on casilla 0001, ``TITBIH`` on 0026, and so on, all of them registry casillas) map to ``/DatosEconomicos/TomaDatosAmpliada/@titular``, so the ordinary walk writes it from whichever titular casilla the return populates. Stamping a constant here would overwrite that: a declaración conjunta reports a spouse's income section under código 3, and a hardcoded declarante 2 would silently re-attribute it on a filed return. A blank ``titular`` means no titular casilla carries a value, which is an input gap the completeness gate owns. Runs AFTER the walk rather than before it, because unlike ``Aux`` the element being stamped is one the walk creates. The NIF is the draft's, deliberately, and is the same value ``DPNIF_D`` carries. Re-reading the profile here would let the block's identity attribute disagree with the identity row above it in the same file when a profile is edited between approval and export. Taking both from the approved artefact makes that disagreement unrepresentable. """ for block in root.iter(_TOMA_DATOS_TAG): block.set("nif", draft.profile_tax_id) def _xsd_declared_children(node: ElementTree.Element[str]) -> list[ElementTree.Element[str]]: """Return ``node``'s child element declarations in the order the schema writes them. Descends through the model-group wrappers (``sequence``, ``choice``, ``all``) that carry no ordering meaning of their own here, so a declaration nested one or more groups deep is still found at its true sibling position. Descent stops at the first ``element`` on each branch: that element's own children belong to its path, not to ``node``'s. """ found: list[ElementTree.Element[str]] = [] pending = list(node) while pending: current = pending.pop(0) # A comment or processing instruction carries a callable tag rather than # a name; rendering it to text simply matches nothing below. local_name = str(current.tag).rpartition("}")[2] if local_name == "element" and current.get("name"): found.append(current) elif local_name in _XSD_MODEL_GROUP_TAGS: pending = list(current) + pending return found def _xml_dictionary_element_order( source: SourceReference, *, source_payloads: Mapping[str, bytes], ) -> dict[str, tuple[str, ...]]: """Return each element path's declared child order, read from the official XSD. AEAT declares the declaration body as ``xs:sequence``, so sibling order is part of the schema rather than a presentation choice. The dictionary's row order is NOT that order -- it lists ``CalculoImpuestoRes`` first under ``Resultados`` where the schema puts it later, and some sibling fields before others under ``Declarante`` where the schema places them later. Appending each element as its dictionary row is reached therefore emits a document AEAT's own schema rejects, so the writer reads the order from the schema instead. The map is keyed by absolute path rather than by element name because a name is not unique across the document: the same tag recurs under different parents carrying different types, and a name-keyed map would silently apply one parent's order to another's children. Args: source: The layout's resolved XSD :class:`SourceReference`. source_payloads: Published authority bytes keyed by source reference. Returns: Absolute element path (``""`` for the root's own children) mapped to the child element names the schema declares under it, in declared order. A path the schema does not describe is absent, and the writer falls back to first-encounter order for it. Raises: FilingExportValidationError: The published XSD projection could not be parsed, or declares no root ``Declaracion`` element to walk from. """ root = _xml_dictionary_xsd_root(source, source_payloads=source_payloads) named_types = {name: node for node in root.iter(f"{_XSD_NS}complexType") if (name := node.get("name")) is not None} order: dict[str, tuple[str, ...]] = {} declaration = next( (node for node in root if node.tag == f"{_XSD_NS}element" and node.get("name") == _XML_DICTIONARY_ROOT_TAG), None, ) if declaration is None: raise FilingExportValidationError( f"XML dictionary XSD source {source.id!r} declares no {_XML_DICTIONARY_ROOT_TAG!r} root element", ) _record_xsd_child_order( "", declaration.find(f"{_XSD_NS}complexType"), named_types=named_types, order=order, seen=frozenset(), ) return order def _record_xsd_child_order( path: str, type_node: ElementTree.Element[str] | None, *, named_types: Mapping[str, ElementTree.Element[str]], order: dict[str, tuple[str, ...]], seen: frozenset[str], ) -> None: """Record ``path``'s declared child order into ``order``, then descend into each child's type. ``seen`` carries the named types already open on this branch, so a type that reaches itself terminates instead of recursing without end. """ if type_node is None or path in order: return children = _xsd_declared_children(type_node) if not children: return order[path] = tuple(str(child.get("name")) for child in children) for child in children: child_path = f"{path}/{child.get('name')}" declared_type = child.get("type") if declared_type is None or declared_type not in named_types: _record_xsd_child_order( child_path, child.find(f"{_XSD_NS}complexType"), named_types=named_types, order=order, seen=seen, ) elif declared_type not in seen: # A type that reaches itself would recurse without end; its # order is already recorded at the shallower path. _record_xsd_child_order( child_path, named_types[declared_type], named_types=named_types, order=order, seen=seen | {declared_type}, ) def _registry_modelo_100_xml_declarations() -> Mapping[str, str]: """Resolve the selected XML export declarations without a Python copy.""" authority = governed_facts_in_scope() if authority is None: raise FilingExportValidationError( "Modelo 100 XML export declarations require a generation-pinned authority operation" ) resolved = authority.resolve_governed_fact( MappingFactQuery( fact_id="modelo-100-xml-export-declarations-mapping", date_axis=DateAxis.FILING_PERIOD, effective_date=today_madrid(), ), ) if not isinstance(resolved, ResolvedMappingFact): raise FilingExportValidationError("selected Modelo 100 XML declarations must be a mapping fact") return {str(entry.key): str(entry.value) for entry in resolved.payload.entries} #: The value converters a registry XML declaration may name, by that name. An #: explicit table rather than a module-namespace lookup, so an import tidy-up #: cannot silently make a declared converter unavailable. _XML_VALUE_CONVERTERS: Mapping[str, Callable[[str], str]] = MappingProxyType( { "modelo100_ccaa_codigo": modelo100_ccaa_codigo, "modelo100_ecivil_export_code": modelo100_ecivil_export_code, }, ) def _xml_dictionary_rendered_value( entry: XmlDictionaryEntry, *, draft: ModeloDraft, casilla_values: dict[CasillaId, object], headers: Mapping[FilingProducerKey, object], dictionary_values: Mapping[str, object], declarations: Mapping[str, str] | None, ) -> str | None: raw = casilla_values.get(entry.casilla_id) if entry.casilla_id is not None else None if raw is None: raw = _xml_dictionary_non_casilla_value(entry, headers=headers, dictionary_values=dictionary_values) if raw is None: return None if draft.modelo == Modelo("100"): if declarations is None: raise FilingExportValidationError("Modelo 100 XML declarations were not resolved") raw = _modelo_100_sign_branch_value(entry, raw, declarations=declarations) rendered = format_xml_dictionary_value(entry.data_type, raw) converter_name = declarations.get(f"xml.converter.{entry.field_id}") if declarations is not None else None if converter_name is not None: converter = _XML_VALUE_CONVERTERS.get(converter_name) if converter is None: raise FilingExportValidationError(f"registry-selected XML converter {converter_name!r} is not available") try: converted = converter(rendered) if not isinstance(converted, str): raise FilingExportValidationError( f"registry-selected XML converter {converter_name!r} returned a non-string value" ) return converted except ValueError as exc: raise FilingExportValidationError(str(exc)) from exc return rendered def _modelo_100_comunidad_block(path: str, *, declarations: Mapping[str, str]) -> str | None: """Return the comunidad block ``path`` sits in, or ``None`` when it is elsewhere.""" block_prefix = declarations.get("xml.comunidad.block_prefix") if block_prefix is None: raise FilingExportValidationError("registry-selected comunidad block prefix is missing") if not path.startswith(block_prefix): return None return path[len(block_prefix) :].split("/", 1)[0] def _modelo_100_casilla_is_populated(value: object) -> bool: """Return whether a CCAA casilla value selects a comunidad block. Calculated drafts carry zero-valued placeholders for every CCAA aggregate, sometimes as strings. Those placeholders are absent for the XSD ``choice`` and must not select a block. A nonempty value that cannot be parsed remains populated so the later row formatter raises its typed validation error instead of silently suppressing the invalid input as if it were absent. """ if value is None or value == "": return False amount = coerce_decimal(value) return amount is None or not amount.is_zero() def _modelo_100_unfiled_comunidad_paths( entries: tuple[XmlDictionaryEntry, ...], casilla_values: Mapping[CasillaId, object], *, declarations: Mapping[str, str], ) -> frozenset[str]: """Return the autonomic-deduction paths this draft must not write. Args: entries: Every dictionary row for the layout being rendered. casilla_values: Casilla values the draft carries. declarations: Generation-pinned XML routing declarations. Returns: Paths belonging to a comunidad the draft does not file, plus the shared total when no comunidad is filed at all. Raises: FilingExportValidationError: when the draft populates casillas belonging to more than one comunidad. The schema admits only one, so there is no correct rendering and picking one would launder the conflict. """ own_casillas_by_block = _modelo_100_comunidad_casillas(entries, declarations=declarations) filed = _modelo_100_filed_comunidades(own_casillas_by_block, casilla_values) resident = _modelo_100_resident_comunidad(filed) unfiled = _modelo_100_unfiled_block_paths(entries, resident, declarations=declarations) if resident is None: unfiled.update(_modelo_100_shared_total_paths(entries, declarations=declarations)) return frozenset(unfiled) def _modelo_100_comunidad_casillas( entries: tuple[XmlDictionaryEntry, ...], *, declarations: Mapping[str, str], ) -> dict[str, set[CasillaId]]: """Collect each comunidad block's own casillas, excluding the shared total.""" own_casillas_by_block: dict[str, set[CasillaId]] = {} for entry in entries: block = _modelo_100_comunidad_block(entry.path, declarations=declarations) if block is None or entry.casilla_id is None: continue shared_total = declarations.get("xml.comunidad.shared_total_casilla") if shared_total is None: raise FilingExportValidationError("registry-selected comunidad total casilla is missing") if str(entry.casilla_id) != shared_total: own_casillas_by_block.setdefault(block, set()).add(entry.casilla_id) return own_casillas_by_block def _modelo_100_filed_comunidades( own_casillas_by_block: Mapping[str, set[CasillaId]], casilla_values: Mapping[CasillaId, object], ) -> list[str]: """Return comunidad blocks with at least one populated own casilla.""" return sorted( block for block, own in own_casillas_by_block.items() if any(_modelo_100_casilla_is_populated(casilla_values.get(casilla)) for casilla in own) ) def _modelo_100_resident_comunidad(filed: list[str]) -> str | None: """Require at most one filed comunidad and return the selected block.""" if len(filed) > 1: raise FilingExportValidationError( "draft populates autonomic deductions for more than one comunidad " f"({', '.join(filed)}); a declaration may carry only one", ) return filed[0] if filed else None def _modelo_100_unfiled_block_paths( entries: tuple[XmlDictionaryEntry, ...], resident: str | None, *, declarations: Mapping[str, str], ) -> set[str]: """Return all dictionary paths belonging to non-resident comunidad blocks.""" return { entry.path for entry in entries if (block := _modelo_100_comunidad_block(entry.path, declarations=declarations)) is not None and block != resident } def _modelo_100_shared_total_paths( entries: tuple[XmlDictionaryEntry, ...], *, declarations: Mapping[str, str], ) -> frozenset[str]: """Return shared total paths when no comunidad block is filed.""" shared_total = declarations.get("xml.comunidad.shared_total_casilla") if shared_total is None: raise FilingExportValidationError("registry-selected comunidad total casilla is missing") return frozenset(entry.path for entry in entries if str(entry.casilla_id) == shared_total) def _modelo_100_sign_branch_value( entry: XmlDictionaryEntry, raw: object, *, declarations: Mapping[str, str], ) -> object: """Return ``raw`` for the sign branch it belongs to, and zero for the other. Args: entry: Dictionary row being rendered. raw: Value resolved for the row's casilla. declarations: Generation-pinned XML sign-branch declarations. Returns: ``raw`` when the row's branch matches its sign, ``Decimal("0")`` when the row is the opposite branch, and ``raw`` unchanged for every other row. A value that will not coerce carries no sign to route on, so it is read as zero for the purpose of choosing a branch. This selects a branch rather than validating a value: deciding what an uncoercible amount means is :func:`format_xml_dictionary_value`'s job, and it is the job it does for every other casilla. Without the default, ``coerce_decimal`` answers ``None`` and the comparison below raises ``TypeError`` on the export path. """ negative_field = declarations.get("xml.sign.negative_field") non_negative_field = declarations.get("xml.sign.non_negative_field") if negative_field is None or non_negative_field is None: raise FilingExportValidationError("registry-selected XML sign declarations are incomplete") negative_branch = entry.field_id == negative_field if not negative_branch and entry.field_id != non_negative_field: return raw amount = coerce_decimal(raw, default=Decimal("0")) return raw if (amount < 0) is negative_branch else Decimal("0") def _xml_dictionary_non_casilla_value( entry: XmlDictionaryEntry, *, headers: Mapping[FilingProducerKey, object], dictionary_values: Mapping[str, object], ) -> object | None: """Resolve a row no casilla addresses, keeping the value's Python type. Two channels answer here, and the order between them is what makes the declared one authoritative. ``dictionary_values`` is keyed by the field id AEAT's own dictionary names, which is the address a registry binding declares, and each value arrives as the type its fact carries -- a ``bool`` is still a ``bool``, a :class:`~datetime.date` still a ``date``, so :func:`format_xml_dictionary_value` can still decide ``SI``/``NO`` and ``d/m/yyyy`` from it. ``headers`` is the flat declaration-header mapping, whose contract is ``str`` throughout: a value reaching here through it has already been rendered by its composer and is passed on as written. Consulting the declared channel first means a header key that happens to collide with a dictionary field id cannot shadow a typed value with a pre-rendered string. No such collision exists today -- measured across all 2,383 rows of the Modelo 100 2024 dictionary against every key the export header composer emits, the intersection is empty -- so the order states what happens when one appears rather than changing what happens now. Args: entry: The dictionary row being written. headers: Declaration headers, lowercased keys, string values. dictionary_values: Values addressed by dictionary field id. Returns: The value to render, or ``None`` when neither channel addresses the row. """ del headers return dictionary_values.get(entry.field_id)
[docs] def format_xml_dictionary_value(data_type: str, value: object) -> str: """Render one value in the form the dictionary row's declared type accepts. The row's ``data_type`` decides the rendering, not the Python type of the value. Deciding from the value alone cannot distinguish rows that happen to carry the same Python type but are declared differently by AEAT: every boolean rendered as ``"S"``/``"N"`` regardless of whether the row was ``tipo_logico`` (which accepts only ``0``/``1``) or ``tipo_SINO_Exclusivo`` (only ``SI``/``NO``), and every numeric row was rendered with two decimals even where the row is an ``xs:integer``. Args: data_type: The dictionary row's declared type code. value: The scalar to render. Returns: The rendered text for the XML element or attribute. """ normalized_type = data_type.upper() if isinstance(value, bool): return _format_xml_dictionary_boolean_value(data_type, normalized_type, value) if isinstance(value, date): return f"{value.day}/{value.month}/{value.year}" numeric = _NUMERIC_DICTIONARY_TYPE.match(normalized_type) if numeric is not None: return _format_xml_dictionary_numeric_value(data_type, value, numeric) return _format_xml_dictionary_text_value(data_type, normalized_type, value)
def _format_xml_dictionary_boolean_value(data_type: str, normalized_type: str, value: bool) -> str: """Render a boolean only for the two dictionary types AEAT declares boolean.""" # A boolean renders on exactly the two rows AEAT declares boolean, and is a # type error anywhere else. Stated this way round on purpose: asking what the # row IS keeps the declared type in charge, which is the rule this whole # function exists to enforce, while asking what it is NOT inverts the # dependency and leaves every unlisted row inheriting the last branch by # accident. # # The damage that makes this worth refusing rather than rendering is clearest # on an amount row: ``True`` on a euro-cent row reads as one euro, the XSD # accepts it, nothing on the export path validates anyway, and a taxpayer # files a figure they never stated. Same decision the unreadable-amount # branch below makes, for the same reason -- a wrong number that satisfies # every downstream check is worse than a refusal, because the refusal is the # only place the error is still visible. # # No route delivers a boolean here today: the casilla input door refuses one # for every declared family, and no Modelo 100 revision declares a boolean # casilla on a non-boolean row. This guards a route added later, so it stays # total and cheap rather than trying to interpret the value. if normalized_type not in XML_DICTIONARY_BOOLEAN_TYPES: raise FilingExportValidationError( f"a {data_type} row cannot carry the boolean {value!r}. Only " f"{'/'.join(sorted(XML_DICTIONARY_BOOLEAN_TYPES))} rows are declared boolean by AEAT. " "The value reaching this row has the wrong type; correct it at the source rather " "than rendering it, because a boolean written to an amount row is filed as 1 or 0.", ) if normalized_type == SINO_DICTIONARY_TYPE: return "SI" if value else "NO" return "1" if value else "0" def _format_xml_dictionary_numeric_value(data_type: str, value: object, numeric: re.Match[str]) -> str: """Parse and quantize one dictionary numeric value at its declared scale.""" scale = int(numeric["scale"]) # A text amount is read through the canonical grammar, which refuses on two # independent grounds and they are easy to confuse. # # The CAP is precision: at most ``scale`` fractional digits, never below two. # Capping an integer row at its own scale of zero would refuse ``1.6``, # unambiguous input this renderer has always rounded. # # The AMBIGUITY guard is separate and the row's scale does not relax it. # ``european_thousands_reading_is_ambiguous`` refuses a token that could # equally be read as a Spanish thousands group -- a lead of one to three # digits with no leading zero, then exactly three fractional digits. The # scale disambiguates the FIELD, never the STRING: an operator writing one # thousand types the same characters whatever the row declares, so ``1.000`` # refuses on a three-decimal row exactly as it does on a euro-cent one. Tokens # carrying their own evidence still parse at any scale -- ``0.239`` (a lead # of zero was never grouped) and ``1234.239`` (a four-digit lead would itself # have been grouped). # # A value that already arrives typed carries no such ambiguity and skips the # text grammar entirely. amount = ( try_parse_canonical_decimal(value, max_fraction_digits=max(scale, 2)) if isinstance(value, str) else coerce_decimal(value) ) if amount is None: raise FilingExportValidationError( f"amount for a {data_type} row could not be read: {value!r}. " f"The accepted form is a dot decimal separator with at most {scale} " "fractional digit(s) and no thousands grouping, e.g. 1234.56. A value " "is also refused when its shape cannot be told from a Spanish thousands " "group -- a lead of one to three digits with no leading zero, then " "exactly three more, as in 1.000 or 100.000 -- because that text reads " "as both one and one thousand and no parser can choose. This row's " "declared scale does not settle it: the scale says what the field can " "hold, not which reading was meant. Write the amount unambiguously " "(1000 or 1.0) at the source.", ) return f"{amount.quantize(Decimal(1).scaleb(-scale), rounding=ROUND_HALF_UP)}" def _format_xml_dictionary_text_value(data_type: str, normalized_type: str, value: object) -> str: """Pass through text values, validating the XML dictionary date shape.""" text = str(value).strip() if normalized_type == _DATE_DICTIONARY_TYPE and not _DATE_DICTIONARY_TEXT.fullmatch(text): # A date row reached by text rather than by a ``date``. The typed value # is rendered above and never arrives here, so this is the case where # something upstream held a date as a string -- and an ISO one renders # verbatim, which AEAT's own ``tipo_Fecha`` pattern rejects. # # Checked rather than parsed, deliberately. Reading ``03/04/2024`` would # mean choosing between day-month and month-day, and this renderer has no # basis for that choice; the numeric branch above refuses an ambiguous # amount for the same reason. Text already in AEAT's form passes through # untouched, so the check costs a correct caller nothing. raise FilingExportValidationError( f"date for a {data_type} row is not in the form AEAT accepts: {value!r}. " "The accepted form is d/m/yyyy with a four-digit year, e.g. 2/1/1980. " "Supply the value as a date rather than as text and it is rendered " "correctly without this check.", ) return text def _set_xml_dictionary_path( root: ElementTree.Element[str], absolute_path: str, value: str, *, element_order: dict[str, tuple[str, ...]], ) -> None: """Write ``value`` at ``absolute_path``, creating each element in schema order. A newly created element is placed at the position ``element_order`` declares for it rather than appended, because the schema's ``xs:sequence`` makes sibling order significant and the dictionary rows that drive this walk do not arrive in that order. Placement is the only thing the order decides: an element's tag, text, and attributes are unaffected. """ parts = _xml_dictionary_path_parts(absolute_path) if not parts: raise FilingExportValidationError("XML dictionary entry path must not be empty") current = root current_path = "" for index, part in enumerate(parts): if index == 0 and part == root.tag: continue if _set_xml_dictionary_attribute(current, part, index=index, final_index=len(parts) - 1, value=value): return current = _get_or_create_xml_dictionary_child( current, part, current_path=current_path, element_order=element_order, ) current_path = f"{current_path}/{part}" current.text = value def _xml_dictionary_path_parts(absolute_path: str) -> tuple[str, ...]: """Split an XML dictionary path into its non-empty components.""" return tuple(part for part in absolute_path.strip("/").split("/") if part) def _set_xml_dictionary_attribute( current: ElementTree.Element[str], part: str, *, index: int, final_index: int, value: str, ) -> bool: """Set a terminating attribute component and report whether it was one.""" if not part.startswith("@"): return False if index != final_index: raise FilingExportValidationError("XML dictionary attribute must terminate its path") current.set(part[1:], value) return True def _get_or_create_xml_dictionary_child( current: ElementTree.Element[str], part: str, *, current_path: str, element_order: dict[str, tuple[str, ...]], ) -> ElementTree.Element[str]: """Find a path child or create it at its XSD-declared sibling position.""" child = next((candidate for candidate in current if candidate.tag == part), None) if child is None: child = ElementTree.Element(part) current.insert(_declared_child_position(current, part, element_order.get(current_path)), child) return child def _declared_child_position( parent: ElementTree.Element[str], tag: str, declared: tuple[str, ...] | None, ) -> int: """Return the index ``tag`` belongs at among ``parent``'s existing children. Falls back to appending when the schema describes neither the parent nor the new tag, which keeps a path the XSD does not cover rendering exactly as it did before rather than dropping it or guessing a position for it. """ if declared is None: return len(parent) ranks = {name: position for position, name in enumerate(declared)} rank = ranks.get(tag) if rank is None: return len(parent) for position, existing in enumerate(parent): existing_rank = ranks.get(str(existing.tag)) if existing_rank is not None and existing_rank > rank: return position return len(parent) __all__ = ["render_xml_dictionary_layout"]