Source code for aeat.domain.portals._metadata

"""Define :class:`PortalMetadata` for one :class:`Portal` catalogue entry.

The model binds each URL to its :class:`PortalHost`,
:class:`PortalCategory`, accepted :class:`AuthMethod` values, and
:class:`UrlStability` tier before registry assembly.
"""

from __future__ import annotations

import re
from functools import lru_cache

from pydantic import BaseModel, Field, HttpUrl, field_validator, model_validator

from ...core import STRICT_FROZEN_CONFIG
from ...core.external_constants import load_external_constants
from ...core.i18n import Translatable as tr
from ._categories import AuthMethod, PortalCategory, PortalHost, UrlStability
from ._codes import Portal
from ._errors import PortalValidationError
from ._hosts import portal_host_name


@lru_cache(maxsize=1)
def _filing_censo_path_re() -> re.Pattern[str]:
    """Return the centralized AEAT filing/censo portal path pattern."""
    return re.compile(load_external_constants().aeat.portal_paths.filing_censo_path_regex)


def _filing_censo_path_description() -> str:
    """Return the centralized human-readable filing/censo portal path shape."""
    return load_external_constants().aeat.portal_paths.filing_censo_path_description


[docs] class PortalMetadata(BaseModel): """Curated metadata for a single AEAT portal. One instance per :class:`Portal` member in the registry. The model is strict, frozen, and rejects unknown keys. Structural cross-reference invariants that span the registry as a whole are enforced at registry-assembly time by :func:`aeat.domain.portals._registry._finalise_registry` rather than here, so individual entries can be constructed in isolation. Attributes: portal: The :class:`Portal` this entry describes. url: Canonical HTTPS URL. subdomain: The :class:`PortalHost` the URL is hosted on. Must match ``url.host``. category: :class:`PortalCategory` the portal belongs to. auth_methods: Non-empty frozenset of accepted :class:`AuthMethod` values. ``AuthMethod.ANONYMOUS`` is mutually exclusive with every other method. url_stability: :class:`UrlStability` tier for read-only portal monitoring priority. label: Translation key for the display label. purpose: Translation key for the purpose description. active: ``False`` marks retired portals preserved for historical lookup. replaced_by: When ``active is False``, optionally points to the :class:`Portal` member that supersedes this one. When ``None`` and ``active is False``, ``notes`` must carry a non-empty discontinuation rationale. notes: Tuple of translation keys for notes. """ model_config = STRICT_FROZEN_CONFIG portal: Portal url: HttpUrl subdomain: PortalHost category: PortalCategory auth_methods: frozenset[AuthMethod] = Field(min_length=1) url_stability: UrlStability label: tr purpose: tr active: bool = True replaced_by: Portal | None = None notes: tuple[tr, ...] = () @field_validator("purpose") @classmethod def _purpose_not_blank(cls, value: tr) -> tr: """Reject whitespace-only purpose keys.""" if not value.strip(): raise PortalValidationError("purpose must not be empty or whitespace-only") return value @field_validator("label") @classmethod def _label_not_blank(cls, value: tr) -> tr: """Reject whitespace-only label keys.""" if not value.strip(): raise PortalValidationError("label must not be empty or whitespace-only") return value @field_validator("url") @classmethod def _url_is_https(cls, value: HttpUrl) -> HttpUrl: """Reject non-HTTPS URLs.""" if value.scheme != "https": raise PortalValidationError(f"url scheme must be https, got {value.scheme!r}") return value @model_validator(mode="after") def _validate_invariants(self) -> PortalMetadata: """Enforce cross-field invariants on a single entry.""" # ANONYMOUS exclusivity. if AuthMethod.ANONYMOUS in self.auth_methods and len(self.auth_methods) != 1: raise PortalValidationError("AuthMethod.ANONYMOUS must be the sole method when present") # PortalHost must match URL host. host = self.url.host expected_host = portal_host_name(self.subdomain) if host != expected_host: raise PortalValidationError(f"url host {host!r} does not match subdomain {expected_host!r}") # G-code path check for active FILING / CENSO entries. if self.active and self.category in {PortalCategory.FILING, PortalCategory.CENSO}: path = self.url.path or "" if not _filing_censo_path_re().match(path): raise PortalValidationError( f"active {self.category.value} portal url path must match " f"{_filing_censo_path_description()}, got {path!r}", ) # Retired-without-replacement fallback. if not self.active and self.replaced_by is None and not self.notes: raise PortalValidationError("retired portal without replaced_by must carry a non-empty notes rationale") # Self-replacement check. if self.replaced_by is not None and self.replaced_by == self.portal: raise PortalValidationError(f"portal {self.portal!r} cannot be replaced by itself") # Replaced-by may only be set when active is False. if self.active and self.replaced_by is not None: raise PortalValidationError("replaced_by must be None when active is True") return self
__all__ = ["PortalMetadata"]