mail-address-semantics/mas_core/generator.py
tegwick c3e2f05ee5 MAS-WP-0005-T09: generator destination transforms with round-trip refusal; example profiles
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 252470@bnt-lap001
Assistant-Session: ad628c8f-4be2-4979-94c9-76c7ed6f8241
2026-10-18 22:26:26 +02:00

135 lines
5.9 KiB
Python

"""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)
if (got.equivalence_key, got.facet, got.interaction) != \
(expected.equivalence_key, expected.facet, expected.interaction):
raise CannotExpress("would-be-misread",
f"{text!r} would be read as {got.to_dict()['canonical_local_part']!r}")
return DestinationAddress(text, tuple(applied), tuple(warnings))