diff --git a/docs/architecture.rst b/docs/architecture.rst index a70ef0d..36cabf5 100644 --- a/docs/architecture.rst +++ b/docs/architecture.rst @@ -527,6 +527,9 @@ Sets the **instance** (concrete values) for resolution: * Required for lazy resolution to work * Internally calls ``set_global_config_for_editing()`` * Should be called at application startup (GUI) or before pipeline execution +* Not needed after an edit or a save made through the ``ObjectState`` of the global + config: that state stores the live and the saved instance itself + (see :doc:`state_propagation`) **Key Differences:** diff --git a/docs/provenance.rst b/docs/provenance.rst index cbad688..9007f87 100644 --- a/docs/provenance.rst +++ b/docs/provenance.rst @@ -119,7 +119,9 @@ A ``ResolvedField`` named tuple: * ``value``: The resolved field value (may be ``None``) * ``source_scope``: The scope id of the layer that supplied the value. ``None`` for a layer - entered with ``config_context(obj)`` without a ``scope_id``. + entered with ``config_context(obj)`` without a ``scope_id``. ``ObjectState.get_provenance`` + never reports such a layer: a state resolves against the global config and its registered + ancestors only (:ref:`state-resolution-inputs`). * ``source_type``: The type the value was read from: the container type or one of its bases. When no layer holds a value, ``value`` is the declared default and the source names the diff --git a/docs/state_propagation.rst b/docs/state_propagation.rst index 72c2675..a1422df 100644 --- a/docs/state_propagation.rst +++ b/docs/state_propagation.rst @@ -95,17 +95,35 @@ Use parent forwarding when all of the following are true: For a nested dataclass field in the same state, call ``update_parameter()`` on the dotted path instead. +.. _state-resolution-inputs: + What a resolved value is computed from -------------------------------------- -A resolved value of a state is computed from three things: which states are -registered at the scopes above it, what each of them stores for the field's config -type and field name, and what the global config stores. Each resolved snapshot (live +A resolved value of a state depends only on the state's place in the registered +hierarchy. It is computed from three things: which states are registered at the +scopes above it, what each of them stores for the field's config type and field name, +and what the global config stores. Nothing else is an input. In particular a +``config_context`` that the caller has entered is not: a state built, read or resolved +again inside such a block has the values, saved values, reported sources, dirty +fields and notifications it has outside it, during the block and after it. The state +resolves against a layer stack of its own (``build_context_stack``) that starts from +the global config and holds the registered ancestors and the state's object; the +caller's context is set aside while it does and is active again afterwards. So the +reported source of a resolved value is always a scope where a state is registered, or +``""`` for the global config. + +This is the only statement of the rule. A lazy config object that no state owns is a +different route: plain attribute access on it resolves against whatever +``config_context`` is entered (see ``context_system.rst`` in the package docs), which +is how a compiler resolves a pipeline's objects. + +Each resolved snapshot (live and saved) keeps the objects it was computed from (``ResolutionSources``): the global config instance of the thread that resolved it and the object of every state registered above. Every resolved read (``get_resolved_value``, ``get_saved_resolved_value``, ``get_provenance``, ``to_resolved_object``, -``state.fields``) first compares those objects with the ones the state would be given +``state.fields``, ``dirty_fields``) first compares those objects with the ones the state would be given now. When they are the same objects the read answers from the snapshot. When one is another object, the two are compared field by field, the way field resolution reads a context layer, and only the fields of the state that are unset and can inherit a @@ -129,7 +147,23 @@ Limits: - The global config is stored per thread. A snapshot follows the global config of the thread that resolved it; a read from another thread answers from the snapshot and does not resolve it again for that thread's global config. -- A state built or resolved inside a ``config_context`` the caller entered takes that - context's layers into its snapshot and keeps them after the block - (`issue #53 `_). + +The state of the global config +------------------------------ + +The global config is stored per thread, once as edited (live) and once as saved. A +state whose object is a global config writes that store when its own object changes: +``update_parameter`` applies the edit to the live global config, +``mark_saved`` stores the saved object as the saved global config, ``restore_saved`` +stores the saved object as the live one again, ``update_object_instance`` and time +travel store both. Each is stored before any descendant resolves again, so saving the +global state is one call: its registered descendants resolve their saved values +against the object just saved and are told at once. + +``set_live_global_config``, ``set_saved_global_config`` and +``ensure_global_config_context`` (both at once) store a global config where no state +does it: at startup before a state exists, and in a thread or process that resolves +plain lazy objects without a registry, such as a compile worker. Calling one with the +values the global state has just stored, before or after ``mark_saved``, changes +nothing. A setter tells no state; states find the replaced object on their next read. diff --git a/src/objectstate/__init__.py b/src/objectstate/__init__.py index 41c14ab..fd97d16 100644 --- a/src/objectstate/__init__.py +++ b/src/objectstate/__init__.py @@ -210,7 +210,7 @@ 'ObjectStateEditSession', ] -__version__ = "3.0.3" +__version__ = "3.0.4" __author__ = 'OpenHCS Team' __description__ = 'Generic configuration framework for lazy dataclass resolution' diff --git a/src/objectstate/context_manager.py b/src/objectstate/context_manager.py index 4d1ee4b..a775af2 100644 --- a/src/objectstate/context_manager.py +++ b/src/objectstate/context_manager.py @@ -585,13 +585,20 @@ def build_context_stack( use_live: bool = True, ): """ - Enter every context layer needed to resolve ``object_instance``; leave them on exit. + Enter the complete layer stack that resolves ``object_instance``; leave it on exit. + + The stack is built from the arguments and the global config alone. A context the + caller has entered is set aside for the duration of the block and is active again + after it: none of its layers is below, between or above the layers entered here. + This is how an ``ObjectState`` resolves, so a state's resolved values depend on + its object, the states registered above it and the global config, and never on + where the read was made. A lazy object that no state owns is resolved by plain + attribute access against whatever ``config_context`` is entered. Layers, outermost first: - 1. The global config, under scope ``""``. ``config_context`` puts it below the - first layer entered while no context is active. When ``object_instance`` is - itself a global config, a fresh instance of its type holding the declared - defaults is entered above it, also under scope ``""``, and masks it. + 1. The global config, under scope ``""``. When ``object_instance`` is itself a + global config, a fresh instance of its type holding the declared defaults is + entered above it, also under scope ``""``, and masks it. 2. Each ancestor, under its own scope id. Global-config ancestors are left out because layer 1 already stands for them. 3. ``object_instance``, under ``current_scope_id``. @@ -613,6 +620,14 @@ def enter(stack: ExitStack, obj: object, scope_id: str | None, mask: bool = Fals ) with ExitStack() as stack: + # With no context active, the first layer entered starts from the global config. + for variable, no_context in ( + (current_temp_global, None), + (context_type_stack, ()), + (context_layer_stack, NO_LAYERS), + ): + stack.callback(variable.reset, variable.set(no_context)) + if isinstance(object_instance, GlobalConfigBase): enter(stack, type(object_instance)(), "", mask=True) diff --git a/src/objectstate/docs/context_system.rst b/src/objectstate/docs/context_system.rst index 2812ec8..a6d9767 100644 --- a/src/objectstate/docs/context_system.rst +++ b/src/objectstate/docs/context_system.rst @@ -173,16 +173,13 @@ Global config uses thread-local storage for stability: _global_config_storage: Dict[Type, threading.local] = {} def ensure_global_config_context(config_type: Type, config_instance: Any): - """Establish global config as base context.""" - # Store in thread-local + """Store the instance as both the live and the saved global config of this thread.""" set_global_config_for_editing(config_type, config_instance) - - # Also inject into contextvars base - with config_context(config_instance): - # Global context now available - pass -This hybrid approach uses thread-local for the global base and contextvars for dynamic stacking. +The global config is the base in thread-local storage; contextvars hold the layers +stacked on it. When a registered ``ObjectState`` holds the global config, that state +writes this storage on edit, save and restore (``docs/state_propagation.rst``, "The +state of the global config"). Resolution Integration --------------------- @@ -268,8 +265,13 @@ No manual cleanup needed - Python's context manager protocol handles it. Framework-Agnostic Context Stack Building ----------------------------------------- -``build_context_stack()`` is a context manager that enters every layer needed to -resolve one object, and leaves them again when the block ends: +``build_context_stack()`` is a context manager that enters the complete layer stack +that resolves one object, and leaves it again when the block ends. It is how an +``ObjectState`` resolves. The stack is built from the arguments and the global config +alone: a context the caller has entered is set aside for the duration of the block and +is active again after it. ``docs/state_propagation.rst`` ("What a resolved value is +computed from") states the rule this implements; plain attribute access on a lazy +object, everywhere else on this page, reads the entered context. .. code-block:: python @@ -292,5 +294,6 @@ Layers are entered outermost first: Every layer carries a scope id, so ``resolve_with_provenance()`` can name the scope that supplied a value. The global layer is the one ``config_context()`` puts below the -first layer entered while no context is active. Nothing is set until the ``with`` block is entered. A layer -that cannot be built raises; the block never runs with a layer missing. +first layer entered while no context is active, which is always the case inside this +block. Nothing is set until the ``with`` block is entered. A layer that cannot be built +raises; the block never runs with a layer missing. diff --git a/src/objectstate/object_state.py b/src/objectstate/object_state.py index b39110e..384a61f 100644 --- a/src/objectstate/object_state.py +++ b/src/objectstate/object_state.py @@ -2049,7 +2049,8 @@ def reconstruct_top_level_parameters(self) -> dict[str, Any]: @property def dirty_fields(self) -> set[str]: - """Fields where resolved_live != resolved_saved.""" + """Fields whose live and saved resolved values differ, among today's sources.""" + self._ensure_live_resolved() return self._dirty_fields @property @@ -2309,7 +2310,8 @@ def _resolve_parameter_snapshot( def mark_saved(self) -> None: """Mark current state as saved baseline. - UNIFIED: Works for any object_instance type. + UNIFIED: Works for any object_instance type. For the state of a global + config, the saved object also becomes the saved global config. CRITICAL: Invalidates descendant caches for any parameters that changed. This ensures that when saving, other windows that inherited from the @@ -2377,6 +2379,7 @@ def mark_saved(self) -> None: # Update saved parameters (after object_instance update, before invalidation) self._saved_parameters = self._copy_parameters_for_saved_baseline(self.parameters) + self._store_saved_object_as_saved_global_config() # NOW invalidate descendant caches AFTER object_instance is updated # This ensures descendants see the NEW object_instance when they recompute @@ -2478,6 +2481,23 @@ def restore_saved(self, *, propagate_descendants: bool = True) -> None: self._restore_saved_impl(propagate_descendants=propagate_descendants) return + def _store_saved_object_as_saved_global_config(self) -> None: + """Saving the state of a global config makes its saved object the saved global config. + + Stored before any descendant resolves its saved values again, so they resolve + against the object that was just saved. An application that also calls + ``set_saved_global_config`` with the same values, before or after + ``mark_saved``, changes nothing. + """ + obj_type = type(self._extraction_target) + from objectstate.lazy_factory import is_global_config_type + if not is_global_config_type(obj_type): + return + + from objectstate.global_config import set_saved_global_config + + set_saved_global_config(obj_type, self._extraction_target) + def _restore_live_global_context_from_saved(self) -> None: """Keep global live thread-local aligned after cancelling edits.""" obj_type = type(self.object_instance) diff --git a/tests/test_resolved_values_follow_registered_ancestors.py b/tests/test_resolved_values_follow_registered_ancestors.py index 8559738..546883f 100644 --- a/tests/test_resolved_values_follow_registered_ancestors.py +++ b/tests/test_resolved_values_follow_registered_ancestors.py @@ -16,14 +16,20 @@ registered with unsaved edits; an ancestor's value edited, saved, restored, undone and redone, one and two levels up, with the descendant registered or not; the global config replaced through its setters; the descendant's ``scope_id`` moved -under another ancestor; a context entered after the build. Only the fields a change -can reach are resolved again. The case at the end of the file is known to fail and -says why. +under another ancestor. Only the fields a change can reach are resolved again. + +A context the caller has entered is not one of the sources: the second half of the +file runs one sequence of builds, edits, saves and replacements outside any context +and inside several kinds of entered context, and requires the same reads and the +same notifications. A lazy object that no state owns does resolve against the +entered context. The last section saves the state of the global config and stores +the saved global config in both orders. """ import gc import threading import weakref +from contextlib import ExitStack, nullcontext from dataclasses import dataclass, field from typing import NamedTuple @@ -36,13 +42,19 @@ build_context_stack, config_context, dual_axis_resolver, + get_live_global_config, + get_saved_global_config, mark_global_config_type, + resolve_with_provenance, set_base_config_type, set_global_config_for_editing, set_live_global_config, set_saved_global_config, + spawn_thread_with_context, ) +from objectstate.context_manager import get_context_layer_stack + @dataclass class Settings: @@ -297,20 +309,6 @@ def test_scope_moved_under_another_ancestor(step_and_plate): assert _reads(step) == _inherited("plate", PLATE) -def test_context_entered_after_the_build_changes_no_read(step_and_plate): - step, _ = step_and_plate - expected = _inherited("plate", PLATE) - assert _reads(step) == expected - - with config_context(Holder(settings=LazySettings(value="ambient")), scope_id="ambient"): - inside = _reads(step) - - # ``uncached`` is resolved inside the entered context on purpose; see the last - # test in this file for what that shows. - assert inside._replace(uncached="plate") == expected - assert _reads(step) == expected - - # --- Sources other than registration -------------------------------------------- # A resolved snapshot is computed from the global config and from the objects of # the states registered above the state. The cases below change one of those @@ -565,10 +563,7 @@ def test_global_state_edit_reaches_states_two_levels_down(step_under_plate): global_state.update_parameter(PATH, "typed") assert _reads(step) == _inherited("typed", GLOBAL, saved="global") - # Saving the global state does not store the saved global config; the - # application does, with the setter. global_state.mark_saved() - set_saved_global_config(GlobalConfig, global_state.to_object()) assert _reads(step) == _inherited("typed", GLOBAL) @@ -614,17 +609,363 @@ def read_with_its_own_global_config(): assert step.get_resolved_value(PATH) == "from a thread" -# --- Known to fail -------------------------------------------------------------- -# Open in issue #53: an owner decision on whether a state's values may depend on a -# context the caller has entered. +# --- A context the caller has entered is not a source --------------------------- +# A state resolves against the global config, the states registered above it and its +# own object. ``config_context`` blocks entered around a build, a read or a change +# supply nothing, whatever they hold and whatever scope they claim. + +OTHER_PATH = "settings.other" +AMBIENT = "ambient" # a scope where no state is registered + + +def _ambient(value: str = "ambient", **entered): + return config_context( + Holder(settings=LazySettings(value=value, other=f"{value} other")), **entered + ) + + +def _nested(): + contexts = ExitStack() + contexts.enter_context(_ambient("outer", scope_id="outer")) + contexts.enter_context(_ambient("inner", scope_id="inner")) + return contexts -OPEN = "open, see https://github.com/OpenHCSDev/ObjectState/issues/53: " + +def _ambient_global_config(): + return config_context( + GlobalConfig(settings=Settings(value="ambient", other="ambient other")), scope_id=GLOBAL + ) + + +ENTERED = { + "one context": lambda: _ambient(scope_id=AMBIENT), + "without a scope id": _ambient, + "under the scope of a registered ancestor": lambda: _ambient(scope_id=PLATE), + "under the scope of the state itself": lambda: _ambient(scope_id=STEP), + "under the global scope": lambda: _ambient(scope_id=GLOBAL), + "a global config": _ambient_global_config, + "masking the layers outside it": lambda: _ambient(scope_id=AMBIENT, mask_with_none=True), + "started from the saved global config": lambda: _ambient( + scope_id=AMBIENT, use_live_global=False + ), + "nested": _nested, +} + + +def _start_over() -> None: + ObjectStateRegistry.clear() + set_base_config_type(GlobalConfig) + set_global_config_for_editing(GlobalConfig, GlobalConfig(settings=Settings(value="global"))) + + +def _history(entered) -> list: + """Every read and notification of three steps while their sources change inside ``entered()``. + + One step is built and registered before the block, one is built inside it and + never registered, one is built inside it and first read after it. + """ + plate = ObjectState(Holder(settings=LazySettings(other="plate other")), scope_id=PLATE) + ObjectStateRegistry.register(plate) + states: dict[str, ObjectState] = {} + told: list[tuple] = [] + history: list[tuple] = [] + + def follow(name: str, state: ObjectState) -> ObjectState: + states[name] = state + state.on_resolved_changed(lambda paths: told.append((name, "resolved", frozenset(paths)))) + state.on_state_changed(lambda paths: told.append((name, "state", frozenset(paths)))) + return state + + def record(event: str) -> None: + registered = {state.scope_id for state in ObjectStateRegistry.get_all()} | {GLOBAL} + seen = {} + for name, state in states.items(): + # ``dirty_fields`` first: it is a read like the others and must not need one. + dirty = frozenset(state.dirty_fields) + other = ( + state.get_resolved_value(OTHER_PATH), + state.get_saved_resolved_value(OTHER_PATH), + state.get_provenance(OTHER_PATH), + ) + seen[name] = (dirty, _reads(state), other) + for path in (PATH, OTHER_PATH): + source = state.get_provenance(path) + assert source is None or source[0] in registered, (event, name, path, source) + history.append((event, seen, tuple(told))) + + before = follow("built before", _step_state()) + ObjectStateRegistry.register(before) + record("before the block") + + with entered(): + follow("built inside", _step_state_at(f"{PLATE}::inside")) + late = _step_state_at(f"{PLATE}::late") + record("built") + + _replace_global("replaced") + record("global config replaced") + + plate.update_parameter(PATH, "edited") + record("ancestor edited") + + plate.mark_saved() + record("ancestor saved") + + plate.update_parameter(OTHER_PATH, None) + record("ancestor gives a field up") + + _replace_global("saved too", saved=True) + record("saved global config replaced") + + before.invalidate_cache() + record("every field resolved again") + + with _ambient("deeper", scope_id="deeper"): + plate.update_parameter(PATH, "edited deeper") + record("ancestor edited one block further in") + + ObjectStateRegistry.unregister(plate) + record("ancestor unregistered") + + ObjectStateRegistry.register(plate) + record("ancestor registered again") + + before.update_parameter(PATH, "own") + record("own value set") + + before.update_parameter(PATH, None) + before.mark_saved() + record("own value given up and saved") + + plate.restore_saved() + record("ancestor restored") + + # Last: editing the state of the global config leaves the entered context. + global_state = ObjectState(get_live_global_config(GlobalConfig), scope_id=GLOBAL) + ObjectStateRegistry.register(global_state) + plate.update_parameter(PATH, None) + plate.mark_saved() + global_state.update_parameter(PATH, "typed") + record("global state edited") + + global_state.mark_saved() + record("global state saved") + + follow("first read after the block", late) + record("after the block") + return history + + +def test_the_changes_of_the_history_are_seen_outside_any_context(): + """The sequence the next test repeats inside contexts does change what is read.""" + history = {event: seen for event, seen, _ in _history(nullcontext)} + + def value_reads(event: str) -> set: + return {reads for _, reads, _ in history[event].values()} + + assert value_reads("built") == {FROM_GLOBAL} + assert value_reads("global config replaced") == { + _inherited("replaced", GLOBAL, saved="global") + } + assert value_reads("ancestor edited") == {_inherited("edited", PLATE, saved="global")} + assert value_reads("ancestor saved") == {_inherited("edited", PLATE)} + assert {other for _, _, other in history["ancestor saved"].values()} == { + ("plate other", "plate other", (PLATE, Settings)) + } + assert {other for _, _, other in history["ancestor gives a field up"].values()} == { + (None, "plate other", (GLOBAL, Settings)) + } + assert value_reads("ancestor unregistered") == {_inherited("saved too", GLOBAL)} + assert value_reads("global state edited") == {_inherited("typed", GLOBAL, saved="saved too")} + assert value_reads("after the block") == {_inherited("typed", GLOBAL)} + + +@pytest.mark.parametrize("entered", ENTERED) +def test_reads_and_notifications_inside_an_entered_context_are_those_outside(entered): + outside = _history(nullcontext) + _start_over() + + assert _history(ENTERED[entered]) == outside -@pytest.mark.xfail(strict=True, reason=OPEN + "a state resolved inside a context keeps its values") def test_resolving_inside_an_entered_context_does_not_take_its_values(): - entered = Holder(settings=LazySettings(value="ambient")) - with config_context(entered, scope_id="ambient"): + with _ambient(scope_id=AMBIENT): step = _step_state() + assert _reads(step) == FROM_GLOBAL assert _reads(step) == FROM_GLOBAL + + +def test_layers_a_state_resolves_against_start_from_the_global_config(step_under_plate): + step, plate = step_under_plate + ancestors = ObjectStateRegistry.get_ancestor_objects_with_scopes(step.scope_id) + + with _nested(): + entered = get_context_layer_stack() + with build_context_stack(step.to_object(), ancestors, step.scope_id): + layers = get_context_layer_stack().layers + assert [(layer.scope_id, layer.obj) for layer in layers] == [ + (GLOBAL, get_live_global_config(GlobalConfig)), + *ancestors, + (STEP, step.to_object()), + ] + assert ancestors[0] == (PLATE, plate.to_object()) + assert get_context_layer_stack() is entered + + with pytest.raises(ZeroDivisionError): + with build_context_stack(step.to_object(), ancestors, step.scope_id): + 1 / 0 + assert get_context_layer_stack() is entered + + +def test_lazy_object_no_state_owns_resolves_against_the_entered_context(step_under_plate): + """Plain attribute access reads the entered context, before and after a state resolves in it.""" + step, plate = step_under_plate + plain = LazySettings() + + with _ambient(scope_id=AMBIENT): + entered = get_context_layer_stack() + + def reads_the_entered_context() -> None: + assert (plain.value, Holder().settings.other) == ("ambient", "ambient other") + assert tuple(resolve_with_provenance(LazySettings, "value")) == ( + "ambient", + AMBIENT, + Settings, + ) + assert get_context_layer_stack() is entered + + reads_the_entered_context() + + built_inside = _step_state_at(f"{PLATE}::inside") + assert _reads(built_inside) == _inherited("plate", PLATE) + reads_the_entered_context() + + plate.update_parameter(PATH, "edited") + assert step.get_resolved_value(PATH) == "edited" + reads_the_entered_context() + + step.invalidate_cache() + _replace_global("replaced") + assert step.get_resolved_value(PATH) == "edited" + reads_the_entered_context() + + with _ambient("inner", scope_id="inner"): + assert built_inside.get_provenance(PATH) == (PLATE, Settings) + assert plain.value == "inner" + reads_the_entered_context() + + +def test_context_entered_in_another_thread_is_not_a_source(): + entered, read = threading.Event(), threading.Event() + + def hold_a_context() -> None: + with _ambient(scope_id=AMBIENT): + entered.set() + read.wait(timeout=10) + + thread = threading.Thread(target=hold_a_context) + thread.start() + try: + assert entered.wait(timeout=10) + assert _reads(_step_state()) == FROM_GLOBAL + finally: + read.set() + thread.join() + + +def test_thread_started_with_the_entered_context_reads_states_as_outside(): + """``spawn_thread_with_context`` copies the entered context into the thread.""" + step = _step_state() + configs = get_live_global_config(GlobalConfig), get_saved_global_config(GlobalConfig) + seen: list = [] + + def read() -> None: + set_live_global_config(GlobalConfig, configs[0]) # stored per thread + set_saved_global_config(GlobalConfig, configs[1]) + seen.extend([LazySettings().value, _reads(step), _reads(_step_state())]) + + with _ambient(scope_id=AMBIENT): + spawn_thread_with_context(read).join() + + assert seen == ["ambient", FROM_GLOBAL, FROM_GLOBAL] + + +# --- Saving the state of the global config -------------------------------------- +# Saving that state makes its saved object the saved global config. An application +# that also stores it with the setter, before or after, ends in the same place. + +SAVE_ORDERS = { + "save the state": ("save",), + "save the state, then store": ("save", "store"), + "store, then save the state": ("store", "save"), +} + + +def _save_a_global_edit(order: tuple[str, ...], *, registered: bool): + """Edit ``value`` in the global state, save in ``order``; what its descendants then read.""" + global_state = ObjectState(get_live_global_config(GlobalConfig), scope_id=GLOBAL) + ObjectStateRegistry.register(global_state) + descendants = [ObjectState(Holder(), scope_id=PLATE), _step_state()] + told: list[tuple] = [] + for state in descendants: + if registered: + ObjectStateRegistry.register(state) + state.on_state_changed( + lambda paths, state=state: told.append( + (state.scope_id, frozenset(paths), frozenset(state.dirty_fields)) + ) + ) + + global_state.update_parameter(PATH, "typed") + assert get_live_global_config(GlobalConfig).settings.value == "typed" + assert get_saved_global_config(GlobalConfig).settings.value == "global" + edited = global_state.to_object() + + for operation in order: + if operation == "save": + global_state.mark_saved() + else: + set_saved_global_config(GlobalConfig, edited) + + told_dirty = {scope: dirty for scope, _, dirty in told} # the last each was told + dirty = [set(state.dirty_fields) for state in (global_state, *descendants)] + reads = [_reads(state) for state in descendants] + return ( + dirty, + reads, + told_dirty, + tuple(told), + get_saved_global_config(GlobalConfig).settings.value, + ) + + +@pytest.mark.parametrize("registered", [True, False], ids=["registered", "not registered"]) +def test_saving_the_global_state_in_any_order_leaves_nothing_dirty(registered): + results = {} + for name, order in SAVE_ORDERS.items(): + _start_over() + results[name] = _save_a_global_edit(order, registered=registered) + + saved = _inherited("typed", GLOBAL) + # A state that is not registered is told on its next read, and by then it is + # as clean as it was before the edit. + told_clean = {PLATE: frozenset(), STEP: frozenset()} if registered else {} + expected = ([set(), set(), set()], [saved, saved], told_clean, "typed") + for name, (dirty, reads, told_dirty, _, saved_global) in results.items(): + assert (dirty, reads, told_dirty, saved_global) == expected, name + assert len({told for *_, told, _ in results.values()}) == 1 # told the same, in the same order + + +def test_registered_descendants_are_told_of_the_save_without_a_read(): + global_state = ObjectState(get_live_global_config(GlobalConfig), scope_id=GLOBAL) + step = _step_state() + for state in (global_state, step): + ObjectStateRegistry.register(state) + global_state.update_parameter(PATH, "typed") + assert step._dirty_fields == {PATH} + + global_state.mark_saved() + + assert step._dirty_fields == set() + assert step._saved_resolved[PATH] == "typed"