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
135 lines
5.9 KiB
Python
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))
|