mail-address-semantics/mas_cli/main.py
tegwick 87be0d1da3 MAS-WP-0010 finished: email slave role, stub outside master, contract flows, SCP-8
- EmailMode slave role: master-issued case ids, events to the master,
  sends only on instruction or autonomous policy, transfers blocked or to
  a primed webhook, timeouts left to the master; principle 10 refusals
  reported back.
- conformance/flows.json (13 flows) + mas_modes.runner; mas test runs the
  mode corpus (172/172).
- SCP-8 / v0.2 section 7.1: !MODE requests a handoff defined by the mode
  contract. docs/reuse-surface.md.
- Deploy image copies mas_modes (ticket service imports its mailer).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5787@bnt-lap001
Assistant-Session: 5db26b82-138b-40cc-9856-4d5eb0f752fd
2026-10-19 11:03:16 +02:00

313 lines
14 KiB
Python

"""`mas` — inspection CLI for Mail Address Semantics v0.1."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from mas_core import MAS_VERSION, __version__
from mas_core.discovery import Discovered, discover
from mas_core.generator import DESTINATION_PROFILES, CannotExpress, for_destination, generate_local_part
from mas_core.model import ResourceTarget
from mas_core.parser import MasSyntaxError, parse_address, parse_local_part, validate_profile
from mas_core.policy import resolve
from mas_core.statute import ERROR, SCHEMA, build_statute, lint
def _out(args, data, text):
print(json.dumps(data, indent=2) if args.json else text)
def _parse_any(value):
return parse_address(value) if "@" in value else parse_local_part(value)
def _syntax_lines(d):
lines = [f" target: {d['target']}", f" target syntax: {d['target_kind']}"]
if d["target_kind"] == "resource":
lines += [f" resource type: {d['resource_type']}", f" resource id: {d['resource_id']}"]
if d["facet"]:
lines.append(f" facet: {d['facet']} (base if stripped: {d['base']})")
if d["interaction"]:
lines.append(f" interaction: {d['interaction']}")
lines.append(f" required profile: {d['required_profile']}")
if not d["generator_allowed"]:
lines.append(" note: valid to parse, but a v0.1 generator must not emit '+' with '!'")
return lines
def cmd_parse(args):
try:
p = _parse_any(args.address)
except MasSyntaxError as e:
_out(args, {"valid": False, "error": e.code, "message": str(e)},
f"invalid MAS v0.1 syntax [{e.code}]: {e}")
return 1
d = p.to_dict()
if args.profile:
r = validate_profile(p, args.profile)
d["profile_check"] = {"profile": args.profile.upper(), "ok": r.ok, "error": r.code}
if not r:
_out(args, {"valid": False, **d}, f"invalid under {args.profile.upper()} [{r.code}]: {r.message}")
return 1
_out(args, {"valid": True, **d}, "Syntax:\n" + "\n".join(_syntax_lines(d)))
return 0
def _discover(args, host):
if args.statute:
doc = json.loads(Path(args.statute).read_text())
return Discovered(host, f"file:{args.statute}", build_statute(doc, host))
return discover(host, scheme="http" if args.insecure_http else "https", port=args.port)
def cmd_discover(args):
r = _discover(args, args.host)
if r.ok:
st = r.statute
text = [f"Statute for {r.host} ({r.url}): valid",
f" profiles: P1, P2={'enabled' if st.p2 else 'disabled'}",
f" resource types: {', '.join(k for k in st.resource_types if st.resource_type_enabled(k)) or '-'}",
f" interaction modes: {', '.join(k for k in st.interaction_modes if st.interaction_mode_enabled(k)) or '-'}",
f" equivalence: case={st.local_part_case}, dot_folding={st.dot_folding}",
f" registry entries: {len(st.addresses)}"]
if st.inherited_from:
text.append(f" inherited defaults from: {', '.join(st.inherited_from)}")
_out(args, r.to_dict(), "\n".join(text))
return 0
_out(args, r.to_dict(), f"No valid MAS statute for {r.host}\n failure: {r.kind.value}\n"
f" url: {r.url}\n detail: {r.detail}")
return 2
def cmd_explain(args):
try:
addr = parse_address(args.address)
except MasSyntaxError as e:
_out(args, {"address": args.address, "syntax": {"valid": False, "error": e.code}},
f"Address:\n {args.address}\n\nSyntax:\n invalid MAS v0.1 syntax [{e.code}]: {e}\n"
" (the address may still be deliverable as ordinary email)")
return 1
registry = json.loads(Path(args.registry).read_text()) if args.registry else None
disc = None if args.no_discover else _discover(args, addr.domain)
statute = disc.statute if disc is not None and disc.ok else None
sem = resolve(addr, statute, registry)
syn = addr.to_dict()
lines = ["Address:", f" {addr}", "", "Syntax:", *_syntax_lines(syn), "", "Discovery:"]
if disc is None:
lines.append(" skipped (--no-discover)")
elif disc.ok:
lines.append(f" valid statute for {disc.host} ({disc.url})")
else:
lines += [" no valid MAS statute found", f" failure: {disc.kind.value} — {disc.detail}"]
lines += ["", "Semantics:", f" kind: {sem.kind}" + (f" (from {sem.kind_source})" if sem.kind_source != "none" else "")]
if sem.stand_ins:
lines.append(f" read as: {sem.to_dict()['canonical_local_part']} "
f"(v0.2 stand-in: {', '.join(sem.stand_ins)})")
if sem.registry_key:
lines.append(f" registry entry: {sem.registry_key}")
if sem.intake_for:
lines.append(f" intake for resource type: {sem.intake_for}")
if sem.interaction and statute is not None and sem.ok:
lines.append(f" interaction preference: {sem.interaction} (a request, not a command)")
for f in sem.failures:
lines.append(f" failure: {f.code} — {f.message}")
if sem.unknowns:
lines += ["", "Unknowns:"] + [f" - {u}" for u in sem.unknowns]
data = {"address": str(addr), "syntax": syn,
"discovery": None if disc is None else disc.to_dict(), "semantics": sem.to_dict()}
_out(args, data, "\n".join(lines))
return 0
MAX_OCTETS = 64
TIGHT_OCTETS = 16 # fewer left than this: warn, stand-ins and forwarders add octets
def _forwarder_needs(statute, local_part):
"""The forwarder-safe form (spec v0.2 section 12.1): the stand-ins the
statute declares, then lower case."""
p = parse_local_part(local_part)
needs = []
if statute.alt_resource and isinstance(p.target, ResourceTarget):
needs.append("resource-alternate")
if statute.alt_interaction and p.interaction is not None:
needs.append("interaction-alternate")
return tuple(needs) + ("lowercase",)
def cmd_compose(args):
try:
local = generate_local_part(args.target, facet=args.facet, interaction=args.interaction)
except MasSyntaxError as e:
hint = (" a facet and an interaction are exclusive: pick one, or address the"
" resource and state the mode in the message\n"
if e.code == "generator-facet-and-interaction" else "")
_out(args, {"ok": False, "error": e.code, "message": str(e)},
f"cannot compose [{e.code}]: {e}\n{hint}".rstrip())
return 1
disc = None if args.no_discover else _discover(args, args.domain)
statute = disc.statute if disc is not None and disc.ok else None
warnings = []
if disc is not None and not disc.ok:
warnings.append(f"no valid MAS statute for {args.domain} ({disc.kind.value}); "
"the receiver may read this as ordinary email")
if statute is not None:
sem = resolve(parse_local_part(local), statute)
warnings += [f"{f.code}: {f.message}" for f in sem.failures]
octets = len(local.encode())
if MAX_OCTETS - octets < TIGHT_OCTETS:
warnings.append(f"{octets}/{MAX_OCTETS} octets: little room left for forwarders")
forms = {}
if statute is not None:
wanted = {name: DESTINATION_PROFILES[name] for name in args.destination}
if statute.alt_resource or statute.alt_interaction:
wanted = {"forwarder-safe": _forwarder_needs(statute, local), **wanted}
for name, needs in wanted.items():
try:
r = for_destination(local, statute, needs)
forms[name] = {"local_part": r.local_part, "address": f"{r.local_part}@{args.domain}",
"applied": list(r.applied), "warnings": list(r.warnings)}
except CannotExpress as e:
forms[name] = {"local_part": None, "error": e.code, "message": str(e)}
elif args.destination:
warnings.append("destination forms need the statute; none was used")
lines = [f"{local}@{args.domain}", f" octets: {octets}/{MAX_OCTETS} (local-part)"]
for name, f in forms.items():
if f["local_part"] is None:
lines.append(f" {name}: cannot express [{f['error']}]: {f['message']}")
else:
how = f" ({', '.join(f['applied'])})" if f["applied"] else " (unchanged)"
lines.append(f" {name}: {f['address']}{how}")
lines += [f" warning: {w}" for w in warnings]
_out(args, {"ok": True, "local_part": local, "address": f"{local}@{args.domain}",
"octets": octets, "forms": forms, "warnings": warnings}, "\n".join(lines))
return 0
def cmd_lint(args):
worst = 0
reports = []
for file in args.files:
try:
doc = json.loads(Path(file).read_text())
except (OSError, json.JSONDecodeError) as e:
reports.append({"file": file, "findings": [{"layer": SCHEMA, "severity": ERROR,
"code": "unreadable", "path": "", "message": str(e)}]})
worst = 1
continue
findings = lint(doc, args.host)
reports.append({"file": file, "findings": [f.__dict__ for f in findings]})
if any(f.severity == ERROR for f in findings):
worst = 1
if args.json:
print(json.dumps(reports, indent=2))
return worst
for rep in reports:
fs = rep["findings"]
errs = [f for f in fs if f["severity"] == ERROR]
print(f"{rep['file']}: {'INVALID' if errs else 'OK'}"
f" ({len(errs)} error(s), {len(fs) - len(errs)} warning(s))")
for f in fs:
label = "warning" if f["severity"] != ERROR else f"{f['layer']} error"
print(f" {label} [{f['code']}] {f['path'] or '/'}: {f['message']}")
return worst
def cmd_test(args):
from mas_conformance import statute_cases, statute_document
from mas_conformance.runner import run_parser_corpus, run_statute_aware_corpus
rep = run_parser_corpus()
aware = run_statute_aware_corpus()
st_fail = []
for c in statute_cases():
fs = lint(statute_document(c["file"]), c["host"])
errs = [f for f in fs if f.severity == ERROR]
good = (not errs) if c["expect"] == "valid" else (
errs and {f.layer for f in errs} == {c["expect"]}
and set(c.get("codes", [])) <= {f.code for f in errs})
if not good:
st_fail.append((c["id"], c["file"], [str(f) for f in errs]))
n_st = len(statute_cases())
print(f"parser corpus: {rep.passed}/{rep.passed + len(rep.failures)} passed")
for fid, lp, why in rep.failures:
print(f" FAIL {fid} {lp!r}: {why}")
print(f"statute corpus: {n_st - len(st_fail)}/{n_st} passed")
for fid, file, why in st_fail:
print(f" FAIL {fid} {file}: {why}")
print(f"v0.2 statute-aware corpus: {aware.passed}/{aware.passed + len(aware.failures)} passed")
for fid, subject, why in aware.failures:
print(f" FAIL {fid} {subject!r}: {why}")
from mas_modes.runner import run_mode_corpus
modes = run_mode_corpus()
print(f"mode contract v0.1 corpus: {modes.passed}/{modes.passed + len(modes.failures)} passed")
for fid, why in modes.failures:
print(f" FAIL {fid}: {why}")
return 0 if rep.ok and not st_fail and aware.ok and modes.ok else 1
def build_parser():
ap = argparse.ArgumentParser(prog="mas", description=f"Mail Address Semantics v{MAS_VERSION} tools")
ap.add_argument("--version", action="version", version=f"mas {__version__} (MAS {MAS_VERSION})")
sub = ap.add_subparsers(dest="cmd", required=True)
def common(p):
p.add_argument("--json", action="store_true", help="machine-readable output")
return p
def disc_opts(p):
p.add_argument("--statute", metavar="FILE", help="use a local statute file instead of discovery")
p.add_argument("--insecure-http", action="store_true", help=argparse.SUPPRESS)
p.add_argument("--port", type=int, help=argparse.SUPPRESS)
p = common(sub.add_parser("parse", help="show syntax only (no network)"))
p.add_argument("address", help="local-part or full address")
p.add_argument("--profile", choices=["p1", "p2", "P1", "P2"], help="check a parser conformance class")
p.set_defaults(func=cmd_parse)
p = common(sub.add_parser("discover", help="fetch and validate https://HOST/.well-known/mas"))
p.add_argument("host")
disc_opts(p)
p.set_defaults(func=cmd_discover)
p = common(sub.add_parser("explain", help="syntax, discovery and semantics, kept separate"))
p.add_argument("address")
disc_opts(p)
p.add_argument("--no-discover", action="store_true", help="skip discovery (syntax-only semantics)")
p.add_argument("--registry", metavar="FILE", help="local registry JSON (addresses/resource_types)")
p.set_defaults(func=cmd_explain)
p = common(sub.add_parser("compose", help="write the canonical address for a target, and its "
"forwarder-safe form"))
p.add_argument("domain", help="receiving domain (its statute is discovered)")
p.add_argument("target", help="resource TYPE=ID, or a registry name such as 'ticket'")
p.add_argument("--facet", help="facet (the part after '+')")
p.add_argument("--interaction", metavar="MODE", help="requested interaction mode (P2 '!MODE')")
p.add_argument("--for", dest="destination", action="append", default=[],
choices=sorted(set(DESTINATION_PROFILES) - {"canonical"}),
help="also write the form for this destination (repeatable)")
disc_opts(p)
p.add_argument("--no-discover", action="store_true", help="skip discovery (canonical form only)")
p.set_defaults(func=cmd_compose)
p = common(sub.add_parser("lint-statute", help="schema + semantic validation of statute files"))
p.add_argument("files", nargs="+", metavar="FILE")
p.add_argument("--host", help="host the statute is meant for (checks namespace)")
p.set_defaults(func=cmd_lint)
p = sub.add_parser("test", help="run the bundled conformance corpus")
p.set_defaults(func=cmd_test)
return ap
def main(argv=None) -> int:
args = build_parser().parse_args(argv)
return args.func(args)
if __name__ == "__main__":
sys.exit(main())