"""Canonical MAS local-part generator, with v0.2 destination transforms. Generators are stricter than parsers: they never emit both a facet and an interaction, and they enforce the 64-octet SMTP limit after encoding. """ from __future__ import annotations from dataclasses import dataclass from typing import TYPE_CHECKING, Iterable, Optional from .model import ParsedLocalPart, ResourceTarget, _render from .parser import MasSyntaxError, parse_local_part if TYPE_CHECKING: from .statute import Statute def generate_local_part(target: str, *, facet: Optional[str] = None, interaction: Optional[str] = None) -> str: if facet is not None and interaction is not None: raise MasSyntaxError("generator-facet-and-interaction", "v0.1 generators MUST NOT emit both '+' and '!'") s = target if facet is not None: s += "+" + facet if interaction is not None: s += "!" + interaction parsed = parse_local_part(s) # enforces grammar, emptiness, reserved ops, length if str(parsed.target) != target or (parsed.facet and parsed.facet.value) != facet \ or (parsed.interaction and parsed.interaction.mode) != interaction: raise MasSyntaxError("generator-component", "a component contains a MAS operator") return s def resource_local_part(resource_type: str, resource_id: str, *, facet: Optional[str] = None, interaction: Optional[str] = None) -> str: if not resource_type or not resource_id: raise MasSyntaxError("generator-empty", "resource type and id must be non-empty") if "=" in resource_type: raise MasSyntaxError("invalid-resource-type", "resource type must not contain '='") return generate_local_part(f"{resource_type}={resource_id}", facet=facet, interaction=interaction) # -- v0.2 destination transforms (spec v0.2 section 12.1) -------------------------- TRANSFORMS = ("drop-facet", "drop-interaction", "interaction-alternate", "resource-alternate", "lowercase") LOSSY = frozenset({"drop-facet", "drop-interaction"}) # Example destination profiles (implementation configuration, not spec): # what each destination needs, as observed in MAS-WP-0003. DESTINATION_PROFILES: dict[str, tuple[str, ...]] = { "canonical": (), "ionos-forward": ("resource-alternate", "lowercase"), # refuses '=', forces lower case "google-groups": ("lowercase",), # stores members lower-cased } class CannotExpress(MasSyntaxError): """The address cannot be written for this destination without being misread.""" @dataclass(frozen=True) class DestinationAddress: local_part: str applied: tuple[str, ...] warnings: tuple[str, ...] = () def for_destination(local_part: str, statute: "Statute", needs: Iterable[str] = (), *, allow_lossy: bool = False) -> DestinationAddress: """Write a canonical local-part for a destination that needs `needs`. Transforms apply only where they change something, lossy ones only with `allow_lossy`. The result is read back with the receiving policy; if the namespace would not resolve it to the same resource, facet and interaction, the generator refuses (CannotExpress) instead of emitting it. """ from .policy import resolve # policy imports the parser; keep the generator import-light needs = tuple(needs) unknown = sorted(set(needs) - set(TRANSFORMS)) if unknown: raise ValueError(f"unknown transform(s): {', '.join(unknown)}") if LOSSY & set(needs) and not allow_lossy: raise CannotExpress("lossy-not-allowed", "drop transforms need allow_lossy=True") parsed = parse_local_part(local_part) target, facet, interaction = parsed.target, parsed.facet, parsed.interaction applied: list[str] = [] warnings: list[str] = [] if "drop-facet" in needs and facet is not None: facet = None applied.append("drop-facet") if "drop-interaction" in needs and interaction is not None: warnings.append(f"interaction {interaction.mode!r} dropped; the request mode is lost") interaction = None applied.append("drop-interaction") text = str(target) if "resource-alternate" in needs and isinstance(target, ResourceTarget): if not statute.alt_resource: raise CannotExpress("transform-not-permitted", "statute does not enable the '--' stand-in") if "=" in target.resource_id: raise CannotExpress("cannot-express", "resource id itself contains '='") text = f"{target.resource_type}--{target.resource_id}" applied.append("resource-alternate") if facet is not None: text += "+" + facet.value if interaction is not None: if "interaction-alternate" in needs: if not statute.alt_interaction: raise CannotExpress("transform-not-permitted", "statute does not enable the '---' stand-in") text += "---" + interaction.mode applied.append("interaction-alternate") else: text += "!" + interaction.mode if "lowercase" in needs and text != text.lower(): text = text.lower() applied.append("lowercase") try: out = parse_local_part(text) # grammar and the 64-octet limit, after transforms except MasSyntaxError as e: raise CannotExpress(e.code, f"not expressible for this destination: {e}") from e expected = resolve(ParsedLocalPart(raw=_render(target, facet, interaction), target=target, facet=facet, interaction=interaction), statute) got = resolve(out, statute) fields = ("equivalence_key", "facet", "interaction") diff = [f"{k} {getattr(got, k)!r} instead of {getattr(expected, k)!r}" for k in fields if getattr(got, k) != getattr(expected, k)] if diff: raise CannotExpress("would-be-misread", f"{text!r} would be read with " + ", ".join(diff)) return DestinationAddress(text, tuple(applied), tuple(warnings))