From 79cf7a17ef18adcb38d3d2e503c149c0413d5f66 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 11:09:05 -0400 Subject: [PATCH 1/2] Test that field resolution is one precedence order over (class, scope) sites Enumerates every subset of 3 config classes x 3 scopes for one field and checks: - a site most specific on both axes wins (true under either loop order); - removing losing sites changes neither the winner nor its reported source; - the two-site results form a strict total order whose first member wins every set; - stored value, attribute read, resolver answer and ObjectState's resolved value and source agree in every case and stay distinct. Crossed pairs (no site most specific on both axes) are the only cases that show which axis is the outer loop. Those tests are labelled as a characterisation of today's class-first behaviour, because the documentation states both orders. No production change. Co-Authored-By: Claude Opus 5.5 --- tests/test_resolution_precedence_order.py | 319 ++++++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 tests/test_resolution_precedence_order.py diff --git a/tests/test_resolution_precedence_order.py b/tests/test_resolution_precedence_order.py new file mode 100644 index 0000000..c54dc70 --- /dev/null +++ b/tests/test_resolution_precedence_order.py @@ -0,0 +1,319 @@ +"""Field resolution is one precedence order over (config class, scope) sites. + +A site is a config class holding a value for the field in the layer of one scope. +Three classes (``Leaf`` < ``Middle`` < ``Base``) and three scopes (step, plate, +global) give nine sites; every subset of them is resolved for ``Leaf.value``. + +Two requirements hold whatever the loop order of the resolver is: + +* a site that is most specific on both axes wins; +* the winner of a set of sites stays the winner, with the same reported source, + when losing sites are removed. A resolver with this property picks the first + member of one strict total order. + +Which axis is the outer loop only shows on a crossed pair: a value on a less +specific class in a more specific scope against a value on a more specific class in +a less specific scope, with no site most specific on both. The crossed tests here +are a CHARACTERISATION OF TODAY'S BEHAVIOUR (class first), not a statement of +intended policy: the documentation states both orders. See +``test_crossed_pair_is_won_by_the_more_specific_class``. +""" + +import functools +from dataclasses import dataclass, field +from itertools import combinations, product +from typing import NamedTuple + +import pytest + +from objectstate import ( + LazyDataclassFactory, + ObjectState, + ObjectStateRegistry, + build_context_stack, + mark_global_config_type, + set_base_config_type, + set_global_config_for_editing, +) +from objectstate.dual_axis_resolver import resolve_with_provenance + + +@dataclass +class Base: + value: str | None = None + + +@dataclass +class Middle(Base): + pass + + +@dataclass +class Leaf(Middle): + pass + + +@dataclass +class GlobalConfig: + base_config: Base = field(default_factory=Base) + middle_config: Middle = field(default_factory=Middle) + leaf_config: Leaf = field(default_factory=Leaf) + + +mark_global_config_type(GlobalConfig) +LazyBase = LazyDataclassFactory.make_lazy_simple(Base) +LazyMiddle = LazyDataclassFactory.make_lazy_simple(Middle) +LazyLeaf = LazyDataclassFactory.make_lazy_simple(Leaf) + + +@dataclass +class Holder: + """A pipeline or a step: one lazy config of each class.""" + + base_config: LazyBase = field(default_factory=LazyBase) + middle_config: LazyMiddle = field(default_factory=LazyMiddle) + leaf_config: LazyLeaf = field(default_factory=LazyLeaf) + + +GLOBAL, PLATE, STEP = "", "plate", "plate::step" +CLASSES = (Leaf, Middle, Base) # most specific first: the MRO of the type being read +SCOPES = (STEP, PLATE, GLOBAL) # most specific first: innermost layer first +FIELD_OF = {Base: "base_config", Middle: "middle_config", Leaf: "leaf_config"} +LAZY_OF = {Base: LazyBase, Middle: LazyMiddle, Leaf: LazyLeaf} +READ_PATH = "leaf_config.value" + +Site = tuple[type, str] +ALL_SITES: tuple[Site, ...] = tuple(product(CLASSES, SCOPES)) +ALL_SITE_SETS = tuple( + frozenset(chosen) + for size in range(len(ALL_SITES) + 1) + for chosen in combinations(ALL_SITES, size) +) +OWN_SITE: Site = (Leaf, STEP) + +# Every pair with no site most specific on both axes, less specific class first. +CROSSED_PAIRS = tuple( + ((CLASSES[upper_class], SCOPES[inner_scope]), (CLASSES[lower_class], SCOPES[outer_scope])) + for lower_class, upper_class in combinations(range(len(CLASSES)), 2) + for inner_scope, outer_scope in combinations(range(len(SCOPES)), 2) +) + + +def _value_at(site: Site) -> str: + config_class, scope = site + return f"{config_class.__name__}@{scope or 'global'}" + + +SITE_OF_VALUE = {_value_at(site): site for site in ALL_SITES} + + +def _objects(sites: frozenset[Site]) -> tuple[GlobalConfig, Holder, Holder]: + """The global config, the plate and the step holding exactly ``sites``.""" + + def configs(scope: str, make_config) -> dict: + return { + FIELD_OF[config_class]: make_config(config_class)(value=_value_at((config_class, scope))) + for config_class in CLASSES + if (config_class, scope) in sites + } + + return ( + GlobalConfig(**configs(GLOBAL, lambda config_class: config_class)), + Holder(**configs(PLATE, LAZY_OF.__getitem__)), + Holder(**configs(STEP, LAZY_OF.__getitem__)), + ) + + +class Reads(NamedTuple): + """Every kind of read of ``Leaf.value`` on the step, kept apart.""" + + stored: str | None # what the step's own config holds + attribute: str | None # plain attribute access on the step's config + resolver: tuple # (value, source scope, source type) from the one resolver + saved_value: str | None # ObjectState.resolve_saved_object + saved_source: tuple | None # its provenance; absent when the step holds the value + + +@functools.cache +def _reads(sites: frozenset[Site]) -> Reads: + saved_global, plate, step = _objects(sites) + set_base_config_type(GlobalConfig) + set_global_config_for_editing(GlobalConfig, saved_global) + ancestors = [(PLATE, plate)] + + with build_context_stack(step, ancestors, STEP, use_live=False): + stored = object.__getattribute__(step.leaf_config, "value") + attribute = step.leaf_config.value + resolver = tuple(resolve_with_provenance(LazyLeaf, "value")) + + resolved_step, provenance = ObjectState.resolve_saved_object( + step, scope_id=STEP, ancestor_objects_with_scopes=ancestors + ) + return Reads( + stored, attribute, resolver, resolved_step.leaf_config.value, provenance.get(READ_PATH) + ) + + +def _winner(sites: frozenset[Site]) -> Site | None: + """The site the resolver reports as the source, or ``None`` when no site wins.""" + value, source_scope, source_type = _reads(sites).resolver + if value is None: + return None + assert SITE_OF_VALUE[value] == (source_type, source_scope) + return source_type, source_scope + + +def test_every_read_kind_agrees_with_the_one_resolver_for_every_set_of_sites(): + for sites in ALL_SITE_SETS: + reads = _reads(sites) + winner = _winner(sites) + expected_value = _value_at(winner) if winner else None + + assert (winner is None) == (not sites), sites + assert winner is None or winner in sites, sites + assert reads.attribute == reads.saved_value == expected_value, sites + if OWN_SITE in sites: + # The step holds the value itself: nothing is inherited, so no source. + assert reads.stored == _value_at(OWN_SITE), sites + assert reads.saved_source is None, sites + else: + assert reads.stored is None, sites + assert reads.saved_source == reads.resolver[1:], sites + + +def test_site_most_specific_on_both_axes_wins(): + """Both loop orders pick this site, so this is no policy choice.""" + checked = 0 + for sites in ALL_SITE_SETS: + if not sites: + continue + joint_minimum = ( + min((config_class for config_class, _ in sites), key=CLASSES.index), + min((scope for _, scope in sites), key=SCOPES.index), + ) + if joint_minimum in sites: + checked += 1 + assert _winner(sites) == joint_minimum, sites + assert checked > len(ALL_SITES) + + +def test_removing_losing_sites_changes_neither_the_winner_nor_its_source(): + """The winner does not depend on which rejected candidates are present.""" + for sites in ALL_SITE_SETS: + if not sites: + continue + reads = _reads(sites) + winner = _winner(sites) + losers = sorted(sites - {winner}, key=ALL_SITES.index) + for kept_count in range(len(losers)): + for kept in combinations(losers, kept_count): + fewer = _reads(frozenset(kept) | {winner}) + assert fewer.resolver == reads.resolver, (sites, kept) + assert fewer.attribute == reads.attribute, (sites, kept) + assert fewer.saved_value == reads.saved_value, (sites, kept) + assert fewer.saved_source == reads.saved_source, (sites, kept) + + +def test_winner_is_the_first_site_of_one_strict_total_order(): + """The order read off the two-site sets alone predicts the winner of every set.""" + beaten_by = { + site: { + other + for other in ALL_SITES + if other != site and _winner(frozenset({site, other})) == site + } + for site in ALL_SITES + } + # Total and antisymmetric: of two sites exactly one beats the other. + for first, second in combinations(ALL_SITES, 2): + assert (second in beaten_by[first]) != (first in beaten_by[second]) + # Transitive, hence a strict total order: the sites beat 8, 7, ..., 0 others. + order = sorted(ALL_SITES, key=lambda site: len(beaten_by[site]), reverse=True) + assert [len(beaten_by[site]) for site in order] == list(reversed(range(len(ALL_SITES)))) + + for sites in ALL_SITE_SETS: + if sites: + assert _winner(sites) == min(sites, key=order.index), sites + + +@pytest.mark.parametrize( + ("inner_scope_site", "specific_class_site"), + CROSSED_PAIRS, + ids=[f"{_value_at(a)} vs {_value_at(b)}" for a, b in CROSSED_PAIRS], +) +def test_crossed_pair_is_won_by_the_more_specific_class(inner_scope_site, specific_class_site): + """CHARACTERISATION of today's behaviour, not a statement of intended policy. + + ``_resolve_in_layers`` walks the MRO outside and the layers inside, so a value on + ``Leaf`` in the global config beats a value on ``Base`` set on the plate. Its + docstring and ``docs/context_system.rst`` describe that order; + ``docs/configuration_framework.rst`` describes the opposite one ("check + ``AppConfig`` context first, then ``GlobalAppConfig`` context, walking MRO at each + level"). Until the owner settles which is intended, this test only records what + the resolver does. If the scope is meant to be the outer loop, flip the expected + winner here and in ``test_all_sites_are_ordered_class_first_then_scope``. + """ + assert inner_scope_site[0] is not specific_class_site[0] + assert inner_scope_site[1] != specific_class_site[1] + sites = frozenset({inner_scope_site, specific_class_site}) + reads = _reads(sites) + + assert _winner(sites) == specific_class_site + assert reads.attribute == reads.saved_value == _value_at(specific_class_site) + # Each site wins when it stands alone, so the pair is a real contest. + assert _winner(frozenset({inner_scope_site})) == inner_scope_site + assert _winner(frozenset({specific_class_site})) == specific_class_site + + +def test_all_sites_are_ordered_class_first_then_scope(): + """CHARACTERISATION: the one order is the product order with the class outer.""" + for sites in ALL_SITE_SETS: + if sites: + assert _winner(sites) == min(sites, key=ALL_SITES.index), sites + + +def test_read_kinds_of_an_editing_state_stay_distinct_and_agree_on_a_crossed_pair(): + """Stored value, resolved value and source of a registered ``ObjectState``. + + The stored value stays ``None``; the resolved value and the source are the + resolver's answer for the same crossed pair. Which site that is, is the + characterised class-first order (see the crossed-pair test above). + """ + crossed = frozenset({(Base, PLATE), (Leaf, GLOBAL)}) + plate_only = frozenset({(Base, PLATE)}) + + def editing_state_reads(sites: frozenset[Site]) -> tuple: + saved_global, plate, step = _objects(sites) + set_base_config_type(GlobalConfig) + set_global_config_for_editing(GlobalConfig, saved_global) + states = [] + try: + # An ancestor is registered before the state below it is built. + for scope_id, edited in ((PLATE, plate), (STEP, step)): + states.append(ObjectState(edited, scope_id=scope_id)) + ObjectStateRegistry.register(states[-1], _skip_snapshot=True) + step_state = states[-1] + return ( + step_state.parameters[READ_PATH], + step_state.get_resolved_value(READ_PATH), + step_state.get_saved_resolved_value(READ_PATH), + step_state.get_provenance(READ_PATH), + object.__getattribute__(step_state.to_object().leaf_config, "value"), + ) + finally: + for state in reversed(states): + ObjectStateRegistry.unregister(state, _skip_snapshot=True) + + for sites in (crossed, plate_only): + stored, live_value, saved_value, source, rebuilt_stored = editing_state_reads(sites) + resolver_value, *resolver_source = _reads(sites).resolver + + assert stored is None + assert rebuilt_stored is None # resolving does not write the value back + assert live_value == saved_value == resolver_value + assert source == tuple(resolver_source) + + # The plate's value reaches the step when it stands alone; the global Leaf value + # displaces it in the crossed pair. + assert _winner(plate_only) == (Base, PLATE) + assert _winner(crossed) == (Leaf, GLOBAL) From b64aa4df8f4549c4f070c8fa889b17ab23989805 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 11:15:02 -0400 Subject: [PATCH 2/2] Wrap one line over the 100-column limit Co-Authored-By: Claude Opus 5.5 --- tests/test_resolution_precedence_order.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_resolution_precedence_order.py b/tests/test_resolution_precedence_order.py index c54dc70..901fc0b 100644 --- a/tests/test_resolution_precedence_order.py +++ b/tests/test_resolution_precedence_order.py @@ -112,7 +112,9 @@ def _objects(sites: frozenset[Site]) -> tuple[GlobalConfig, Holder, Holder]: def configs(scope: str, make_config) -> dict: return { - FIELD_OF[config_class]: make_config(config_class)(value=_value_at((config_class, scope))) + FIELD_OF[config_class]: make_config(config_class)( + value=_value_at((config_class, scope)) + ) for config_class in CLASSES if (config_class, scope) in sites }