Source code for cadrumo.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.errors.hierarchy import pydantic_validation_boundary
from ...core.external_constants import load_external_constants
from ...core.i18n.translatable import Translatable as tr
from ...core.models import STRICT_FROZEN_CONFIG
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:`cadrumo.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 @pydantic_validation_boundary 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 @pydantic_validation_boundary 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 @pydantic_validation_boundary 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") @pydantic_validation_boundary def _validate_invariants(self) -> PortalMetadata: """Enforce cross-field invariants on a single entry.""" _validate_auth_method_exclusivity(self) _validate_portal_host(self) _validate_active_portal_path(self) _validate_portal_lifecycle(self) return self
def _validate_auth_method_exclusivity(metadata: PortalMetadata) -> None: if AuthMethod.ANONYMOUS in metadata.auth_methods and len(metadata.auth_methods) != 1: raise PortalValidationError("AuthMethod.ANONYMOUS must be the sole method when present") def _validate_portal_host(metadata: PortalMetadata) -> None: host = metadata.url.host expected_host = portal_host_name(metadata.subdomain) if host != expected_host: raise PortalValidationError(f"url host {host!r} does not match subdomain {expected_host!r}") def _validate_active_portal_path(metadata: PortalMetadata) -> None: if not metadata.active or metadata.category not in {PortalCategory.FILING, PortalCategory.CENSO}: return path = metadata.url.path or "" if not _filing_censo_path_re().match(path): raise PortalValidationError( f"active {metadata.category.value} portal url path must match " f"{_filing_censo_path_description()}, got {path!r}", ) def _validate_portal_lifecycle(metadata: PortalMetadata) -> None: if not metadata.active and metadata.replaced_by is None and not metadata.notes: raise PortalValidationError("retired portal without replaced_by must carry a non-empty notes rationale") if metadata.replaced_by is not None and metadata.replaced_by == metadata.portal: raise PortalValidationError(f"portal {metadata.portal!r} cannot be replaced by itself") if metadata.active and metadata.replaced_by is not None: raise PortalValidationError("replaced_by must be None when active is True") __all__ = ["PortalMetadata"]