diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d77deca7..55e946f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,7 @@ on: branches: [main] types: [opened, synchronize, reopened] push: - branches: [main] + branches: [main, "feature/**", "refactor/**"] concurrency: group: ci-${{ github.ref }} diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3109fdfe..3584a697 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -119,9 +119,9 @@ In contrast to contracts, Capabilities are defined as Abstract Base Classes (`Ca Capabilities do not hold state. They are factories that produce grammars and rules on demand. The engine queries a capability for its available grammars and rules, then filters them based on the contract configuration. This design keeps capabilities lightweight and makes the filtering logic centralized in the engine. -### Notation Bridging +### Typed Notation -Each capability defines a typed Notation (a data class with named fields) that provides domain-specific structure. However, the Rule and Grammar abstract interfaces operate on a generic list-of-strings representation. Capabilities bridge this gap by providing a conversion method from the typed notation to the generic form. This gives type safety at the capability level while maintaining a uniform interface for the engine. +Each capability defines a frozen dataclass Notation with one `str` field per recognized component (e.g., `DateNotation` carries `N1`, `N2`, `N3`). The concrete notation type is the sole type parameter threaded end to end: `Grammar[NotationT]` recognizes it and `Rule[NotationT]` validates it, so the engine and every rule operate on the fully typed object rather than a positional `list[str]`. Rules read notation fields by name (e.g., `notation.N1`); there is no generic list alias and no conversion bridge between the typed notation and a list form. ### Contract Parameters diff --git a/CONTEXT.md b/CONTEXT.md index 1829c05a..09234bb2 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -44,8 +44,6 @@ Capability-defined intermediate representation that Grammars must produce: - **SIUnit:** `SIUnitNotation(text, shape)` — `shape` is `"symbol"` / `"name"` / `"compound"` / `"split_word_prefix"` / `"split_symbol_prefix"`; `text` is the unit expression as written (symbols keep exact casing, names are grammar-folded to lowercase, compounds keep the written form) - **IP / Phone / URL:** capability-defined shapes for address / number / URI components -**Note:** Capabilities define Notation using frozen dataclasses for type safety and immutability. The `as_list()` method bridges the typed notation to the generic `list[str]` interface. - ### Notation Type Example ```python from dataclasses import dataclass @@ -56,10 +54,6 @@ from dataclasses import dataclass class EmailNotation: local_part: str domain_part: str - - def as_list(self) -> list[str]: - """Convert to list[str] for generic Rule interface.""" - return [self.local_part, self.domain_part] ``` --- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e119ab34..7bee92d4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,20 +17,26 @@ Paxman requires Python 3.11 or later. To set up a local development environment: ```bash # Clone the repository git clone -cd paxman-alternative +cd paxman-python -# Create and activate a virtual environment using uv -uv venv -source .venv/bin/activate +# Install the package with all dev tooling (pytest, hypothesis, ruff, +# pyright, import-linter) into a managed virtual environment +uv sync --all-extras +``` -# Install the package in editable mode with dev dependencies -uv pip install -e ".[dev]" +All dev dependencies are listed in `pyproject.toml` under `[dependency-groups] dev` and include pytest, hypothesis, ruff, pyright, and import-linter. -# Install the dependency-group dev tools (import-linter) -uv pip install -e ".[dev]" --group dev -``` +--- + +## Before You Write Code + +Before implementing a capability or changing recognition or validation logic, read the contributor guides in the repository root: + +- [HOW_TO_ADD_NEW_CAPABILITY.md](HOW_TO_ADD_NEW_CAPABILITY.md) — end-to-end walkthrough of adding a self-contained capability (notation, contract, capability, grammar, rules, presentation). +- [HOW_TO_ADD_NEW_GRAMMAR.md](HOW_TO_ADD_NEW_GRAMMAR.md) — conventions for writing grammars that recognize input without validating it. +- [TESTING_STRATEGY.md](TESTING_STRATEGY.md) — the project's TDD and testing expectations, including purity scans and registry hygiene. -All dev dependencies are listed in `pyproject.toml` under `[project.optional-dependencies] dev` and include pytest, hypothesis, ruff, pyright, and import-linter. +These guides document the contribution patterns; the code under `paxman/` remains the source of truth. --- diff --git a/HOW_TO_ADD_NEW_CAPABILITY.md b/HOW_TO_ADD_NEW_CAPABILITY.md index 07323392..c2ac069e 100644 --- a/HOW_TO_ADD_NEW_CAPABILITY.md +++ b/HOW_TO_ADD_NEW_CAPABILITY.md @@ -99,15 +99,13 @@ Create `paxman/capabilities/YourDomain/notation.py`: 1. Import `dataclass` from `dataclasses` 2. Define a frozen dataclass with `@dataclass(frozen=True)` and one field per component of your notation -3. Add an `as_list()` method that returns the fields as a plain list of strings (in order) — an optional helper for bridging to generic interfaces -The engine passes typed notation objects to rules, not lists. Rules access fields by name (e.g., `notation.field_name`). The `as_list()` method is a convenience helper, not an engine requirement. +The engine passes typed notation objects to rules, not lists. Rules access fields by name (e.g., `notation.field_name`). **Rules for Notation:** - Every field must be a `str` type - The dataclass must be frozen (immutable) -- The `as_list()` method must return fields in a consistent, documented order - Rules access notation fields by name (e.g., `notation.field_name`), not by list position **Example patterns:** @@ -128,7 +126,7 @@ class YourDomainNotation: field_two: str ``` -This is optional. Use it when your notation will be instantiated many times (e.g., processing bulk input). The `as_list()` method works the same way. +This is optional. Use it when your notation will be instantiated many times (e.g., processing bulk input). --- @@ -362,12 +360,11 @@ Create `paxman/capabilities/YourDomain/capability.py`: 1. Import `Capability` from `paxman.core.capability` 2. Import your grammars and rules 3. Define a class that extends `Capability` -4. Set `name` to a lowercase identifier (e.g., "yourdomain") — this is the name users pass to the contract -5. Set `version` to a semantic version string (e.g., "1.0.0") -6. Implement `get_grammars()` — return a list of grammar instances -7. Implement `get_rules()` — return a list of rule instances -8. Define a `create_contract()` static method that returns a default contract -9. Implement `format_value(value, output_format, notation)` — the single presentation seam. The engine calls it immediately after `Rule.normalize()` and before candidate deduplication and status determination. Return the value unchanged for the default format; only an explicitly offered alternative triggers conversion (e.g., Date `"US"` rendering, Country `alpha3`/`numeric`/`name` conversion, Phone RFC 3966/national rendering). A capability with no alternative formats (e.g., Email, IP) inherits the identity implementation from `Capability` and does not override it. Rules never implement presentation — see Step 5d and the presentational-only invariant in Step 7. + 4. Set `name` to a lowercase identifier (e.g., "yourdomain") — this is the name users pass to the contract + 5. Implement `get_grammars()` — return a list of grammar instances + 6. Implement `get_rules()` — return a list of rule instances + 7. Define a `create_contract()` static method that returns a default contract + 8. Implement `format_value(value, output_format, notation)` — the single presentation seam. The engine calls it immediately after `Rule.normalize()` and before candidate deduplication and status determination. Return the value unchanged for the default format; only an explicitly offered alternative triggers conversion (e.g., Date `"US"` rendering, Country `alpha3`/`numeric`/`name` conversion, Phone RFC 3966/national rendering). A capability with no alternative formats (e.g., Email, IP) inherits the identity implementation from `Capability` and does not override it. Rules never implement presentation — see Step 5d and the presentational-only invariant in Step 7. --- @@ -746,20 +743,17 @@ Two sections in one file: 1. `test_creates_with_fields` — verify field access 2. `test_is_frozen` — verify immutability (assigning raises error) -3. `test_as_list_returns_correct` — verify list conversion -4. `test_as_list_preserves_order` — verify field order matches list order -5. `test_equality` — verify value equality -6. `test_hashable` — verify it can be used in sets or as dict keys +3. `test_equality` — verify value equality +4. `test_hashable` — verify it can be used in sets or as dict keys **Capability wiring tests:** -1. `test_is_capability_subclass` — verify isinstance check -2. `test_name` — verify name matches expected value -3. `test_version` — verify version matches expected value -4. `test_get_grammars_returns_all` — verify grammar count -5. `test_get_rules_returns_all` — verify rule count -6. `test_grammar_name` — verify grammar names follow convention -7. `test_rule_name` — verify rule names follow convention + 1. `test_is_capability_subclass` — verify isinstance check + 2. `test_name` — verify name matches expected value + 3. `test_get_grammars_returns_all` — verify grammar count + 4. `test_get_rules_returns_all` — verify rule count + 5. `test_grammar_name` — verify grammar names follow convention + 6. `test_rule_name` — verify rule names follow convention ### 10d: Integration Tests @@ -917,7 +911,7 @@ Rule names follow the pattern `Section {X.Y.Z}-{description}` (e.g., `Section 3. ### Pattern: Notation Fields Are Typed -Rules access notation fields by name (e.g., `notation.field_name`), not by list position. Your `as_list()` method is an optional helper for bridging to generic interfaces. +Rules access notation fields by name (e.g., `notation.field_name`), not by list position. ### Pitfall: Grammar Regex Must Be Compiled Once @@ -1064,7 +1058,7 @@ Semantics to rely on: Use this checklist to verify your capability is complete: -- [ ] Notation is a frozen dataclass with `as_list()` method +- [ ] Notation is a frozen dataclass with one `str` field per component - [ ] Each grammar extends `Grammar[YourDomainNotation]` and implements `recognize(text) -> list[RecognitionMatch[YourDomainNotation]]` - [ ] Each rule extends `Rule[YourDomainNotation]` and implements `matches(notation, contract) -> bool` and `normalize(notation, contract) -> str` - [ ] Each rule declares `target_semantics` (non-empty `frozenset[str]`) and `requires_features` (`frozenset()` when the rule always runs) @@ -1088,7 +1082,7 @@ Use this checklist to verify your capability is complete: - [ ] Grammar tests cover happy path, edge cases, multiple matches, and empty input - [ ] Rule tests cover valid input, invalid input, normalization, provenance, and naming - [ ] Notation tests cover creation, immutability, and equality -- [ ] Capability tests cover subclass check, name, version, grammar count, and rule count +- [ ] Capability tests cover subclass check, name, grammar count, and rule count - [ ] Integration tests use the `_clean_registry` fixture - [ ] End-to-end tests exercise the public API - [ ] `pyright --strict` passes with zero errors diff --git a/HOW_TO_ADD_NEW_GRAMMAR.md b/HOW_TO_ADD_NEW_GRAMMAR.md index 248fa324..cd7e242e 100644 --- a/HOW_TO_ADD_NEW_GRAMMAR.md +++ b/HOW_TO_ADD_NEW_GRAMMAR.md @@ -62,7 +62,7 @@ class TestSlashISODateGrammar: grammar = SlashISODateGrammar() result = grammar.recognize("2026/07/26") assert len(result) == 1 - assert result[0].notation.as_list() == ["2026", "07", "26"] + assert result[0].notation == DateNotation(N1="2026", N2="07", N3="26") def test_does_not_match_us_or_european_order(self) -> None: """A 2-digit-first slash date is not a slash-ISO date.""" @@ -76,7 +76,7 @@ class TestSlashISODateGrammar: assert result[0].start == 2 assert result[0].end == 12 assert result[0].raw_text == "2026/07/26" - assert result[0].notation.as_list() == ["2026", "07", "26"] + assert result[0].notation == DateNotation(N1="2026", N2="07", N3="26") def test_grammar_name(self) -> None: grammar = SlashISODateGrammar() diff --git a/TESTING_STRATEGY.md b/TESTING_STRATEGY.md index dc56711a..e5e26f03 100644 --- a/TESTING_STRATEGY.md +++ b/TESTING_STRATEGY.md @@ -45,7 +45,7 @@ For the Email capability, this means three classes of tests: **Rule tests** confirm that each validation rule's `matches()` method accepts valid notation and rejects invalid notation. They verify that `normalize()` produces the expected canonical string. They check that rule metadata (name, strategy, provenance attributes, citation) is set correctly. For example, `Section341AddrSpec` lowercases the email during normalization, while `Section63localhost` preserves case. -**Capability tests** verify that the capability class itself satisfies the `Capability` ABC, that it returns the correct number and type of grammars and rules from `get_grammars()` and `get_rules()`, and that its notation type (`EmailNotation`) is immutable, equality-comparable, hashable, and bridges to the generic `list[str]` representation via `as_list()`. +**Capability tests** verify that the capability class itself satisfies the `Capability` ABC, that it returns the correct number and type of grammars and rules from `get_grammars()` and `get_rules()`, and that its notation type (`EmailNotation`) is an immutable, equality-comparable, hashable frozen dataclass with one `str` field per component. ### Integration Tests diff --git a/paxman/capabilities/AGENTS.md b/paxman/capabilities/AGENTS.md index 87730ccc..1d9eb0ac 100644 --- a/paxman/capabilities/AGENTS.md +++ b/paxman/capabilities/AGENTS.md @@ -10,7 +10,7 @@ The deepest directory in the repo (157 py files): 10 shipped capability packages paxman/capabilities/ ├── __init__.py # registration imports + __all__ (see NOTES) ├── / # one self-contained package per capability -│ ├── __init__.py # exports Capability, Contract, Notation +│ ├── __init__.py # exports Capability, Contract, Notation │ ├── notation.py # frozen slots dataclass — the intermediate token │ ├── contract.py # frozen CapabilityContract subclass (NO slots) │ ├── capability.py # Capability[NotationT] subclass — wiring + format_value() diff --git a/paxman/capabilities/Country/capability.py b/paxman/capabilities/Country/capability.py index 881ccebe..15fb9a3e 100644 --- a/paxman/capabilities/Country/capability.py +++ b/paxman/capabilities/Country/capability.py @@ -43,7 +43,6 @@ class CountryCapability(Capability[CountryNotation]): """ name = "country" - version = "1.0.0" def get_grammars(self) -> list[Grammar[CountryNotation]]: """Return all grammar instances. diff --git a/paxman/capabilities/Country/notation.py b/paxman/capabilities/Country/notation.py index bbb95060..77d69705 100644 --- a/paxman/capabilities/Country/notation.py +++ b/paxman/capabilities/Country/notation.py @@ -16,11 +16,3 @@ class CountryNotation: shape: str value: str - - def as_list(self) -> list[str]: - """Bridge to generic list[str] interface. - - Returns: - [shape, value] — shape first for consistent ordering. - """ - return [self.shape, self.value] diff --git a/paxman/capabilities/Currency/capability.py b/paxman/capabilities/Currency/capability.py index 3e349788..a6b83eb7 100644 --- a/paxman/capabilities/Currency/capability.py +++ b/paxman/capabilities/Currency/capability.py @@ -32,7 +32,6 @@ class CurrencyCapability(Capability[CurrencyNotation]): """ name = "currency" - version = "1.0.0" def get_grammars(self) -> list[Grammar[CurrencyNotation]]: """Return all grammar instances. diff --git a/paxman/capabilities/Currency/notation.py b/paxman/capabilities/Currency/notation.py index b185c238..dce17353 100644 --- a/paxman/capabilities/Currency/notation.py +++ b/paxman/capabilities/Currency/notation.py @@ -33,7 +33,3 @@ def __post_init__(self) -> None: raise ValueError( f"invalid shape {self.shape!r}; expected one of {sorted(_VALID_SHAPES)}" ) - - def as_list(self) -> list[str]: - """Flatten the notation for structural equality checks.""" - return [self.text, self.shape] diff --git a/paxman/capabilities/Date/capability.py b/paxman/capabilities/Date/capability.py index fa0650c3..585133c5 100644 --- a/paxman/capabilities/Date/capability.py +++ b/paxman/capabilities/Date/capability.py @@ -32,7 +32,6 @@ class DateCapability(Capability[DateNotation]): """Date canonicalization capability.""" name = "date" - version = "1.0.0" def get_grammars(self) -> list[Grammar[DateNotation]]: return [ diff --git a/paxman/capabilities/Date/notation.py b/paxman/capabilities/Date/notation.py index c3b1eee6..8f16881d 100644 --- a/paxman/capabilities/Date/notation.py +++ b/paxman/capabilities/Date/notation.py @@ -18,7 +18,3 @@ class DateNotation: N1: str N2: str N3: str - - def as_list(self) -> list[str]: - """Convert to list[str] for generic Rule interface.""" - return [self.N1, self.N2, self.N3] diff --git a/paxman/capabilities/Email/capability.py b/paxman/capabilities/Email/capability.py index 16f5eeba..02602e0f 100644 --- a/paxman/capabilities/Email/capability.py +++ b/paxman/capabilities/Email/capability.py @@ -27,7 +27,6 @@ class EmailCapability(Capability[EmailNotation]): """Email canonicalization capability.""" name = "email" - version = "1.0.0" def get_grammars(self) -> list[Grammar[EmailNotation]]: return [ diff --git a/paxman/capabilities/Email/notation.py b/paxman/capabilities/Email/notation.py index 991a91c9..825bc271 100644 --- a/paxman/capabilities/Email/notation.py +++ b/paxman/capabilities/Email/notation.py @@ -11,7 +11,3 @@ class EmailNotation: local_part: str domain_part: str - - def as_list(self) -> list[str]: - """Convert to list[str] for generic Rule interface.""" - return [self.local_part, self.domain_part] diff --git a/paxman/capabilities/IP/capability.py b/paxman/capabilities/IP/capability.py index c2eaf2c8..0a65a55f 100644 --- a/paxman/capabilities/IP/capability.py +++ b/paxman/capabilities/IP/capability.py @@ -22,7 +22,6 @@ class IPCapability(Capability[IPNotation]): """IP canonicalization capability.""" name = "ip" - version = "1.0.0" def get_grammars(self) -> list[Grammar[IPNotation]]: return [ diff --git a/paxman/capabilities/IP/notation.py b/paxman/capabilities/IP/notation.py index 36d582ba..4febbd62 100644 --- a/paxman/capabilities/IP/notation.py +++ b/paxman/capabilities/IP/notation.py @@ -10,7 +10,3 @@ class IPNotation: """IP address notation: a single address string.""" address: str - - def as_list(self) -> list[str]: - """Convert to list[str] for generic Rule interface.""" - return [self.address] diff --git a/paxman/capabilities/ISBN/capability.py b/paxman/capabilities/ISBN/capability.py index 8f1aa321..cec43270 100644 --- a/paxman/capabilities/ISBN/capability.py +++ b/paxman/capabilities/ISBN/capability.py @@ -78,7 +78,6 @@ class ISBNCapability(Capability[ISBNNotation]): """ name = "isbn" - version = "1.0.0" def get_grammars(self) -> list[Grammar[ISBNNotation]]: return [ISBN13RecognitionGrammar(), ISBN10RecognitionGrammar()] diff --git a/paxman/capabilities/ISBN/notation.py b/paxman/capabilities/ISBN/notation.py index 7f6caa42..9b350f65 100644 --- a/paxman/capabilities/ISBN/notation.py +++ b/paxman/capabilities/ISBN/notation.py @@ -14,7 +14,3 @@ class ISBNNotation: shape: Literal["isbn10", "isbn13"] digits: str - - def as_list(self) -> list[str]: - """Convert to list[str] for the generic Rule interface.""" - return [self.shape, self.digits] diff --git a/paxman/capabilities/Money/capability.py b/paxman/capabilities/Money/capability.py index d7e3a0b2..2c646481 100644 --- a/paxman/capabilities/Money/capability.py +++ b/paxman/capabilities/Money/capability.py @@ -30,7 +30,6 @@ class MoneyCapability(Capability[MoneyNotation]): """ name = "money" - version = "1.0.0" def get_grammars(self) -> list[Grammar[MoneyNotation]]: """Return all grammar instances. diff --git a/paxman/capabilities/Money/notation.py b/paxman/capabilities/Money/notation.py index 4234d3b0..cc1b7336 100644 --- a/paxman/capabilities/Money/notation.py +++ b/paxman/capabilities/Money/notation.py @@ -45,16 +45,3 @@ def __post_init__(self) -> None: raise ValueError(f"invalid currency_shape: {self.currency_shape!r}") if self.amount_shape and self.amount_shape not in _VALID_AMOUNT_SHAPES: raise ValueError(f"invalid amount_shape: {self.amount_shape!r}") - - def as_list(self) -> list[str]: - """Bridge to generic list[str] interface. - - Returns: - [currency_part, amount_part, currency_shape, amount_shape]. - """ - return [ - self.currency_part, - self.amount_part, - self.currency_shape, - self.amount_shape, - ] diff --git a/paxman/capabilities/Phone/capability.py b/paxman/capabilities/Phone/capability.py index d00845fd..27cbd8aa 100644 --- a/paxman/capabilities/Phone/capability.py +++ b/paxman/capabilities/Phone/capability.py @@ -38,7 +38,6 @@ class PhoneCapability(Capability[PhoneNotation]): """ name = "phone" - version = "1.0.0" def get_grammars(self) -> list[Grammar[PhoneNotation]]: """Return all grammar instances. diff --git a/paxman/capabilities/Phone/notation.py b/paxman/capabilities/Phone/notation.py index 814987de..e0eaa14b 100644 --- a/paxman/capabilities/Phone/notation.py +++ b/paxman/capabilities/Phone/notation.py @@ -25,11 +25,3 @@ class PhoneNotation: shape: str value: str extension: str = "" - - def as_list(self) -> list[str]: - """Bridge to generic list[str] interface. - - Returns: - [shape, value, extension] — shape first for consistent ordering. - """ - return [self.shape, self.value, self.extension] diff --git a/paxman/capabilities/SIUnit/capability.py b/paxman/capabilities/SIUnit/capability.py index c3fbb330..8701156b 100644 --- a/paxman/capabilities/SIUnit/capability.py +++ b/paxman/capabilities/SIUnit/capability.py @@ -36,7 +36,6 @@ class SIUnitCapability(Capability[SIUnitNotation]): """ name = "si_unit" - version = "1.0.0" def get_grammars(self) -> list[Grammar[SIUnitNotation]]: """Return all grammar instances. diff --git a/paxman/capabilities/SIUnit/notation.py b/paxman/capabilities/SIUnit/notation.py index 07a179c2..de30c076 100644 --- a/paxman/capabilities/SIUnit/notation.py +++ b/paxman/capabilities/SIUnit/notation.py @@ -25,7 +25,8 @@ class SIUnitNotation: Attributes: text: The unit text. Symbols keep their exact casing; names are grammar-folded to lowercase; compounds keep the written form. - shape: One of "symbol", "name", "compound", "split_word_prefix", "split_symbol_prefix". + shape: One of "symbol", "name", "compound", "split_word_prefix", + "split_symbol_prefix". """ text: str @@ -43,7 +44,3 @@ def __post_init__(self) -> None: raise ValueError( f"invalid shape {self.shape!r}; expected one of {sorted(_VALID_SHAPES)}" ) - - def as_list(self) -> list[str]: - """Flatten the notation for structural equality checks.""" - return [self.text, self.shape] diff --git a/paxman/capabilities/URL/capability.py b/paxman/capabilities/URL/capability.py index f466fedc..324f04e4 100644 --- a/paxman/capabilities/URL/capability.py +++ b/paxman/capabilities/URL/capability.py @@ -26,7 +26,6 @@ class URLCapability(Capability[URLNotation]): """ name = "url" - version = "1.0.0" def get_grammars(self) -> list[Grammar[URLNotation]]: return [ diff --git a/paxman/capabilities/URL/notation.py b/paxman/capabilities/URL/notation.py index 175fa092..73d3403f 100644 --- a/paxman/capabilities/URL/notation.py +++ b/paxman/capabilities/URL/notation.py @@ -11,11 +11,7 @@ class URLNotation: Shape-only carrier (D15): it stores the recognized text exactly as scanned and never validates it — validity is the rule layer's job (D7). - The single ``text`` component maps one-to-one through ``as_list()``. + The single ``text`` component is the recognized URL string. """ text: str - - def as_list(self) -> list[str]: - """Convert to list[str] for generic Rule interface.""" - return [self.text] diff --git a/paxman/core/__init__.py b/paxman/core/__init__.py index 092d2c3a..1da0b8bc 100644 --- a/paxman/core/__init__.py +++ b/paxman/core/__init__.py @@ -11,7 +11,6 @@ Candidate, Grammar, GrammarRule, - Notation, Provenance, RecognitionMatch, RecognizedRep, @@ -46,7 +45,6 @@ "Grammar", "GrammarRule", "MultipleMentionsError", - "Notation", "PaxmanError", "Provenance", "RecognitionError", diff --git a/paxman/core/capability.py b/paxman/core/capability.py index 5fa8957f..0b733203 100644 --- a/paxman/core/capability.py +++ b/paxman/core/capability.py @@ -28,7 +28,6 @@ class EmailCapability(Capability[EmailNotation]): ... """ name: str - version: str @abstractmethod def get_grammars(self) -> list[Grammar[NotationT]]: @@ -76,7 +75,7 @@ class ContractFactory(Protocol): unanimous common parameter block — ``excluded_rules``, ``pinned_rules``, ``year``, ``output_format``, all keyword-only — followed by capability- specific parameters. This protocol makes that common block structural: - the five capability classes satisfy it by declaring ``create_contract`` + every shipped capability class satisfies it by declaring ``create_contract`` with those parameters (plus their own extras). """ diff --git a/paxman/core/domain.py b/paxman/core/domain.py index 056107a4..0973253c 100644 --- a/paxman/core/domain.py +++ b/paxman/core/domain.py @@ -33,12 +33,6 @@ class Resolution(Enum): AMBIGUOUS = "ambiguous" -# Notation type — capability-defined, but list[str] is the generic contract. -# For capability-specific Notation, use a TypedDict or dataclass to capture -# positional semantics. -Notation = list[str] - - @dataclass(frozen=True, slots=True) class Provenance: """Authority citation for a validated value.""" diff --git a/pyproject.toml b/pyproject.toml index 1277d67f..255dae8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,10 @@ build-backend = "hatchling.build" name = "paxman" version = "0.2.0" description = "Canonicalization authority resolver" +readme = "README.md" requires-python = ">=3.11" +license = "MIT" +license-files = ["LICENSE.md"] classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", @@ -15,15 +18,10 @@ classifiers = [ ] dependencies = [] -[project.optional-dependencies] -dev = [ - "pytest>=8.0", - "pytest-cov>=5.0", - "hypothesis>=6.100", - "ruff>=0.5", - "pyright>=1.1", - "import-linter>=2.10", -] +[project.urls] +Homepage = "https://github.com/azaharizaman/paxman-python" +Repository = "https://github.com/azaharizaman/paxman-python" +Issues = "https://github.com/azaharizaman/paxman-python" [tool.pytest.ini_options] markers = [ @@ -112,5 +110,10 @@ packages = ["paxman"] [dependency-groups] dev = [ + "pytest>=8.0", + "pytest-cov>=5.0", + "hypothesis>=6.100", + "ruff>=0.5", + "pyright>=1.1", "import-linter>=2.13", ] diff --git a/tests/capabilities/country/test_capability.py b/tests/capabilities/country/test_capability.py index 6250e0cf..dd5378f9 100644 --- a/tests/capabilities/country/test_capability.py +++ b/tests/capabilities/country/test_capability.py @@ -24,18 +24,6 @@ def test_is_frozen(self) -> None: with pytest.raises(AttributeError): notation.shape = "alpha3" # type: ignore[misc] - def test_as_list_returns_correct(self) -> None: - """Verify list conversion.""" - notation = CountryNotation(shape="alpha2", value="US") - assert notation.as_list() == ["alpha2", "US"] - - def test_as_list_preserves_order(self) -> None: - """Verify field order matches list order.""" - notation = CountryNotation(shape="name", value="United States") - result = notation.as_list() - assert result[0] == notation.shape - assert result[1] == notation.value - def test_equality(self) -> None: """Verify value equality.""" n1 = CountryNotation(shape="alpha2", value="US") @@ -187,10 +175,6 @@ def test_name(self) -> None: """Verify name matches expected value.""" assert CountryCapability.name == "country" - def test_version(self) -> None: - """Verify version matches expected value.""" - assert CountryCapability.version == "1.0.0" - def test_get_grammars_returns_all(self) -> None: """Verify grammar count (4).""" cap = CountryCapability() diff --git a/tests/capabilities/currency/test_capability.py b/tests/capabilities/currency/test_capability.py index 73bf03ad..0912fc29 100644 --- a/tests/capabilities/currency/test_capability.py +++ b/tests/capabilities/currency/test_capability.py @@ -21,10 +21,6 @@ def test_name(self) -> None: """Verify the capability name.""" assert CurrencyCapability.name == "currency" - def test_version(self) -> None: - """Verify the capability version.""" - assert CurrencyCapability.version == "1.0.0" - def test_get_grammars(self) -> None: """Verify the three grammar instances and their names.""" cap = CurrencyCapability() diff --git a/tests/capabilities/currency/test_notation.py b/tests/capabilities/currency/test_notation.py index e1386589..d9a1212a 100644 --- a/tests/capabilities/currency/test_notation.py +++ b/tests/capabilities/currency/test_notation.py @@ -16,13 +16,6 @@ def test_frozen() -> None: notation.text = "EUR" # type: ignore[misc] -def test_as_list() -> None: - assert CurrencyNotation(text="US$", shape="qualified_symbol").as_list() == [ - "US$", - "qualified_symbol", - ] - - @pytest.mark.parametrize( ("text", "shape"), [ diff --git a/tests/capabilities/date/test_capability.py b/tests/capabilities/date/test_capability.py index 5880100d..f0f7c24d 100644 --- a/tests/capabilities/date/test_capability.py +++ b/tests/capabilities/date/test_capability.py @@ -28,10 +28,6 @@ def test_is_frozen(self) -> None: with pytest.raises(AttributeError): notation.N1 = "2025" # type: ignore[misc] - def test_as_list_returns_correct(self) -> None: - notation = DateNotation(N1="2026", N2="07", N3="26") - assert notation.as_list() == ["2026", "07", "26"] - def test_equality(self) -> None: n1 = DateNotation(N1="2026", N2="07", N3="26") n2 = DateNotation(N1="2026", N2="07", N3="26") @@ -57,10 +53,6 @@ def test_name(self) -> None: cap = DateCapability() assert cap.name == "date" - def test_version(self) -> None: - cap = DateCapability() - assert cap.version == "1.0.0" - def test_get_grammars_returns_all(self) -> None: cap = DateCapability() assert len(cap.get_grammars()) == 4 diff --git a/tests/capabilities/date/test_grammar.py b/tests/capabilities/date/test_grammar.py index fdb3ec00..0a40ca53 100644 --- a/tests/capabilities/date/test_grammar.py +++ b/tests/capabilities/date/test_grammar.py @@ -14,6 +14,7 @@ SlashISODateGrammar, ) from paxman.capabilities.Date.grammar.us_recognition import USDateGrammar +from paxman.capabilities.Date.notation import DateNotation @pytest.mark.capability @@ -24,7 +25,7 @@ def test_recognizes_valid_input(self) -> None: grammar = ISO8601DateGrammar() result = grammar.recognize("2026-07-26") assert len(result) == 1 - assert result[0].notation.as_list() == ["2026", "07", "26"] + assert result[0].notation == DateNotation(N1="2026", N2="07", N3="26") def test_recognizes_multiple(self) -> None: grammar = ISO8601DateGrammar() @@ -62,7 +63,7 @@ def test_emits_spans(self) -> None: assert result[0].start == 2 assert result[0].end == 12 assert result[0].raw_text == "2026-07-26" - assert result[0].notation.as_list() == ["2026", "07", "26"] + assert result[0].notation == DateNotation(N1="2026", N2="07", N3="26") @property def grammar(self) -> ISO8601DateGrammar: @@ -77,13 +78,13 @@ def test_recognizes_4digit_year(self) -> None: grammar = USDateGrammar() result = grammar.recognize("07/26/2026") assert len(result) == 1 - assert result[0].notation.as_list() == ["07", "26", "2026"] + assert result[0].notation == DateNotation(N1="07", N2="26", N3="2026") def test_recognizes_2digit_year(self) -> None: grammar = USDateGrammar() result = grammar.recognize("07/26/26") assert len(result) == 1 - assert result[0].notation.as_list() == ["07", "26", "26"] + assert result[0].notation == DateNotation(N1="07", N2="26", N3="26") def test_recognizes_variant_input(self) -> None: grammar = USDateGrammar() @@ -117,7 +118,7 @@ def test_emits_spans(self) -> None: assert result[0].start == 2 assert result[0].end == 12 assert result[0].raw_text == "07/26/2026" - assert result[0].notation.as_list() == ["07", "26", "2026"] + assert result[0].notation == DateNotation(N1="07", N2="26", N3="2026") @property def grammar(self) -> USDateGrammar: @@ -132,13 +133,13 @@ def test_recognizes_4digit_year(self) -> None: grammar = EuropeanDateGrammar() result = grammar.recognize("26/07/2026") assert len(result) == 1 - assert result[0].notation.as_list() == ["26", "07", "2026"] + assert result[0].notation == DateNotation(N1="26", N2="07", N3="2026") def test_recognizes_2digit_year(self) -> None: grammar = EuropeanDateGrammar() result = grammar.recognize("26/07/26") assert len(result) == 1 - assert result[0].notation.as_list() == ["26", "07", "26"] + assert result[0].notation == DateNotation(N1="26", N2="07", N3="26") def test_recognizes_variant_input(self) -> None: grammar = EuropeanDateGrammar() @@ -167,7 +168,7 @@ def test_emits_spans(self) -> None: assert result[0].start == 2 assert result[0].end == 12 assert result[0].raw_text == "26/07/2026" - assert result[0].notation.as_list() == ["26", "07", "2026"] + assert result[0].notation == DateNotation(N1="26", N2="07", N3="2026") @property def grammar(self) -> EuropeanDateGrammar: @@ -182,13 +183,13 @@ def test_recognizes_valid_input(self) -> None: grammar = SlashISODateGrammar() result = grammar.recognize("2026/07/26") assert len(result) == 1 - assert result[0].notation.as_list() == ["2026", "07", "26"] + assert result[0].notation == DateNotation(N1="2026", N2="07", N3="26") def test_recognizes_single_digit_components(self) -> None: grammar = SlashISODateGrammar() result = grammar.recognize("2026/7/6") assert len(result) == 1 - assert result[0].notation.as_list() == ["2026", "7", "6"] + assert result[0].notation == DateNotation(N1="2026", N2="7", N3="6") def test_recognizes_multiple(self) -> None: grammar = SlashISODateGrammar() @@ -232,7 +233,7 @@ def test_emits_spans(self) -> None: assert result[0].start == 2 assert result[0].end == 12 assert result[0].raw_text == "2026/07/26" - assert result[0].notation.as_list() == ["2026", "07", "26"] + assert result[0].notation == DateNotation(N1="2026", N2="07", N3="26") @property def grammar(self) -> SlashISODateGrammar: diff --git a/tests/capabilities/email/test_capability.py b/tests/capabilities/email/test_capability.py index 3d578ad7..38aaab5c 100644 --- a/tests/capabilities/email/test_capability.py +++ b/tests/capabilities/email/test_capability.py @@ -30,21 +30,6 @@ def test_is_frozen(self) -> None: with pytest.raises(AttributeError): notation.local_part = "other" # type: ignore[misc] - @pytest.mark.capability - def test_as_list_returns_two_element_list(self) -> None: - """as_list() bridges to generic list[str] interface.""" - notation = EmailNotation(local_part="user", domain_part="example.com") - result = notation.as_list() - assert result == ["user", "example.com"] - assert isinstance(result, list) - - @pytest.mark.capability - def test_as_list_preserves_order(self) -> None: - """as_list() returns [local_part, domain_part] in order.""" - notation = EmailNotation(local_part="azahari", domain_part="gmail.com") - assert notation.as_list()[0] == "azahari" - assert notation.as_list()[1] == "gmail.com" - @pytest.mark.capability def test_equality(self) -> None: """Two notations with same fields are equal.""" @@ -83,11 +68,6 @@ def test_name(self) -> None: """Capability name is 'email'.""" assert EmailCapability().name == "email" - @pytest.mark.capability - def test_version(self) -> None: - """Capability version is '1.0.0'.""" - assert EmailCapability().version == "1.0.0" - @pytest.mark.capability def test_get_grammars_returns_all_email_grammars(self) -> None: """get_grammars() returns all three email grammars.""" diff --git a/tests/capabilities/ip/test_capability.py b/tests/capabilities/ip/test_capability.py index afa06863..ba613304 100644 --- a/tests/capabilities/ip/test_capability.py +++ b/tests/capabilities/ip/test_capability.py @@ -29,14 +29,6 @@ def test_is_frozen(self) -> None: with pytest.raises(AttributeError): notation.address = "10.0.0.1" # type: ignore[misc] - @pytest.mark.capability - def test_as_list_returns_single_element(self) -> None: - """as_list() bridges to generic list[str] interface.""" - notation = IPNotation(address="192.168.1.1") - result = notation.as_list() - assert result == ["192.168.1.1"] - assert isinstance(result, list) - @pytest.mark.capability def test_equality(self) -> None: """Two notations with same address are equal.""" @@ -75,11 +67,6 @@ def test_name(self) -> None: """Capability name is 'ip'.""" assert IPCapability().name == "ip" - @pytest.mark.capability - def test_version(self) -> None: - """Capability version is '1.0.0'.""" - assert IPCapability().version == "1.0.0" - @pytest.mark.capability def test_get_grammars_returns_all_ip_grammars(self) -> None: """get_grammars() returns both IPv4 and IPv6 grammars.""" diff --git a/tests/capabilities/isbn/test_capability.py b/tests/capabilities/isbn/test_capability.py index e419a965..70c1ae46 100644 --- a/tests/capabilities/isbn/test_capability.py +++ b/tests/capabilities/isbn/test_capability.py @@ -8,10 +8,9 @@ pytestmark = [pytest.mark.capability] -def test_capability_name_version() -> None: - """ISBNCapability has the correct name and version.""" +def test_capability_name() -> None: + """ISBNCapability has the correct name.""" assert ISBNCapability.name == "isbn" - assert ISBNCapability.version == "1.0.0" def test_get_grammars() -> None: diff --git a/tests/capabilities/isbn/test_notation.py b/tests/capabilities/isbn/test_notation.py index 38b6d030..e117dddc 100644 --- a/tests/capabilities/isbn/test_notation.py +++ b/tests/capabilities/isbn/test_notation.py @@ -20,12 +20,6 @@ def test_notation_fields() -> None: assert [f.name for f in dataclasses.fields(ISBNNotation)] == ["shape", "digits"] -def test_as_list() -> None: - """as_list bridges to the generic list[str] interface.""" - notation = ISBNNotation(shape="isbn13", digits="9780306406157") - assert notation.as_list() == ["isbn13", "9780306406157"] - - def test_notation_hashable() -> None: """Equal instances hash equal.""" a = ISBNNotation(shape="isbn10", digits="0306406152") diff --git a/tests/capabilities/money/test_capability.py b/tests/capabilities/money/test_capability.py index c5faa536..6642da4e 100644 --- a/tests/capabilities/money/test_capability.py +++ b/tests/capabilities/money/test_capability.py @@ -20,10 +20,6 @@ def test_name(self) -> None: """Verify the capability name.""" assert MoneyCapability.name == "money" - def test_version(self) -> None: - """Verify the capability version.""" - assert MoneyCapability.version == "1.0.0" - def test_get_grammars(self) -> None: """Verify the three grammar instances and their names.""" cap = MoneyCapability() diff --git a/tests/capabilities/money/test_notation.py b/tests/capabilities/money/test_notation.py index 2664b5e5..39148224 100644 --- a/tests/capabilities/money/test_notation.py +++ b/tests/capabilities/money/test_notation.py @@ -55,17 +55,6 @@ def test_valid_shapes_accepted() -> None: MoneyNotation(currency_part="USD", amount_part="500") -def test_as_list_order() -> None: - """as_list returns [currency_part, amount_part, currency_shape, amount_shape].""" - notation = MoneyNotation( - currency_part="$", - amount_part="1,234.56", - currency_shape="symbol", - amount_shape="comma_decimal", - ) - assert notation.as_list() == ["$", "1,234.56", "symbol", "comma_decimal"] - - def test_notation_hashable() -> None: """Equal instances hash equal.""" a = MoneyNotation(currency_part="USD", amount_part="500") diff --git a/tests/capabilities/phone/test_capability.py b/tests/capabilities/phone/test_capability.py index 4fb4572a..a47e8e53 100644 --- a/tests/capabilities/phone/test_capability.py +++ b/tests/capabilities/phone/test_capability.py @@ -33,16 +33,6 @@ def test_is_frozen(self) -> None: with pytest.raises(AttributeError): notation.shape = "national" # type: ignore[misc] - def test_as_list_returns_correct(self) -> None: - """Verify list conversion.""" - notation = PhoneNotation(shape="e164", value="15551234567") - assert notation.as_list() == ["e164", "15551234567", ""] - - def test_as_list_with_extension(self) -> None: - """Verify list conversion includes extension.""" - notation = PhoneNotation(shape="rfc3966", value="15551234567", extension="890") - assert notation.as_list() == ["rfc3966", "15551234567", "890"] - def test_equality(self) -> None: """Verify value equality.""" n1 = PhoneNotation(shape="e164", value="15551234567") @@ -142,10 +132,6 @@ def test_name(self) -> None: """Verify capability name.""" assert PhoneCapability.name == "phone" - def test_version(self) -> None: - """Verify capability version.""" - assert PhoneCapability.version == "1.0.0" - def test_get_grammars_returns_all(self) -> None: """Verify grammar count.""" capability = PhoneCapability() diff --git a/tests/capabilities/si_unit/test_capability.py b/tests/capabilities/si_unit/test_capability.py index a97e0504..fc689d6a 100644 --- a/tests/capabilities/si_unit/test_capability.py +++ b/tests/capabilities/si_unit/test_capability.py @@ -19,9 +19,7 @@ def setup_method(self) -> None: self.capability = SIUnitCapability() def test_metadata(self) -> None: - # name == "si_unit"; version == "1.0.0" assert self.capability.name == "si_unit" - assert self.capability.version == "1.0.0" def test_get_grammars(self) -> None: # 3 instances with names {symbol_recognition, name_recognition, diff --git a/tests/capabilities/si_unit/test_notation.py b/tests/capabilities/si_unit/test_notation.py index 75be0f3c..80cfb73a 100644 --- a/tests/capabilities/si_unit/test_notation.py +++ b/tests/capabilities/si_unit/test_notation.py @@ -25,10 +25,6 @@ def test_rejects_unknown_shape(self) -> None: with pytest.raises(ValueError): SIUnitNotation(text="kg", shape="quantity") - def test_as_list(self) -> None: - n = SIUnitNotation(text="kg", shape="symbol") - assert n.as_list() == ["kg", "symbol"] - def test_frozen(self) -> None: n = SIUnitNotation(text="kg", shape="symbol") with pytest.raises(dataclasses.FrozenInstanceError): diff --git a/tests/capabilities/url/test_capability.py b/tests/capabilities/url/test_capability.py index b0d75862..ea835090 100644 --- a/tests/capabilities/url/test_capability.py +++ b/tests/capabilities/url/test_capability.py @@ -31,10 +31,6 @@ def test_name(self) -> None: """Capability name is 'url'.""" assert URLCapability().name == "url" - def test_version(self) -> None: - """Capability version is '1.0.0'.""" - assert URLCapability().version == "1.0.0" - def test_get_grammars_returns_url_grammar(self) -> None: """get_grammars() returns exactly the absolute-URI grammar.""" cap = URLCapability() diff --git a/tests/capabilities/url/test_notation.py b/tests/capabilities/url/test_notation.py index 3dd57bf1..e0c4e2c9 100644 --- a/tests/capabilities/url/test_notation.py +++ b/tests/capabilities/url/test_notation.py @@ -14,13 +14,6 @@ def test_fields() -> None: assert URLNotation(text="https://example.com").text == "https://example.com" -def test_as_list() -> None: - """as_list returns the text wrapped in a single-element list.""" - assert URLNotation(text="mailto:user@example.com").as_list() == [ - "mailto:user@example.com" - ] - - def test_frozen() -> None: """Assigning a field raises FrozenInstanceError.""" notation = URLNotation(text="https://example.com") @@ -35,4 +28,4 @@ def test_slots() -> None: def test_empty_text_valid() -> None: """Shape-only notation accepts empty text; validity is the rule's job (D7).""" - assert URLNotation(text="").as_list() == [""] + assert URLNotation(text="").text == "" diff --git a/tests/integration/test_feature_gating.py b/tests/integration/test_feature_gating.py index b3267948..f73e3f27 100644 --- a/tests/integration/test_feature_gating.py +++ b/tests/integration/test_feature_gating.py @@ -117,7 +117,6 @@ class _LocalizedOnlyCapability(Capability[CountryNotation]): """Capability exposing only the localized rule over the fixture grammar.""" name = "country" - version = "1.0.0" def get_grammars(self) -> list[Grammar[CountryNotation]]: return [_NameRecognitionGrammar()] @@ -160,7 +159,6 @@ class _DanglingFeatureCapability(Capability[CountryNotation]): """Capability exposing a rule that names a nonexistent contract feature.""" name = "dangling_feature" - version = "0.1.0" def get_grammars(self) -> list[Grammar[CountryNotation]]: return [_NameRecognitionGrammar()] @@ -226,7 +224,6 @@ class _DanglingGrammarCapability(Capability[CountryNotation]): """Capability exposing a rule with a dangling grammar affinity.""" name = "dangling_grammar" - version = "0.1.0" def get_grammars(self) -> list[Grammar[CountryNotation]]: return [_NameRecognitionGrammar()] diff --git a/tests/unit/test_capability.py b/tests/unit/test_capability.py index 2f4ddcb6..893e1031 100644 --- a/tests/unit/test_capability.py +++ b/tests/unit/test_capability.py @@ -6,7 +6,7 @@ from paxman.core.capability import Capability from paxman.core.contract import Contract -from paxman.core.domain import Grammar, Notation, Provenance, Rule, RuleStrategy +from paxman.core.domain import Grammar, Provenance, Rule, RuleStrategy # --- Concrete test doubles --- @@ -17,7 +17,7 @@ class StubGrammar(Grammar): name: str = "stub_grammar" semantics = "stub_grammar" - def recognize(self, text: str) -> list[Notation]: + def recognize(self, text: str) -> list[list[str]]: return [] @@ -39,10 +39,10 @@ class StubRule(Rule): target_semantics = frozenset({"stub_grammar"}) requires_features = frozenset() - def matches(self, notation: Notation, contract: Contract) -> bool: + def matches(self, notation: list[str], contract: Contract) -> bool: return True - def normalize(self, notation: Notation, contract: Contract) -> str: + def normalize(self, notation: list[str], contract: Contract) -> str: return "stub" @@ -50,7 +50,6 @@ class ConcreteCapability(Capability): """Concrete capability that fully implements the ABC.""" name = "test_cap" - version = "0.1.0" def get_grammars(self) -> list[Grammar]: return [StubGrammar()] @@ -75,7 +74,6 @@ def test_cannot_instantiate_without_abstract_methods(self) -> None: class IncompleteCapability(Capability): name = "incomplete" - version = "0.1.0" # Missing get_grammars and get_rules with pytest.raises(TypeError): @@ -87,7 +85,6 @@ def test_cannot_instantiate_missing_get_grammars(self) -> None: class PartialCapability(Capability): name = "partial" - version = "0.1.0" def get_rules(self) -> list[Rule]: return [] @@ -101,7 +98,6 @@ def test_cannot_instantiate_missing_get_rules(self) -> None: class PartialCapability(Capability): name = "partial" - version = "0.1.0" def get_grammars(self) -> list[Grammar]: return [] @@ -114,7 +110,6 @@ def test_concrete_subclass_instantiates(self) -> None: """A fully implemented subclass can be instantiated.""" cap = ConcreteCapability() assert cap.name == "test_cap" - assert cap.version == "0.1.0" @pytest.mark.unit def test_get_grammars_returns_list(self) -> None: diff --git a/tests/unit/test_discovery.py b/tests/unit/test_discovery.py index c316cadc..03f31a0b 100644 --- a/tests/unit/test_discovery.py +++ b/tests/unit/test_discovery.py @@ -23,7 +23,6 @@ class StubCapability(Capability): """Minimal capability for testing the registry.""" name = "stub" - version = "0.1.0" def get_grammars(self) -> list[Grammar]: return [] @@ -36,7 +35,6 @@ class StubCapabilityV2(Capability): """Same name, different class — tests name collision.""" name = "stub" - version = "2.0.0" def get_grammars(self) -> list[Grammar]: return [] @@ -49,7 +47,6 @@ class AnotherCapability(Capability): """Different capability name — tests multiple registrations.""" name = "another" - version = "1.0.0" def get_grammars(self) -> list[Grammar]: return [] diff --git a/tests/unit/test_package_install.py b/tests/unit/test_package_install.py index 98988e23..64096536 100644 --- a/tests/unit/test_package_install.py +++ b/tests/unit/test_package_install.py @@ -101,3 +101,58 @@ def test_package_metadata_matches_pyproject(self) -> None: if line.strip().startswith("version =") ) assert metadata.version("paxman") == declared + + @pytest.mark.unit + def test_project_metadata_declared(self) -> None: + """pyproject declares license, urls, readme, and consolidated dev deps. + + Community-trust floor: the distribution must declare its license, + project URLs, and readme, and the duplicated dev-dependency blocks + must be collapsed into a single ``[dependency-groups] dev`` group. + Tolerant to both PEP 639 license forms (string vs table). + """ + import tomllib + + data = tomllib.loads( + (_REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8") + ) + project = data["project"] + + # readme declared and present on disk + assert project.get("readme") == "README.md" + assert (_REPO_ROOT / "README.md").is_file() + + # License declared in EITHER PEP 639 form + license_field = project.get("license") + assert license_field in ("MIT", {"file": "LICENSE.md"}), ( + f"unexpected license declaration: {license_field!r}" + ) + + # License file(s) exist on disk + if "license-files" in project: + for lic in project["license-files"]: + assert (_REPO_ROOT / lic).is_file(), f"missing license file: {lic}" + else: + assert (_REPO_ROOT / "LICENSE.md").is_file() + + # Project URLs + urls = project.get("urls", {}) + repo_url = "https://github.com/azaharizaman/paxman-python" + assert urls.get("Repository") == repo_url + assert urls.get("Homepage") == repo_url + assert urls.get("Issues") == repo_url + + # The legacy optional-dependencies table is gone + assert "optional-dependencies" not in project + + # Dev deps consolidated into [dependency-groups] dev + dev_deps = " ".join(data["dependency-groups"]["dev"]).lower() + for tool in ( + "pytest", + "pytest-cov", + "hypothesis", + "ruff", + "pyright", + "import-linter", + ): + assert tool in dev_deps, f"missing dev dependency: {tool}" diff --git a/tests/unit/test_removed_surfaces.py b/tests/unit/test_removed_surfaces.py new file mode 100644 index 00000000..81b2bb61 --- /dev/null +++ b/tests/unit/test_removed_surfaces.py @@ -0,0 +1,62 @@ +"""Guards for removed legacy surfaces (architecture-review Near-Term 3). + +Each test locks a removal: the surface must not reappear in source. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +import paxman.core.domain + +_REPO_ROOT = Path(__file__).resolve().parents[2] + + +@pytest.mark.unit +def test_generic_notation_alias_removed() -> None: + """`Notation = list[str]` alias is gone from core and its export.""" + assert not hasattr(paxman.core.domain, "Notation") + assert not hasattr(paxman.core, "Notation") + + +@pytest.mark.unit +def test_no_as_list_bridging_in_source() -> None: + """No paxman source module defines as_list() bridging.""" + offenders = [ + p.as_posix() + for p in (_REPO_ROOT / "paxman").rglob("*.py") + if re.search(r"\bdef as_list\b", p.read_text(encoding="utf-8")) + ] + assert offenders == [], f"as_list bridging found in: {offenders}" + + +@pytest.mark.unit +def test_capability_abc_has_no_version_surface() -> None: + """`Capability.version` is removed from the ABC in both declaration forms.""" + from paxman.core.capability import Capability + + assert "version" not in Capability.__annotations__ + assert "version" not in vars(Capability) + + +@pytest.mark.unit +def test_shipped_capabilities_do_not_declare_version() -> None: + """No shipped capability class carries a dead version attribute.""" + from paxman.capabilities import ( + IP, + ISBN, + URL, + Country, + Currency, + Date, + Email, + Money, + Phone, + SIUnit, + ) + + for cls in (Country, Currency, Date, Email, IP, ISBN, Money, Phone, SIUnit, URL): + assert "version" not in vars(cls), cls.__name__ diff --git a/uv.lock b/uv.lock index 5fdb6461..53f41a53 100644 --- a/uv.lock +++ b/uv.lock @@ -333,7 +333,7 @@ name = "paxman" version = "0.2.0" source = { editable = "." } -[package.optional-dependencies] +[package.dev-dependencies] dev = [ { name = "hypothesis" }, { name = "import-linter" }, @@ -343,24 +343,17 @@ dev = [ { name = "ruff" }, ] -[package.dev-dependencies] -dev = [ - { name = "import-linter" }, -] - [package.metadata] -requires-dist = [ - { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.100" }, - { name = "import-linter", marker = "extra == 'dev'", specifier = ">=2.10" }, - { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, - { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5" }, -] -provides-extras = ["dev"] [package.metadata.requires-dev] -dev = [{ name = "import-linter", specifier = ">=2.13" }] +dev = [ + { name = "hypothesis", specifier = ">=6.100" }, + { name = "import-linter", specifier = ">=2.13" }, + { name = "pyright", specifier = ">=1.1" }, + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-cov", specifier = ">=5.0" }, + { name = "ruff", specifier = ">=0.5" }, +] [[package]] name = "pluggy"