diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3584a697..9e454244 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -80,7 +80,7 @@ Each capability is a self-contained domain module that provides: - **Validation Rules** — semantic rules that validate the notation against authoritative specifications - **A Contract** — a user-facing configuration object that toggles grammars, excludes rules, and passes parameters -Capabilities are registered with the discovery registry before the first canonicalization call. The registry freezes at the start of each pipeline run, ensuring that the set of available capabilities is stable during execution. +Capabilities are registered with the discovery registry before the first canonicalization call. The registry freezes on the first `canonicalize()` call and remains frozen for later runs, ensuring that the set of available capabilities is stable during execution. The sanctioned bulk form is `paxman.register_all_shipped()`, which registers all ten shipped capabilities in fixed alphabetical order; registration — single or bootstrap — must complete from a single thread before the first `canonicalize()` call, after which reads are safe from any thread. ### Engine diff --git a/QUICKSTART.md b/QUICKSTART.md index 5cedebf2..8f98b057 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -18,10 +18,8 @@ pip install paxman import paxman from paxman.capabilities.Email.capability import EmailCapability from paxman.core.domain import Resolution -from paxman.core.discovery import register_capability -# Register the Email capability (once, before first use) -register_capability(EmailCapability()) +paxman.register_all_shipped() # once, before first use # Create a contract and canonicalize contract = EmailCapability.create_contract() @@ -32,6 +30,10 @@ if result.status == Resolution.SUCCESS: print(result.canonicalized_value) # "user@example.com" ``` +To register only what you need, call `paxman.register_capability(EmailCapability())` per capability. + +Registration — single or bootstrap — must complete from a single thread before the first `canonicalize()` call; post-freeze reads are safe from any thread. + That's it. Paxman recognized the email in your text, validated it against RFC 5322, and returned the lowercase canonical form. --- diff --git a/README.md b/README.md index 13251871..04eaceab 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,9 @@ pip install paxman ```python import paxman from paxman.capabilities import Email -from paxman.core.discovery import register_capability from paxman.core.domain import Resolution -# Register the Email capability (once, before first use) -register_capability(Email()) +paxman.register_all_shipped() # once, before first use # Create a contract and canonicalize contract = Email.create_contract() @@ -34,6 +32,10 @@ if result.status == Resolution.SUCCESS: print(result.canonicalized_value) # "user@example.com" ``` +To register only what you need, call `paxman.register_capability(Email())` per capability. + +**Registration and threading:** Registration — single (`register_capability`) or bootstrap (`paxman.register_all_shipped()`) — must complete from a single thread before the first `canonicalize()` call; the registry then freezes and reads are safe from any thread; registering later raises `CapabilityError`. + --- ## What Happens diff --git a/paxman/__init__.py b/paxman/__init__.py index cc35bb43..ff6e01dd 100644 --- a/paxman/__init__.py +++ b/paxman/__init__.py @@ -1,3 +1,4 @@ +from paxman.api.bootstrap import register_all_shipped from paxman.api.canonicalize import canonicalize from paxman.core.discovery import register_capability from paxman.core.errors import CapabilityError @@ -7,6 +8,7 @@ "CapabilityError", "canonicalize", "register_capability", + "register_all_shipped", "register_grammar", "register_rule", ] diff --git a/paxman/api/bootstrap.py b/paxman/api/bootstrap.py new file mode 100644 index 00000000..02d15dca --- /dev/null +++ b/paxman/api/bootstrap.py @@ -0,0 +1,62 @@ +"""Sanctioned bootstrap: register every shipped capability in one call.""" + +from __future__ import annotations + +from typing import Any + +from paxman.capabilities import ( + IP, + ISBN, + URL, + Country, + Currency, + Date, + Email, + Money, + Phone, + SIUnit, +) +from paxman.core.capability import Capability +from paxman.core.discovery import get_capability, register_capability +from paxman.core.errors import CapabilityError + +# Fixed, documented order (alphabetical by capability registry name) — +# bootstrap is deterministic. D2: literal tuple, no dynamic enumeration. +_SHIPPED: tuple[type[Capability[Any]], ...] = ( + Country, + Currency, + Date, + Email, + IP, + ISBN, + Money, + Phone, + SIUnit, + URL, +) + + +def register_all_shipped() -> tuple[str, ...]: + """Register every shipped capability not already registered. + + Idempotent by name: a capability already registered (including a + caller-registered subclass) is skipped, never overridden. Does not + freeze the registry — freezing still happens on the first + ``canonicalize()`` call. Raises ``CapabilityError`` if the registry is + already frozen and anything remains to register. + + Threading contract: complete registration — single-calls or this + helper — from a single thread before the first ``canonicalize()`` + call; post-freeze reads are safe from any thread. + + Returns: + Names newly registered, in call order. + """ + registered: list[str] = [] + for cls in _SHIPPED: + try: + get_capability(cls.name) + except CapabilityError: + register_capability(cls()) + registered.append(cls.name) + return tuple(registered) diff --git a/tests/e2e/test_bootstrap.py b/tests/e2e/test_bootstrap.py new file mode 100644 index 00000000..01a2e1f4 --- /dev/null +++ b/tests/e2e/test_bootstrap.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import pytest + +import paxman +from paxman.capabilities import Email +from paxman.core.discovery import reset_registry +from paxman.core.domain import Resolution + + +@pytest.fixture(autouse=True) +def _clean_registry(): + reset_registry() + yield + reset_registry() + + +@pytest.mark.e2e +def test_bootstrap_then_canonicalize_round_trip() -> None: + """register_all_shipped() is a complete bootstrap: pipeline resolves.""" + paxman.register_all_shipped() + contract = Email.create_contract() + result = paxman.canonicalize("user@Example.COM", contract) + assert result.status is Resolution.SUCCESS + assert result.canonicalized_value == "user@example.com" diff --git a/tests/unit/test_bootstrap.py b/tests/unit/test_bootstrap.py new file mode 100644 index 00000000..752c44cb --- /dev/null +++ b/tests/unit/test_bootstrap.py @@ -0,0 +1,79 @@ +"""Unit tests for the register_all_shipped bootstrap helper.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest + +import paxman +from paxman.capabilities import Email +from paxman.core.discovery import ( + get_capability, + is_registry_frozen, + register_capability, + reset_registry, +) + + +@pytest.fixture +def _clean_registry() -> Iterator[None]: + reset_registry() + yield + reset_registry() + + +@pytest.mark.unit +def test_registers_all_ten_shipped(_clean_registry) -> None: + names = paxman.register_all_shipped() + expected = ( + "country", + "currency", + "date", + "email", + "ip", + "isbn", + "money", + "phone", + "si_unit", + "url", + ) + assert names == expected + for name in expected: + assert get_capability(name).name == name + + +@pytest.mark.unit +def test_idempotent_second_call_registers_nothing(_clean_registry) -> None: + paxman.register_all_shipped() + assert paxman.register_all_shipped() == () + + +@pytest.mark.unit +def test_preserves_caller_registration(_clean_registry) -> None: + mine = Email() + register_capability(mine) + names = paxman.register_all_shipped() + assert "email" not in names + assert len(names) == 9 + assert get_capability("email") is mine + + +@pytest.mark.unit +def test_does_not_freeze_registry(_clean_registry) -> None: + paxman.register_all_shipped() + assert is_registry_frozen() is False + + +@pytest.mark.unit +def test_raises_after_freeze(_clean_registry) -> None: + """Natural freeze: one canonicalize() call freezes the registry; a later + bootstrap with anything left to register must surface the error.""" + register_capability(Email()) + contract = Email.create_contract() + paxman.canonicalize("user@example.com", contract) # freezes naturally + assert is_registry_frozen() is True + # "url" was never registered, so the helper still has work to do — it + # must raise (via register_capability's frozen check), never swallow. + with pytest.raises(paxman.CapabilityError): + paxman.register_all_shipped()