Source code for cadrumo.application.flows.copy

"""The render-time copy assembler: references in, display copy out.

Every copy slot on a flow definition is a :class:`CopyRef`, never a
literal string; this module resolves those references against the
bundled sources at render time and composes the per-page copy bundle
both frontends consume. Locale keys resolve through the canonical
:func:`~cadrumo.core.i18n.render.tr` catalogue lookup built in; the
``SCHEMA_FIELD`` and ``TERMINOLOGY_CONCEPT`` kinds resolve through
resolvers a domain flow registers at import time, keeping the substrate
blind to which schema or concept corpus backs them.

Resolution is loud: an unresolvable reference raises
:class:`FlowCopyResolutionError` naming the reference kind and id — a
page never renders a silent blank or a raw key leak.
"""

from __future__ import annotations

from collections.abc import Callable, Mapping
from typing import Final

from pydantic import BaseModel

from ...core.flows import CopyRefKind
from ...core.i18n.render import tr
from ...core.models import STRICT_FROZEN_CONFIG
from .definition import CopyRef, FlowDefinition, FlowPage
from .errors import FlowCopyResolutionError

type CopySourceResolver = Callable[[str], "str | None"]
"""Resolver for one :class:`CopyRefKind`: reference id in, display text or None."""

_UNRESOLVED_SENTINEL = "\x00cadrumo-flows-copy-unresolved\x00"

_SOURCE_RESOLVERS: dict[CopyRefKind, list[CopySourceResolver]] = {}


PAGE_REQUIREMENT_LOCALE_KEYS: Final[Mapping[bool, str]] = {
    True: "flows.progress.required",
    False: "flows.progress.optional",
}
"""The required/optional badge keys, keyed by whether the page is required.

Both frontends pick one of these behind a variable and hand it to ``tr()``, so
no literal reaches the call site and the static scanner cannot see either key
there. A catalogue strip would then prune both as orphans and the badge would
raise at render time on a key nobody could find a reference to.

The scanner does read module-level constants whose name carries the
``_LOCALE_KEYS`` suffix, so centralising the pair here is what keeps them
visible. That makes the name load-bearing rather than descriptive: renaming this
constant to something outside the convention silently removes both keys from the
scan.
"""


[docs] def register_copy_source(kind: CopyRefKind, resolver: CopySourceResolver) -> None: """Register a resolver for a non-locale copy source kind. ``LOCALE_KEY`` is built in and may not be overridden. A kind may carry several resolvers — distinct domains legitimately serve disjoint reference namespaces of the same kind (profile schema fields and modelo casilla labels are both ``SCHEMA_FIELD``) — and resolution tries them in registration order, first non-``None`` winning. Registering the same resolver object twice is refused so an accidental double import surfaces loudly. """ if kind is CopyRefKind.LOCALE_KEY: raise FlowCopyResolutionError( translated_message="application.flows.errors.copy_source_locale_builtin", ) resolvers = _SOURCE_RESOLVERS.setdefault(kind, []) if any(existing is resolver for existing in resolvers): raise FlowCopyResolutionError( translated_message="application.flows.errors.copy_source_duplicate", context={"kind": kind.value}, ) resolvers.append(resolver)
[docs] def resolve_copy(ref: CopyRef) -> str: """Resolve one reference to display copy, or refuse loudly.""" if ref.kind is CopyRefKind.LOCALE_KEY: rendered = tr( ref.ref, default=_UNRESOLVED_SENTINEL, ) if rendered == _UNRESOLVED_SENTINEL: raise FlowCopyResolutionError( translated_message="application.flows.errors.copy_locale_key_unresolved", context={"ref": ref.ref}, ) return rendered resolvers = _SOURCE_RESOLVERS.get(ref.kind) if not resolvers: raise FlowCopyResolutionError( translated_message="application.flows.errors.copy_source_unregistered", context={"kind": ref.kind.value, "ref": ref.ref}, ) for resolver in resolvers: rendered = resolver(ref.ref) if rendered is not None: return rendered raise FlowCopyResolutionError( translated_message="application.flows.errors.copy_ref_unresolved", context={"kind": ref.kind.value, "ref": ref.ref, "resolver_count": len(resolvers)}, )
[docs] def resolve_optional_copy(ref: CopyRef | None) -> str | None: """Resolve an optional copy slot; ``None`` stays ``None``.""" return None if ref is None else resolve_copy(ref)
[docs] class ChoiceCopy(BaseModel): """Rendered copy for one choice row.""" model_config = STRICT_FROZEN_CONFIG value: str label: str description: str | None = None provenance: str | None = None
[docs] class LegalRefCopy(BaseModel): """Resolved copy for one legal citation on the provenance zone. ``ref`` is the registry legal-ref token, carried through verbatim; ``label`` is the resolved display text, or ``None`` when the citation declared no label. The pairing is structured, not pre-rendered prose — how a ``ref``/``label`` pair reads on screen is the frontend's concern. """ model_config = STRICT_FROZEN_CONFIG ref: str label: str | None = None
[docs] class PageCopy(BaseModel): """The fully-assembled display bundle for one page. Frontends render this bundle and nothing else — no frontend ever resolves a reference itself, so page copy is identical across the full-screen, line-mode, and any future surface by construction. """ model_config = STRICT_FROZEN_CONFIG prompt: str help: str | None = None format_hint: str | None = None failure_modes: tuple[str, ...] = () legal_zone: tuple[LegalRefCopy, ...] = () choices: tuple[ChoiceCopy, ...] = ()
[docs] def assemble_page_copy(page: FlowPage) -> PageCopy: """Resolve every copy slot on ``page`` into one display bundle.""" return PageCopy( prompt=resolve_copy(page.prompt), help=resolve_optional_copy(page.help), format_hint=resolve_optional_copy(page.format_hint), failure_modes=tuple(resolve_copy(ref) for ref in page.failure_modes), legal_zone=tuple( LegalRefCopy(ref=legal_ref.ref, label=resolve_optional_copy(legal_ref.label)) for legal_ref in page.legal_zone ), choices=tuple( ChoiceCopy( value=choice.value, label=resolve_copy(choice.label), description=resolve_optional_copy(choice.description), provenance=resolve_optional_copy(choice.provenance), ) for choice in page.choices ), )
[docs] def assemble_section_titles(definition: FlowDefinition) -> dict[str, str]: """Resolve every section's title copy, keyed by section id. A section id is an internal slug (``residence``, ``identidad``) that is never display copy; :attr:`FlowSection.title` is the reference that carries the operator-facing, locale-resolved title. Every frontend that shows a section — the question panel's group-box title, the progress header, the review grouping — resolves it here so no surface can fall back to rendering the slug. """ return {section.id: resolve_copy(section.title) for section in definition.sections}
__all__ = [ "ChoiceCopy", "CopySourceResolver", "LegalRefCopy", "PageCopy", "assemble_page_copy", "assemble_section_titles", "register_copy_source", "resolve_copy", "resolve_optional_copy", ]