mail-address-semantics/mas_core/model.py

132 lines
3.4 KiB
Python
Raw Normal View History

"""Immutable syntax model for MAS v0.1 addresses.
These objects carry syntactic facts only. Nothing here encodes a semantic
kind (person, actor, function): that requires statute or registry metadata.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Optional, Union
class RequiredProfile(str, Enum):
"""The minimum MAS profile whose grammar the syntax requires."""
P1 = "P1"
P2 = "P2"
@dataclass(frozen=True)
class OpaqueTarget:
value: str
kind = "opaque"
def __str__(self) -> str:
return self.value
@dataclass(frozen=True)
class ResourceTarget:
resource_type: str
resource_id: str
kind = "resource"
def __str__(self) -> str:
return f"{self.resource_type}={self.resource_id}"
Target = Union[OpaqueTarget, ResourceTarget]
@dataclass(frozen=True)
class Facet:
value: str
def __str__(self) -> str:
return self.value
@dataclass(frozen=True)
class Interaction:
mode: str
def __str__(self) -> str:
return self.mode
@dataclass(frozen=True)
class ParsedLocalPart:
"""A canonical MAS v0.1 local-part, parsed with the maximal (P2) grammar."""
raw: str
target: Target
facet: Optional[Facet] = None
interaction: Optional[Interaction] = None
@property
def required_profile(self) -> RequiredProfile:
return RequiredProfile.P2 if self.interaction is not None else RequiredProfile.P1
@property
def base(self) -> str:
"""The base target: what remains if a mail system strips the facet."""
return str(self.target)
@property
def generator_allowed(self) -> bool:
"""v0.1 generators MUST NOT emit both a facet and an interaction."""
return not (self.facet is not None and self.interaction is not None)
def without_facet(self) -> "ParsedLocalPart":
return ParsedLocalPart(raw=_render(self.target, None, self.interaction),
target=self.target, interaction=self.interaction)
def to_fixture_dict(self) -> dict:
"""Shape used by the conformance corpus (`parsed` objects)."""
out = {
"target": str(self.target),
"target_kind": self.target.kind,
"facet": self.facet.value if self.facet else None,
"interaction": self.interaction.mode if self.interaction else None,
}
if isinstance(self.target, ResourceTarget):
out["resource_type"] = self.target.resource_type
out["resource_id"] = self.target.resource_id
return out
def to_dict(self) -> dict:
out = self.to_fixture_dict()
out["required_profile"] = self.required_profile.value
out["base"] = self.base
out["generator_allowed"] = self.generator_allowed
return out
@dataclass(frozen=True)
class ParsedAddress:
local: ParsedLocalPart
domain: str
@property
def required_profile(self) -> RequiredProfile:
return self.local.required_profile
def __str__(self) -> str:
return f"{self.local.raw}@{self.domain}"
def to_dict(self) -> dict:
return {"address": str(self), "domain": self.domain, **self.local.to_dict()}
def _render(target: Target, facet: Optional[Facet], interaction: Optional[Interaction]) -> str:
s = str(target)
if facet is not None:
s += "+" + facet.value
if interaction is not None:
s += "!" + interaction.mode
return s