From 97d6d28960fd59f32492c3e96b8fb0b3f61df090 Mon Sep 17 00:00:00 2001 From: Leo Lara Date: Mon, 11 May 2026 22:31:22 +0700 Subject: [PATCH 1/3] feat(forks): add SigScheme capability and @requires marker (Stage 7 of #686) Introduces the first fork-level capability and a pytest marker to gate tests on capability presence. Capability ---------- - New `SigScheme` runtime-checkable Protocol in `forks/capabilities.py` asserts a `sig_scheme: ClassVar[GeneralizedXmssScheme]` attribute. - `LstarSpec` binds `sig_scheme = TARGET_SIGNATURE_SCHEME` so `isinstance(LstarSpec(), SigScheme)` returns True. - The three spec methods that previously took `scheme=TARGET_SIGNATURE_SCHEME` (`verify_signatures`, `on_gossip_attestation`, `on_block`) drop the parameter and read `self.sig_scheme` directly. The capability becomes the runtime source of truth. Marker ------ - `requires(*capabilities)` pytest marker, registered in `pytest_plugins/filler.py`. Composes (AND) with the existing `valid_from` / `valid_until` / `valid_at` fork-range markers. - `_check_markers_valid_for_fork` instantiates the active spec once and runs `isinstance(spec, capability)` per required capability. - A `requires(...)` helper in `framework.markers` works around pytest's auto-detect-class shortcut (which trips on Protocol args to `@pytest.mark.requires(...)`). Tests ----- - 11 unit tests in `tests/lean_spec/forks/test_capabilities.py` cover the Protocol and the dispatch helper (composition with the fork-range markers, multiple `@requires` markers, error path for non-runtime_checkable Protocol). - One smoke filler test in `tests/consensus/lstar/test_capability_gating.py` exercises the marker through pytest's live collection: one test marked with SigScheme runs, one marked with a synthetic absent capability is deselected. Filler scheme override ---------------------- The three filler call sites that previously passed `scheme=LEAN_ENV_TO_SCHEMES[self.lean_env]` to spec methods (in `test_fixtures/fork_choice.py` and `test_types/block_spec.py`) drop the kwarg. The PR description has the trade-off note and revert path if that override is in fact needed somewhere we missed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../test_fixtures/fork_choice.py | 8 +- .../test_types/block_spec.py | 3 +- packages/testing/src/framework/__init__.py | 4 + packages/testing/src/framework/markers.py | 38 ++++ .../src/framework/pytest_plugins/filler.py | 32 ++- src/lean_spec/forks/__init__.py | 2 + src/lean_spec/forks/capabilities.py | 28 +++ src/lean_spec/forks/lstar/spec.py | 26 ++- .../consensus/lstar/test_capability_gating.py | 68 +++++++ tests/lean_spec/forks/test_capabilities.py | 186 ++++++++++++++++++ 10 files changed, 375 insertions(+), 20 deletions(-) create mode 100644 packages/testing/src/framework/markers.py create mode 100644 src/lean_spec/forks/capabilities.py create mode 100644 tests/consensus/lstar/test_capability_gating.py create mode 100644 tests/lean_spec/forks/test_capabilities.py diff --git a/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py b/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py index 22e2180d8..07aaf8578 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py +++ b/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py @@ -25,7 +25,6 @@ from lean_spec.types import Slot, Uint64, ValidatorIndex from ..keys import ( - LEAN_ENV_TO_SCHEMES, XmssKeyManager, ) from ..test_types import ( @@ -330,11 +329,7 @@ def make_fixture(self) -> Self: # Process the block through Store. # This validates, applies state transition, and updates the store's head. - store = spec.on_block( - store, - signed_block, - scheme=LEAN_ENV_TO_SCHEMES[self.lean_env], - ) + store = spec.on_block(store, signed_block) case AttestationStep(): # Process a gossip attestation. @@ -351,7 +346,6 @@ def make_fixture(self) -> Self: store = spec.on_gossip_attestation( store, signed_attestation, - scheme=LEAN_ENV_TO_SCHEMES[self.lean_env], is_aggregator=step.is_aggregator, ) diff --git a/packages/testing/src/consensus_testing/test_types/block_spec.py b/packages/testing/src/consensus_testing/test_types/block_spec.py index 21aa8f05f..92b55f075 100644 --- a/packages/testing/src/consensus_testing/test_types/block_spec.py +++ b/packages/testing/src/consensus_testing/test_types/block_spec.py @@ -29,7 +29,7 @@ from lean_spec.subspecs.xmss.containers import Signature from lean_spec.types import Bytes32, CamelModel, Slot, ValidatorIndex, ValidatorIndices -from ..keys import LEAN_ENV_TO_SCHEMES, XmssKeyManager, create_dummy_signature +from ..keys import XmssKeyManager, create_dummy_signature from .aggregated_attestation_spec import AggregatedAttestationSpec @@ -448,7 +448,6 @@ def build_signed_block_with_store( data=attestation.data, signature=signature, ), - scheme=LEAN_ENV_TO_SCHEMES[lean_env], is_aggregator=True, ) diff --git a/packages/testing/src/framework/__init__.py b/packages/testing/src/framework/__init__.py index 9addb6831..b0aa3d010 100644 --- a/packages/testing/src/framework/__init__.py +++ b/packages/testing/src/framework/__init__.py @@ -4,3 +4,7 @@ This module provides base classes and utilities that are common across both consensus and execution layer testing. """ + +from .markers import requires + +__all__ = ["requires"] diff --git a/packages/testing/src/framework/markers.py b/packages/testing/src/framework/markers.py new file mode 100644 index 000000000..2852ff7b3 --- /dev/null +++ b/packages/testing/src/framework/markers.py @@ -0,0 +1,38 @@ +"""Helpers for framework pytest markers. + +The `@requires` marker registered in `pytest_plugins/filler.py` takes a +capability Protocol as its argument. Writing it as +`@pytest.mark.requires(SigScheme)` triggers pytest's auto-detection +heuristic that treats a single class argument as the decoration target, +which fails for Protocol classes ("Protocols cannot be instantiated"). + +The `requires(...)` helper here bypasses the heuristic by going through +`with_args`. + +Preferred placement +------------------- +Match the existing fork-marker convention: pin at the module level +unless individual tests in the file need different capabilities. + +Module-level (preferred when the whole file shares one capability set): + + from framework import requires + from lean_spec.forks import SigScheme + + pytestmark = [pytest.mark.valid_until("Lstar"), requires(SigScheme)] + +Per-function (only when tests within a module differ): + + @requires(SigScheme) + def test_something(...): ... + +Stack multiple `requires(...)` to AND-compose capabilities. Either form +works. +""" + +import pytest + + +def requires(*capabilities: type) -> pytest.MarkDecorator: + """Build a `@requires(...)` marker around one or more capabilities.""" + return pytest.mark.requires.with_args(*capabilities) diff --git a/packages/testing/src/framework/pytest_plugins/filler.py b/packages/testing/src/framework/pytest_plugins/filler.py index af5eec73a..6185f695a 100644 --- a/packages/testing/src/framework/pytest_plugins/filler.py +++ b/packages/testing/src/framework/pytest_plugins/filler.py @@ -226,6 +226,11 @@ def pytest_configure(config: pytest.Config) -> None: "markers", "valid_at(fork): specifies at which fork a test case is valid", ) + config.addinivalue_line( + "markers", + "requires(*capabilities): only collect when the active fork advertises " + "every listed @runtime_checkable Protocol", + ) # Get options output_dir = Path(config.getoption("--output")) @@ -313,6 +318,14 @@ def _check_markers_valid_for_fork( """Check if test markers indicate validity for the given fork. Shared logic for both collection-time and parametrization-time fork filtering. + + Composition rules: + + - `valid_from` / `valid_until` form a fork-range intersection (AND across + kinds, OR across multiple markers of the same kind). + - `valid_at` short-circuits to exact-fork match. + - `requires(capability)` AND-composes on top of either branch — the + active fork must satisfy every listed capability Protocol. """ has_valid_from = False has_valid_until = False @@ -321,6 +334,7 @@ def _check_markers_valid_for_fork( valid_from_forks = [] valid_until_forks = [] valid_at_forks = [] + required_capabilities: list[type] = [] for marker in markers: if marker.name == "valid_from": @@ -341,12 +355,24 @@ def _check_markers_valid_for_fork( target_fork = get_fork_by_name(fork_name) if target_fork: valid_at_forks.append(target_fork) + elif marker.name == "requires": + required_capabilities.extend(marker.args) + + def _capability_check() -> bool: + """Active fork must structurally satisfy every required capability.""" + if not required_capabilities: + return True + # spec_class is a layer-specific extension on BaseFork subclasses. + # The framework dispatches duck-typed across layers. + spec_class_method: Any = fork_class.spec_class # ty: ignore[unresolved-attribute] + spec = spec_class_method()() + return all(isinstance(spec, cap) for cap in required_capabilities) - if not (has_valid_from or has_valid_until or has_valid_at): + if not (has_valid_from or has_valid_until or has_valid_at or required_capabilities): return True if has_valid_at: - return fork_class in valid_at_forks + return fork_class in valid_at_forks and _capability_check() from_valid = True if has_valid_from: @@ -356,7 +382,7 @@ def _check_markers_valid_for_fork( if has_valid_until: until_valid = any(fork_class <= until_fork for until_fork in valid_until_forks) - return from_valid and until_valid + return from_valid and until_valid and _capability_check() def _is_test_item_valid_for_fork( diff --git a/src/lean_spec/forks/__init__.py b/src/lean_spec/forks/__init__.py index 6af6e401c..03df41209 100644 --- a/src/lean_spec/forks/__init__.py +++ b/src/lean_spec/forks/__init__.py @@ -1,5 +1,6 @@ """Multi-fork dispatch layer for leanSpec consensus specification.""" +from .capabilities import SigScheme from .lstar.containers import ( AggregatedAttestation, Attestation, @@ -48,6 +49,7 @@ "ForkRegistry", "LstarSpec", "LstarStore", + "SigScheme", "SignedAggregatedAttestation", "SignedAttestation", "SignedBlock", diff --git a/src/lean_spec/forks/capabilities.py b/src/lean_spec/forks/capabilities.py new file mode 100644 index 000000000..544af918b --- /dev/null +++ b/src/lean_spec/forks/capabilities.py @@ -0,0 +1,28 @@ +"""Optional structural capabilities a fork may advertise. + +Capabilities sit alongside the required Spec*Type protocols in +forks/protocol.py. + +- Required protocols every fork must satisfy via its *_class bindings. +- A capability is optional: a fork advertises it by binding a matching + attribute, and the test filler keys off presence to deselect tests + that need it. + +Today there is one capability: SigScheme. Future capabilities live in +this module too. +""" + +from typing import ClassVar, Protocol, runtime_checkable + +from lean_spec.subspecs.xmss.interface import GeneralizedXmssScheme + + +@runtime_checkable +class SigScheme(Protocol): + """Fork advertising a generalized XMSS signature scheme. + + The runtime check only verifies the attribute is present. + The attribute's type contract is enforced statically. + """ + + sig_scheme: ClassVar[GeneralizedXmssScheme] diff --git a/src/lean_spec/forks/lstar/spec.py b/src/lean_spec/forks/lstar/spec.py index 164d68bf8..64ffd7ac6 100644 --- a/src/lean_spec/forks/lstar/spec.py +++ b/src/lean_spec/forks/lstar/spec.py @@ -75,6 +75,9 @@ class LstarSpec(ForkProtocol): previous: ClassVar[type[ForkProtocol] | None] = None + # Capabilities (see lean_spec/forks/capabilities.py). + sig_scheme: ClassVar[GeneralizedXmssScheme] = TARGET_SIGNATURE_SCHEME + state_class: type[State] = State block_class: type[Block] = Block block_body_class: type[BlockBody] = BlockBody @@ -787,7 +790,6 @@ def verify_signatures( self, signed_block: SignedBlock, validators: Validators, - scheme: GeneralizedXmssScheme = TARGET_SIGNATURE_SCHEME, ) -> bool: """ Verify all XMSS signatures in this signed block. @@ -797,10 +799,11 @@ def verify_signatures( - Each body attestation is signed by participating validators - The proposer signed the block root with the proposal key + The signing scheme is read from the SigScheme capability on this fork. + Args: signed_block: The signed block whose signatures are checked. validators: Validator registry providing public keys for verification. - scheme: XMSS signature scheme for verification. Returns: True if all signatures are valid. @@ -862,7 +865,7 @@ def verify_signatures( block_root = hash_tree_root(block) try: - valid = scheme.verify( + valid = self.sig_scheme.verify( proposer.get_proposal_pubkey(), block.slot, block_root, @@ -1031,19 +1034,24 @@ def on_gossip_attestation( self, store: LstarStore, signed_attestation: SignedAttestation, - scheme: GeneralizedXmssScheme = TARGET_SIGNATURE_SCHEME, is_aggregator: bool = False, ) -> LstarStore: """Process a signed attestation received via gossip network. This method: - 1. Verifies the XMSS signature + + 1. Verifies the XMSS signature using the fork's SigScheme 2. Stores the signature when the node is in aggregator mode Subnet filtering happens at the p2p subscription layer — only attestations from subscribed subnets reach this method. No additional subnet check is needed here. + Args: + store: The current forkchoice store. + signed_attestation: The signed attestation to process. + is_aggregator: True if the node is an aggregator. + Raises: ValueError: If validator not found in state. AssertionError: If signature verification fails. @@ -1067,7 +1075,7 @@ def on_gossip_attestation( ) public_key = key_state.validators[validator_id].get_attestation_pubkey() - assert scheme.verify( + assert self.sig_scheme.verify( public_key, attestation_data.slot, hash_tree_root(attestation_data), signature ), "Signature verification failed" @@ -1160,16 +1168,18 @@ def on_block( self, store: LstarStore, signed_block: SignedBlock, - scheme: GeneralizedXmssScheme = TARGET_SIGNATURE_SCHEME, ) -> LstarStore: """Process a new block and update the forkchoice state. This method integrates a block into the forkchoice store by: + 1. Validating the block's parent exists 2. Computing the post-state via the state transition function 3. Processing attestations included in the block body (on-chain) 4. Updating the forkchoice head + Signatures are verified using the fork's SigScheme capability. + Raises: AssertionError: If parent block/state not found in store. """ @@ -1196,7 +1206,7 @@ def on_block( ) # Validate cryptographic signatures - valid_signatures = self.verify_signatures(signed_block, parent_state.validators, scheme) + valid_signatures = self.verify_signatures(signed_block, parent_state.validators) # Execute state transition function to compute post-block state post_state = self.state_transition(parent_state, block, valid_signatures) diff --git a/tests/consensus/lstar/test_capability_gating.py b/tests/consensus/lstar/test_capability_gating.py new file mode 100644 index 000000000..b7c9b69c2 --- /dev/null +++ b/tests/consensus/lstar/test_capability_gating.py @@ -0,0 +1,68 @@ +"""Smoke tests for the @requires(capability) marker dispatch. + +These exercise the marker through the live pytest collection and +parametrization plumbing (rather than the dispatch helper in isolation). + +Two tests live here: + +- One marked `@requires(SigScheme)` — Lstar satisfies SigScheme, so this + test runs and asserts a trivial state-transition fixture. +- One marked `@requires(_AbsentCapability)` — a Protocol no fork can + satisfy, so this test must be deselected by the framework. If it ever + executes, it fails loudly. + +The second test is the actual proof: a successful `uv run fill` with +this file present means the framework deselected it correctly. + +Marker placement note +--------------------- +Real consensus fillers pin themselves to a fork at the module level via +`pytestmark = pytest.mark.valid_until("Lstar")` (67 files do this). +That's the convention for new fillers. + +This file uses per-function decorators only because each test needs a +*different* capability — one requires `SigScheme`, the other requires +`_AbsentCapability`. A module-level `pytestmark` would apply the same +marker to both. The decorator form is the right tool when individual +tests within a module advertise different capability requirements. +""" + +from typing import ClassVar, Protocol, runtime_checkable + +import pytest +from consensus_testing import StateExpectation, StateTransitionTestFiller, generate_pre_state +from framework import requires + +from lean_spec.forks import SigScheme +from lean_spec.types import Slot + +pytestmark = pytest.mark.valid_until("Lstar") + + +@runtime_checkable +class _AbsentCapability(Protocol): + """A capability no real fork advertises (its required attribute is bogus).""" + + never_an_attribute_on_any_real_fork: ClassVar[object] + + +@requires(SigScheme) +def test_runs_when_fork_advertises_sigscheme( + state_transition_test: StateTransitionTestFiller, +) -> None: + """Lstar advertises SigScheme; this test must run.""" + state_transition_test( + pre=generate_pre_state(), + blocks=[], + post=StateExpectation(slot=Slot(0)), + ) + + +@requires(_AbsentCapability) +def test_deselected_when_capability_absent( + state_transition_test: StateTransitionTestFiller, +) -> None: + """No fork advertises _AbsentCapability; this test must be deselected.""" + raise AssertionError( + "test_deselected_when_capability_absent was executed — @requires deselection is broken." + ) diff --git a/tests/lean_spec/forks/test_capabilities.py b/tests/lean_spec/forks/test_capabilities.py new file mode 100644 index 000000000..a71588aa0 --- /dev/null +++ b/tests/lean_spec/forks/test_capabilities.py @@ -0,0 +1,186 @@ +"""Tests for fork capability Protocols and the @requires marker dispatch.""" + +from dataclasses import dataclass, field +from typing import Any, ClassVar, Protocol, runtime_checkable + +import pytest +from framework.forks import BaseFork +from framework.pytest_plugins.filler import _check_markers_valid_for_fork + +from lean_spec.forks import LstarSpec, SigScheme +from lean_spec.forks.protocol import ForkProtocol, SpecBlockType, SpecStateType +from lean_spec.subspecs.xmss.interface import TARGET_SIGNATURE_SCHEME +from lean_spec.types import ValidatorIndex + + +@dataclass(frozen=True) +class _Mark: + """Minimal stand-in for pytest's Mark object. + + The dispatch helper only reads `.name` and `.args`, so this is enough + to drive it without going through real pytest collection. + """ + + name: str + args: tuple[Any, ...] = () + kwargs: dict[str, Any] = field(default_factory=dict) + + +class _NoSigSpec(ForkProtocol): + """Synthetic fork that does NOT advertise SigScheme. + + Implements just enough abstract surface to be instantiable. Used to + verify the @requires marker correctly deselects against forks lacking + a capability. + """ + + NAME: ClassVar[str] = "no_sig" + VERSION: ClassVar[int] = LstarSpec.VERSION + 1 + GOSSIP_DIGEST: ClassVar[str] = "deadbeef" + previous: ClassVar[type[ForkProtocol] | None] = LstarSpec + + def upgrade_state(self, state: SpecStateType) -> SpecStateType: + """Root-fork identity migration.""" + return state + + def generate_genesis(self, genesis_time: Any, validators: Any) -> SpecStateType: + """Not exercised by capability dispatch.""" + raise NotImplementedError + + def create_store( + self, + state: SpecStateType, + anchor_block: SpecBlockType, + validator_id: ValidatorIndex | None, + ) -> Any: + """Not exercised by capability dispatch.""" + raise NotImplementedError + + +class _NoSigFork(BaseFork): + """BaseFork wrapper exposing _NoSigSpec through spec_class().""" + + @classmethod + def name(cls) -> str: + return "_NoSigFork" + + @classmethod + def spec_class(cls) -> type[_NoSigSpec]: + return _NoSigSpec + + +class _LstarLikeFork(BaseFork): + """Local BaseFork wrapper exposing LstarSpec. + + Mirrors `consensus_testing.forks.Lstar` but kept local so capability + tests don't drag the entire consensus filler bootstrap into the + import graph. + """ + + @classmethod + def name(cls) -> str: + return "_LstarLikeFork" + + @classmethod + def spec_class(cls) -> type[LstarSpec]: + return LstarSpec + + +def _fork_by_name_table(*forks: type[BaseFork]) -> Any: + """Build a get_fork_by_name lookup over the given fork classes.""" + table = {fork.name(): fork for fork in forks} + return table.get + + +class TestSigSchemeCapability: + """SigScheme structurally identifies forks that expose an XMSS scheme.""" + + def test_lstar_advertises_sigscheme(self) -> None: + """LstarSpec satisfies SigScheme at runtime.""" + assert isinstance(LstarSpec(), SigScheme) + + def test_lstar_sig_scheme_is_target_scheme(self) -> None: + """The bound scheme is the same TARGET_SIGNATURE_SCHEME singleton.""" + assert LstarSpec.sig_scheme is TARGET_SIGNATURE_SCHEME + + def test_fork_without_attribute_not_recognized(self) -> None: + """A fork without sig_scheme is structurally rejected.""" + assert not isinstance(_NoSigSpec(), SigScheme) + + +class TestRequiresMarkerDispatch: + """`requires(capability)` composes with the fork-range markers.""" + + def test_no_markers_passes(self) -> None: + """A test with no markers runs on any fork.""" + assert _check_markers_valid_for_fork([], _LstarLikeFork, _fork_by_name_table()) is True + + def test_requires_passes_when_capability_present(self) -> None: + """LstarSpec advertises SigScheme — test is included.""" + markers = [_Mark("requires", (SigScheme,))] + assert _check_markers_valid_for_fork(markers, _LstarLikeFork, _fork_by_name_table()) is True + + def test_requires_fails_when_capability_absent(self) -> None: + """_NoSigSpec doesn't advertise SigScheme — test is deselected.""" + markers = [_Mark("requires", (SigScheme,))] + assert _check_markers_valid_for_fork(markers, _NoSigFork, _fork_by_name_table()) is False + + def test_requires_composes_with_valid_until_and_passes(self) -> None: + """`valid_until` AND `requires` both pass — test is included.""" + markers = [ + _Mark("valid_until", (_LstarLikeFork.name(),)), + _Mark("requires", (SigScheme,)), + ] + assert ( + _check_markers_valid_for_fork( + markers, _LstarLikeFork, _fork_by_name_table(_LstarLikeFork) + ) + is True + ) + + def test_requires_composes_with_valid_until_and_fails_on_capability(self) -> None: + """`valid_until` passes but capability missing — deselected.""" + markers = [ + _Mark("valid_until", (_NoSigFork.name(),)), + _Mark("requires", (SigScheme,)), + ] + assert ( + _check_markers_valid_for_fork(markers, _NoSigFork, _fork_by_name_table(_NoSigFork)) + is False + ) + + def test_valid_at_short_circuit_still_checks_capability(self) -> None: + """`valid_at` matches the fork name but capability missing — deselected.""" + markers = [ + _Mark("valid_at", (_NoSigFork.name(),)), + _Mark("requires", (SigScheme,)), + ] + assert ( + _check_markers_valid_for_fork(markers, _NoSigFork, _fork_by_name_table(_NoSigFork)) + is False + ) + + def test_multiple_requires_markers_compose_with_and(self) -> None: + """Stacked @requires markers all checked — one failing fails the whole.""" + + @runtime_checkable + class _Absent(Protocol): + never_an_attribute_on_any_real_fork: ClassVar[object] + + markers = [ + _Mark("requires", (SigScheme,)), + _Mark("requires", (_Absent,)), + ] + assert ( + _check_markers_valid_for_fork(markers, _LstarLikeFork, _fork_by_name_table()) is False + ) + + def test_requires_with_non_runtime_checkable_protocol_raises(self) -> None: + """isinstance against a plain Protocol raises TypeError at check time.""" + + class _NotRuntimeCheckable(Protocol): + sig_scheme: ClassVar[object] + + markers = [_Mark("requires", (_NotRuntimeCheckable,))] + with pytest.raises(TypeError, match="runtime_checkable"): + _check_markers_valid_for_fork(markers, _LstarLikeFork, _fork_by_name_table()) From ec7eb289e05ad8b7cde4139308fc96c5b3e50982 Mon Sep 17 00:00:00 2001 From: Thomas Coratger <60488569+tcoratger@users.noreply.github.com> Date: Tue, 12 May 2026 00:58:29 +0200 Subject: [PATCH 2/3] refactor(forks): tighten capability marker and apply review feedback - Rename the marker helper to requires_capability and validate runtime-checkable Protocols at call time (fail at import, not at collection) - Cache the fork spec instance in marker dispatch instead of constructing it once per test - Re-export the capabilities namespace from lean_spec.forks so future capabilities don't need new import-site edits - Register valid_from / valid_at / requires markers in pyproject so unit tests can build real pytest Marks under strict-markers - Drop the hand-rolled Mark stand-in in tests; build real Marks via the MarkDecorator path; drop is True / is False on bool predicates - Tighten docstrings per project style (no paragraph blocks, no backtick references, no internal-name references) Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/testing/src/framework/__init__.py | 4 +- packages/testing/src/framework/markers.py | 54 +++--- .../src/framework/pytest_plugins/filler.py | 25 ++- pyproject.toml | 3 + src/lean_spec/forks/__init__.py | 2 + src/lean_spec/forks/capabilities.py | 18 +- src/lean_spec/forks/lstar/spec.py | 12 +- .../consensus/lstar/test_capability_gating.py | 45 +---- tests/lean_spec/forks/test_capabilities.py | 179 ++++++++++-------- 9 files changed, 165 insertions(+), 177 deletions(-) diff --git a/packages/testing/src/framework/__init__.py b/packages/testing/src/framework/__init__.py index b0aa3d010..224576179 100644 --- a/packages/testing/src/framework/__init__.py +++ b/packages/testing/src/framework/__init__.py @@ -5,6 +5,6 @@ both consensus and execution layer testing. """ -from .markers import requires +from .markers import requires_capability -__all__ = ["requires"] +__all__ = ["requires_capability"] diff --git a/packages/testing/src/framework/markers.py b/packages/testing/src/framework/markers.py index 2852ff7b3..9624eba24 100644 --- a/packages/testing/src/framework/markers.py +++ b/packages/testing/src/framework/markers.py @@ -1,38 +1,34 @@ -"""Helpers for framework pytest markers. +"""Helper for the capability-requirement pytest marker.""" -The `@requires` marker registered in `pytest_plugins/filler.py` takes a -capability Protocol as its argument. Writing it as -`@pytest.mark.requires(SigScheme)` triggers pytest's auto-detection -heuristic that treats a single class argument as the decoration target, -which fails for Protocol classes ("Protocols cannot be instantiated"). - -The `requires(...)` helper here bypasses the heuristic by going through -`with_args`. - -Preferred placement -------------------- -Match the existing fork-marker convention: pin at the module level -unless individual tests in the file need different capabilities. - -Module-level (preferred when the whole file shares one capability set): - - from framework import requires - from lean_spec.forks import SigScheme +import pytest - pytestmark = [pytest.mark.valid_until("Lstar"), requires(SigScheme)] -Per-function (only when tests within a module differ): +def requires_capability(*capabilities: type) -> pytest.MarkDecorator: + """Build a capability-requirement marker over one or more Protocols. - @requires(SigScheme) - def test_something(...): ... + Why a helper is needed at all: -Stack multiple `requires(...)` to AND-compose capabilities. Either form -works. -""" + - Pytest treats a single class argument to a marker as the + thing being decorated, not as marker data. + - That makes pytest try to instantiate the class. + - Protocols can't be instantiated, so applying the marker + directly to a Protocol raises TypeError at import. -import pytest + What this helper does: + - Passes the capability through as marker data instead of as + the decoration target. + - Validates each argument up front, so non-Protocol classes + and Protocols missing the runtime-checkable decorator fail + at import rather than at test collection. -def requires(*capabilities: type) -> pytest.MarkDecorator: - """Build a `@requires(...)` marker around one or more capabilities.""" + Raises: + TypeError: If any argument is not a runtime-checkable Protocol. + """ + for cap in capabilities: + if not getattr(cap, "_is_runtime_protocol", False): + raise TypeError( + f"requires_capability expects @runtime_checkable Protocols; " + f"got {getattr(cap, '__name__', cap)!r}" + ) return pytest.mark.requires.with_args(*capabilities) diff --git a/packages/testing/src/framework/pytest_plugins/filler.py b/packages/testing/src/framework/pytest_plugins/filler.py index 6185f695a..67748d8ac 100644 --- a/packages/testing/src/framework/pytest_plugins/filler.py +++ b/packages/testing/src/framework/pytest_plugins/filler.py @@ -1,5 +1,6 @@ """Layer-agnostic pytest plugin for generating Ethereum test fixtures.""" +import functools import importlib import json import shutil @@ -12,6 +13,13 @@ import pytest +@functools.cache +def _spec_instance_for(fork_class: type) -> Any: + """Build the active fork's spec instance once and reuse across collection.""" + spec_class_method: Any = fork_class.spec_class # ty: ignore[unresolved-attribute] + return spec_class_method()() + + class FixtureCollector: """Collects generated fixtures and writes them to disk.""" @@ -228,8 +236,8 @@ def pytest_configure(config: pytest.Config) -> None: ) config.addinivalue_line( "markers", - "requires(*capabilities): only collect when the active fork advertises " - "every listed @runtime_checkable Protocol", + "requires(*capabilities): only collect when the active fork " + "advertises every listed runtime-checkable Protocol", ) # Get options @@ -321,10 +329,10 @@ def _check_markers_valid_for_fork( Composition rules: - - `valid_from` / `valid_until` form a fork-range intersection (AND across - kinds, OR across multiple markers of the same kind). - - `valid_at` short-circuits to exact-fork match. - - `requires(capability)` AND-composes on top of either branch — the + - Fork-range markers form an intersection across kinds and a union + within a kind. + - The exact-fork marker short-circuits to a single-fork match. + - The capability marker AND-composes on top of either branch — the active fork must satisfy every listed capability Protocol. """ has_valid_from = False @@ -362,10 +370,7 @@ def _capability_check() -> bool: """Active fork must structurally satisfy every required capability.""" if not required_capabilities: return True - # spec_class is a layer-specific extension on BaseFork subclasses. - # The framework dispatches duck-typed across layers. - spec_class_method: Any = fork_class.spec_class # ty: ignore[unresolved-attribute] - spec = spec_class_method()() + spec = _spec_instance_for(fork_class) return all(isinstance(spec, cap) for cap in required_capabilities) if not (has_valid_from or has_valid_until or has_valid_at or required_capabilities): diff --git a/pyproject.toml b/pyproject.toml index 88edbaffc..208630ff1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -106,7 +106,10 @@ addopts = [ ] markers = [ "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "valid_from: marks tests as valid from a specific fork version", "valid_until: marks tests as valid until a specific fork version", + "valid_at: marks tests as valid only at a specific fork version", + "requires: marks tests as requiring one or more fork capabilities", "interop: integration tests for multiple leanSpec nodes", "num_validators: number of validators for interop test cluster", ] diff --git a/src/lean_spec/forks/__init__.py b/src/lean_spec/forks/__init__.py index 03df41209..5a1178af9 100644 --- a/src/lean_spec/forks/__init__.py +++ b/src/lean_spec/forks/__init__.py @@ -1,5 +1,6 @@ """Multi-fork dispatch layer for leanSpec consensus specification.""" +from . import capabilities from .capabilities import SigScheme from .lstar.containers import ( AggregatedAttestation, @@ -59,4 +60,5 @@ "Store", "Validator", "Validators", + "capabilities", ] diff --git a/src/lean_spec/forks/capabilities.py b/src/lean_spec/forks/capabilities.py index 544af918b..8ca738ba6 100644 --- a/src/lean_spec/forks/capabilities.py +++ b/src/lean_spec/forks/capabilities.py @@ -1,16 +1,4 @@ -"""Optional structural capabilities a fork may advertise. - -Capabilities sit alongside the required Spec*Type protocols in -forks/protocol.py. - -- Required protocols every fork must satisfy via its *_class bindings. -- A capability is optional: a fork advertises it by binding a matching - attribute, and the test filler keys off presence to deselect tests - that need it. - -Today there is one capability: SigScheme. Future capabilities live in -this module too. -""" +"""Optional structural capabilities a fork may advertise.""" from typing import ClassVar, Protocol, runtime_checkable @@ -21,8 +9,8 @@ class SigScheme(Protocol): """Fork advertising a generalized XMSS signature scheme. - The runtime check only verifies the attribute is present. - The attribute's type contract is enforced statically. + - The runtime check only verifies the attribute is present. + - The static type contract is enforced by the type checker. """ sig_scheme: ClassVar[GeneralizedXmssScheme] diff --git a/src/lean_spec/forks/lstar/spec.py b/src/lean_spec/forks/lstar/spec.py index 64ffd7ac6..3701bc308 100644 --- a/src/lean_spec/forks/lstar/spec.py +++ b/src/lean_spec/forks/lstar/spec.py @@ -75,7 +75,7 @@ class LstarSpec(ForkProtocol): previous: ClassVar[type[ForkProtocol] | None] = None - # Capabilities (see lean_spec/forks/capabilities.py). + # Capabilities advertised by this fork. sig_scheme: ClassVar[GeneralizedXmssScheme] = TARGET_SIGNATURE_SCHEME state_class: type[State] = State @@ -799,7 +799,7 @@ def verify_signatures( - Each body attestation is signed by participating validators - The proposer signed the block root with the proposal key - The signing scheme is read from the SigScheme capability on this fork. + The signing scheme is read from this fork's capability. Args: signed_block: The signed block whose signatures are checked. @@ -1040,7 +1040,7 @@ def on_gossip_attestation( This method: - 1. Verifies the XMSS signature using the fork's SigScheme + 1. Verifies the XMSS signature using this fork's capability 2. Stores the signature when the node is in aggregator mode Subnet filtering happens at the p2p subscription layer — only @@ -1052,6 +1052,10 @@ def on_gossip_attestation( signed_attestation: The signed attestation to process. is_aggregator: True if the node is an aggregator. + Returns: + A new store with the attestation signature recorded when in + aggregator mode, otherwise the input store unchanged. + Raises: ValueError: If validator not found in state. AssertionError: If signature verification fails. @@ -1178,7 +1182,7 @@ def on_block( 3. Processing attestations included in the block body (on-chain) 4. Updating the forkchoice head - Signatures are verified using the fork's SigScheme capability. + Signatures are verified using this fork's capability. Raises: AssertionError: If parent block/state not found in store. diff --git a/tests/consensus/lstar/test_capability_gating.py b/tests/consensus/lstar/test_capability_gating.py index b7c9b69c2..849fafd84 100644 --- a/tests/consensus/lstar/test_capability_gating.py +++ b/tests/consensus/lstar/test_capability_gating.py @@ -1,37 +1,10 @@ -"""Smoke tests for the @requires(capability) marker dispatch. - -These exercise the marker through the live pytest collection and -parametrization plumbing (rather than the dispatch helper in isolation). - -Two tests live here: - -- One marked `@requires(SigScheme)` — Lstar satisfies SigScheme, so this - test runs and asserts a trivial state-transition fixture. -- One marked `@requires(_AbsentCapability)` — a Protocol no fork can - satisfy, so this test must be deselected by the framework. If it ever - executes, it fails loudly. - -The second test is the actual proof: a successful `uv run fill` with -this file present means the framework deselected it correctly. - -Marker placement note ---------------------- -Real consensus fillers pin themselves to a fork at the module level via -`pytestmark = pytest.mark.valid_until("Lstar")` (67 files do this). -That's the convention for new fillers. - -This file uses per-function decorators only because each test needs a -*different* capability — one requires `SigScheme`, the other requires -`_AbsentCapability`. A module-level `pytestmark` would apply the same -marker to both. The decorator form is the right tool when individual -tests within a module advertise different capability requirements. -""" +"""Smoke tests for the capability-requirement marker dispatch.""" from typing import ClassVar, Protocol, runtime_checkable import pytest from consensus_testing import StateExpectation, StateTransitionTestFiller, generate_pre_state -from framework import requires +from framework import requires_capability from lean_spec.forks import SigScheme from lean_spec.types import Slot @@ -41,16 +14,16 @@ @runtime_checkable class _AbsentCapability(Protocol): - """A capability no real fork advertises (its required attribute is bogus).""" + """A capability no real fork advertises.""" never_an_attribute_on_any_real_fork: ClassVar[object] -@requires(SigScheme) +@requires_capability(SigScheme) def test_runs_when_fork_advertises_sigscheme( state_transition_test: StateTransitionTestFiller, ) -> None: - """Lstar advertises SigScheme; this test must run.""" + """Lstar advertises the signature-scheme capability — this test runs.""" state_transition_test( pre=generate_pre_state(), blocks=[], @@ -58,11 +31,9 @@ def test_runs_when_fork_advertises_sigscheme( ) -@requires(_AbsentCapability) +@requires_capability(_AbsentCapability) def test_deselected_when_capability_absent( state_transition_test: StateTransitionTestFiller, ) -> None: - """No fork advertises _AbsentCapability; this test must be deselected.""" - raise AssertionError( - "test_deselected_when_capability_absent was executed — @requires deselection is broken." - ) + """No fork advertises the absent capability — this test must be deselected.""" + raise AssertionError("this test was executed — capability-requirement deselection is broken") diff --git a/tests/lean_spec/forks/test_capabilities.py b/tests/lean_spec/forks/test_capabilities.py index a71588aa0..d08274c67 100644 --- a/tests/lean_spec/forks/test_capabilities.py +++ b/tests/lean_spec/forks/test_capabilities.py @@ -1,10 +1,10 @@ -"""Tests for fork capability Protocols and the @requires marker dispatch.""" +"""Unit tests for fork capabilities and the requirement-marker dispatch.""" -from dataclasses import dataclass, field from typing import Any, ClassVar, Protocol, runtime_checkable import pytest from framework.forks import BaseFork +from framework.markers import requires_capability from framework.pytest_plugins.filler import _check_markers_valid_for_fork from lean_spec.forks import LstarSpec, SigScheme @@ -13,26 +13,8 @@ from lean_spec.types import ValidatorIndex -@dataclass(frozen=True) -class _Mark: - """Minimal stand-in for pytest's Mark object. - - The dispatch helper only reads `.name` and `.args`, so this is enough - to drive it without going through real pytest collection. - """ - - name: str - args: tuple[Any, ...] = () - kwargs: dict[str, Any] = field(default_factory=dict) - - class _NoSigSpec(ForkProtocol): - """Synthetic fork that does NOT advertise SigScheme. - - Implements just enough abstract surface to be instantiable. Used to - verify the @requires marker correctly deselects against forks lacking - a capability. - """ + """Synthetic fork without the signature-scheme capability.""" NAME: ClassVar[str] = "no_sig" VERSION: ClassVar[int] = LstarSpec.VERSION + 1 @@ -40,11 +22,11 @@ class _NoSigSpec(ForkProtocol): previous: ClassVar[type[ForkProtocol] | None] = LstarSpec def upgrade_state(self, state: SpecStateType) -> SpecStateType: - """Root-fork identity migration.""" + """Identity migration.""" return state def generate_genesis(self, genesis_time: Any, validators: Any) -> SpecStateType: - """Not exercised by capability dispatch.""" + """Not exercised.""" raise NotImplementedError def create_store( @@ -53,12 +35,12 @@ def create_store( anchor_block: SpecBlockType, validator_id: ValidatorIndex | None, ) -> Any: - """Not exercised by capability dispatch.""" + """Not exercised.""" raise NotImplementedError class _NoSigFork(BaseFork): - """BaseFork wrapper exposing _NoSigSpec through spec_class().""" + """Fork wrapper around the no-capability synthetic spec.""" @classmethod def name(cls) -> str: @@ -70,12 +52,8 @@ def spec_class(cls) -> type[_NoSigSpec]: class _LstarLikeFork(BaseFork): - """Local BaseFork wrapper exposing LstarSpec. - - Mirrors `consensus_testing.forks.Lstar` but kept local so capability - tests don't drag the entire consensus filler bootstrap into the - import graph. - """ + """Fork wrapper around the real Lstar spec, kept local to avoid pulling + the consensus filler bootstrap into the unit-test import graph.""" @classmethod def name(cls) -> str: @@ -87,100 +65,141 @@ def spec_class(cls) -> type[LstarSpec]: def _fork_by_name_table(*forks: type[BaseFork]) -> Any: - """Build a get_fork_by_name lookup over the given fork classes.""" + """Build a name lookup over the given forks.""" table = {fork.name(): fork for fork in forks} return table.get +def _mark(name: str, *args: Any) -> Any: + """Build a real pytest Mark via the public MarkDecorator path.""" + return getattr(pytest.mark, name)(*args).mark + + class TestSigSchemeCapability: - """SigScheme structurally identifies forks that expose an XMSS scheme.""" + """A fork advertises the signature-scheme capability by binding the attribute.""" def test_lstar_advertises_sigscheme(self) -> None: - """LstarSpec satisfies SigScheme at runtime.""" + """The real fork passes the structural check.""" assert isinstance(LstarSpec(), SigScheme) def test_lstar_sig_scheme_is_target_scheme(self) -> None: - """The bound scheme is the same TARGET_SIGNATURE_SCHEME singleton.""" + """The bound scheme is the same singleton resolved at import time.""" assert LstarSpec.sig_scheme is TARGET_SIGNATURE_SCHEME def test_fork_without_attribute_not_recognized(self) -> None: - """A fork without sig_scheme is structurally rejected.""" + """A fork lacking the attribute is structurally rejected.""" assert not isinstance(_NoSigSpec(), SigScheme) -class TestRequiresMarkerDispatch: - """`requires(capability)` composes with the fork-range markers.""" +class TestRequiresCapabilityHelper: + """The helper rejects non-runtime-checkable arguments at call time.""" + + def test_accepts_runtime_checkable_protocol(self) -> None: + """A runtime-checkable Protocol produces a usable marker.""" + decorator = requires_capability(SigScheme) + assert decorator.mark.name == "requires" + assert decorator.mark.args == (SigScheme,) + + def test_accepts_multiple_capabilities(self) -> None: + """Capabilities round-trip into marker args in the order given.""" + + @runtime_checkable + class _Other(Protocol): + other_attr: ClassVar[object] + + decorator = requires_capability(SigScheme, _Other) + assert decorator.mark.args == (SigScheme, _Other) + + def test_rejects_non_runtime_checkable_protocol(self) -> None: + """A plain Protocol is rejected at call time.""" + + class _NotRuntimeCheckable(Protocol): + sig_scheme: ClassVar[object] + + with pytest.raises(TypeError, match="runtime_checkable"): + requires_capability(_NotRuntimeCheckable) + + def test_rejects_plain_class(self) -> None: + """A non-Protocol class is rejected too.""" + + class _PlainClass: + sig_scheme: ClassVar[object] = object() + + with pytest.raises(TypeError, match="runtime_checkable"): + requires_capability(_PlainClass) + + +class TestMarkerDispatch: + """The capability marker AND-composes with the fork-range markers.""" def test_no_markers_passes(self) -> None: - """A test with no markers runs on any fork.""" - assert _check_markers_valid_for_fork([], _LstarLikeFork, _fork_by_name_table()) is True + """An unmarked test runs on any fork.""" + assert _check_markers_valid_for_fork([], _LstarLikeFork, _fork_by_name_table()) - def test_requires_passes_when_capability_present(self) -> None: - """LstarSpec advertises SigScheme — test is included.""" - markers = [_Mark("requires", (SigScheme,))] - assert _check_markers_valid_for_fork(markers, _LstarLikeFork, _fork_by_name_table()) is True + def test_capability_present_passes(self) -> None: + """Capability advertised → test included.""" + markers = [requires_capability(SigScheme).mark] + assert _check_markers_valid_for_fork(markers, _LstarLikeFork, _fork_by_name_table()) - def test_requires_fails_when_capability_absent(self) -> None: - """_NoSigSpec doesn't advertise SigScheme — test is deselected.""" - markers = [_Mark("requires", (SigScheme,))] - assert _check_markers_valid_for_fork(markers, _NoSigFork, _fork_by_name_table()) is False + def test_capability_absent_fails(self) -> None: + """Capability missing → test deselected.""" + markers = [requires_capability(SigScheme).mark] + assert not _check_markers_valid_for_fork(markers, _NoSigFork, _fork_by_name_table()) - def test_requires_composes_with_valid_until_and_passes(self) -> None: - """`valid_until` AND `requires` both pass — test is included.""" + def test_composes_with_valid_until_and_passes(self) -> None: + """Fork-range and capability both satisfied → test included.""" markers = [ - _Mark("valid_until", (_LstarLikeFork.name(),)), - _Mark("requires", (SigScheme,)), + _mark("valid_until", _LstarLikeFork.name()), + requires_capability(SigScheme).mark, ] - assert ( - _check_markers_valid_for_fork( - markers, _LstarLikeFork, _fork_by_name_table(_LstarLikeFork) - ) - is True + assert _check_markers_valid_for_fork( + markers, _LstarLikeFork, _fork_by_name_table(_LstarLikeFork) ) - def test_requires_composes_with_valid_until_and_fails_on_capability(self) -> None: - """`valid_until` passes but capability missing — deselected.""" + def test_composes_with_valid_until_and_fails_on_capability(self) -> None: + """Fork-range passes but capability missing → deselected.""" markers = [ - _Mark("valid_until", (_NoSigFork.name(),)), - _Mark("requires", (SigScheme,)), + _mark("valid_until", _NoSigFork.name()), + requires_capability(SigScheme).mark, ] - assert ( - _check_markers_valid_for_fork(markers, _NoSigFork, _fork_by_name_table(_NoSigFork)) - is False + assert not _check_markers_valid_for_fork( + markers, _NoSigFork, _fork_by_name_table(_NoSigFork) ) def test_valid_at_short_circuit_still_checks_capability(self) -> None: - """`valid_at` matches the fork name but capability missing — deselected.""" + """Exact-fork match still requires the capability.""" markers = [ - _Mark("valid_at", (_NoSigFork.name(),)), - _Mark("requires", (SigScheme,)), + _mark("valid_at", _NoSigFork.name()), + requires_capability(SigScheme).mark, ] - assert ( - _check_markers_valid_for_fork(markers, _NoSigFork, _fork_by_name_table(_NoSigFork)) - is False + assert not _check_markers_valid_for_fork( + markers, _NoSigFork, _fork_by_name_table(_NoSigFork) ) - def test_multiple_requires_markers_compose_with_and(self) -> None: - """Stacked @requires markers all checked — one failing fails the whole.""" + def test_multiple_capability_markers_compose_with_and(self) -> None: + """Stacked capability markers fail the whole if any one fails.""" @runtime_checkable class _Absent(Protocol): never_an_attribute_on_any_real_fork: ClassVar[object] markers = [ - _Mark("requires", (SigScheme,)), - _Mark("requires", (_Absent,)), + requires_capability(SigScheme).mark, + requires_capability(_Absent).mark, ] - assert ( - _check_markers_valid_for_fork(markers, _LstarLikeFork, _fork_by_name_table()) is False - ) + assert not _check_markers_valid_for_fork(markers, _LstarLikeFork, _fork_by_name_table()) + + def test_dispatcher_raises_on_non_runtime_checkable_protocol(self) -> None: + """The dispatcher's own guard rejects non-runtime-checkable Protocols. - def test_requires_with_non_runtime_checkable_protocol_raises(self) -> None: - """isinstance against a plain Protocol raises TypeError at check time.""" + Defense in depth: even if a marker is built without going through + the helper, the dispatch must not silently pass. + """ class _NotRuntimeCheckable(Protocol): sig_scheme: ClassVar[object] - markers = [_Mark("requires", (_NotRuntimeCheckable,))] + # Bypass the helper's validation to exercise the dispatcher's guard. + markers = [pytest.mark.requires.with_args(_NotRuntimeCheckable).mark] with pytest.raises(TypeError, match="runtime_checkable"): _check_markers_valid_for_fork(markers, _LstarLikeFork, _fork_by_name_table()) From 66ed62d441729cfb48b81f78856e097afbfaee7e Mon Sep 17 00:00:00 2001 From: Thomas Coratger <60488569+tcoratger@users.noreply.github.com> Date: Tue, 12 May 2026 00:59:10 +0200 Subject: [PATCH 3/3] test(forks): tighten dispatcher-guard test docstring Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/lean_spec/forks/test_capabilities.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/lean_spec/forks/test_capabilities.py b/tests/lean_spec/forks/test_capabilities.py index d08274c67..36f969be6 100644 --- a/tests/lean_spec/forks/test_capabilities.py +++ b/tests/lean_spec/forks/test_capabilities.py @@ -190,11 +190,7 @@ class _Absent(Protocol): assert not _check_markers_valid_for_fork(markers, _LstarLikeFork, _fork_by_name_table()) def test_dispatcher_raises_on_non_runtime_checkable_protocol(self) -> None: - """The dispatcher's own guard rejects non-runtime-checkable Protocols. - - Defense in depth: even if a marker is built without going through - the helper, the dispatch must not silently pass. - """ + """The dispatcher's own guard rejects non-runtime-checkable Protocols.""" class _NotRuntimeCheckable(Protocol): sig_scheme: ClassVar[object]