Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/architecture.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand Down
4 changes: 3 additions & 1 deletion docs/provenance.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 41 additions & 7 deletions docs/state_propagation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <https://github.com/OpenHCSDev/ObjectState/issues/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.

2 changes: 1 addition & 1 deletion src/objectstate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@
'ObjectStateEditSession',
]

__version__ = "3.0.3"
__version__ = "3.0.4"
__author__ = 'OpenHCS Team'
__description__ = 'Generic configuration framework for lazy dataclass resolution'

Expand Down
25 changes: 20 additions & 5 deletions src/objectstate/context_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``.
Expand All @@ -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)

Expand Down
27 changes: 15 additions & 12 deletions src/objectstate/docs/context_system.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
---------------------
Expand Down Expand Up @@ -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

Expand All @@ -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.
24 changes: 22 additions & 2 deletions src/objectstate/object_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading