diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8a4b07b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,68 @@ +# Changelog + +## 4.0.0 + +The global config has one holder (#56). While a state whose object is of the +global config type is registered at the global scope (`scope_id=""`), that state +holds the global config: the live global config is the object its parameters +describe and the saved one is the object it last saved. While no such state is +registered, the object given to `set_global_config` is both. The global config is +one value per process; threads no longer each have their own. + +Removed, and what to call instead: + +- `ensure_global_config_context(config_type, config)` (also + `objectstate.lazy_factory.ensure_global_config_context`): call + `set_global_config(config_type, config)`. Not needed at all beside a registered + global state: registering the state makes its object the global config. +- `set_global_config_for_editing(config_type, config)`: call + `set_global_config(config_type, config)`. +- `set_live_global_config(config_type, config)`: with a registered global state, + edit the state (`state.update_parameter(path, value)`); the live global config + follows. Without one there is no separate live value; call `set_global_config`. +- `set_saved_global_config(config_type, config)`: with a registered global state, + save it (`state.mark_saved()`); to replace the object, live and saved, call + `state.update_object_instance(config)` or `set_global_config`. Without one, call + `set_global_config`. +- `get_current_global_config(config_type, use_live=...)`: call + `get_live_global_config(config_type)` or `get_saved_global_config(config_type)`. +- `clear_current_temp_global()`: nothing; no library code leaves the caller's + entered context any more. Leave a `config_context` block to leave its context. +- `objectstate.global_config.GlobalContextValues`: with a registered global state, + checkpoint the state (`ObjectStateTransactionCheckpoint.capture(state)` / + `.restore(state)`). Without one, keep `get_live_global_config(config_type)` and + put it back with `set_global_config`, or `forget_stored_global_config` when it + was `None`. +- `ObjectState.prepare_history_global_context()`: nothing; read + `get_live_global_config` / `get_saved_global_config`. +- `objectstate.lazy_factory.rebuild_lazy_config_with_new_global_reference(...)`: + nothing (it had no caller; it copied a lazy config and stored a global config as + a side effect). +- `ObjectStateRegistry._resolve_again_below` is now `resolve_again_below`. + +Added: `set_global_config`, `forget_stored_global_config(config_type)` (a session +reset or a test that must leave nothing stored), `ObjectStateRegistry.global_config_state(config_type)`; +`get_live_global_config` and `get_saved_global_config` are exported from +`objectstate`. + +Behaviour changes: + +- `set_global_config` while a global state is registered replaces that state's + object (`update_object_instance`): unsaved edits of the global state are dropped + and its descendants are told at once. A call that only repeated what the state + already held must be deleted, not kept. +- `set_global_config` without a global state tells registered states at once (the + setters used to tell nobody until the next read). +- Registering a global state makes its object the global config at once, also when + nothing was stored before. Unregistering it makes the global config what + `set_global_config` last stored while no state held it (or the declared defaults). +- Every thread reads the same global config. A thread that used to see the declared + defaults because nothing was stored in it now sees the process's global config. +- Editing, replacing, restoring or time-travelling the global state no longer + leaves the caller's entered `config_context`: a block entered before the change + keeps the layers it was entered with until it is left. +- A global state that is not registered no longer changes the global config when it + is edited, saved, restored or given another object. +- `get_live_global_config` returns the object the registered global state builds + from its parameters (one object until the next change), not the instance the + application first passed in. Compare values, not identity. diff --git a/README.md b/README.md index 5f09d36..41bd2d2 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ - Y-Axis: Sibling inheritance within same context - **Contextvars-Based**: Uses Python's `contextvars` for clean context management - **UI Integration**: Placeholder text generation for configuration forms -- **Thread-Safe**: Thread-local global configuration storage +- **One global config**: held by the registered global state, or stored where no state exists - **100% Generic**: No application-specific dependencies - **Small dependency surface**: Uses ``python-introspect`` for callable and dataclass analysis @@ -75,7 +75,7 @@ resolution state. When using the decorator pattern with `auto_create_decorator`, you need to establish the global configuration context for lazy resolution: ```python -from objectstate import ensure_global_config_context +from objectstate import set_global_config # After creating your global config instance global_config = GlobalPipelineConfig( @@ -84,15 +84,15 @@ global_config = GlobalPipelineConfig( ) # REQUIRED: Establish global config context for lazy resolution -ensure_global_config_context(GlobalPipelineConfig, global_config) +set_global_config(GlobalPipelineConfig, global_config) # Now lazy configs can resolve from the global context ``` **Key differences:** - `set_base_config_type(MyConfig)`: Sets the **type** (class) for the framework -- `ensure_global_config_context(GlobalConfig, instance)`: Sets the **instance** (concrete values) for resolution -- Call `ensure_global_config_context()` at application startup (GUI) or before pipeline execution +- `set_global_config(GlobalConfig, instance)`: Sets the **instance** (concrete values) for resolution +- Call `set_global_config()` at application startup (GUI) or before pipeline execution ## Installation diff --git a/docs/api/modules.rst b/docs/api/modules.rst index f12ebf2..ed57821 100644 --- a/docs/api/modules.rst +++ b/docs/api/modules.rst @@ -12,7 +12,7 @@ Configuration and lazy types Builds an application-specific configuration decorator and related lazy types. -``config_context`` and ``ensure_global_config_context`` +``config_context`` and ``set_global_config`` Install concrete configuration used during resolution. ``set_base_config_type`` diff --git a/docs/architecture.rst b/docs/architecture.rst index 36cabf5..e89ed2b 100644 --- a/docs/architecture.rst +++ b/docs/architecture.rst @@ -487,8 +487,8 @@ How It Works Global Configuration Context ----------------------------- -Understanding set_base_config_type vs ensure_global_config_context -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Understanding set_base_config_type vs set_global_config +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The framework provides two related but distinct functions: @@ -507,29 +507,27 @@ Sets the **type** (class) for the framework: * Required for type validation and registry * Does not set any concrete values -**ensure_global_config_context(config_type: Type, instance: Any)** +**set_global_config(config_type: Type, instance: Any)** Sets the **instance** (concrete values) for resolution: .. code-block:: python - from objectstate import ensure_global_config_context + from objectstate import set_global_config global_config = GlobalPipelineConfig( num_workers=8, output_dir="/data" ) - ensure_global_config_context(GlobalPipelineConfig, global_config) + set_global_config(GlobalPipelineConfig, global_config) -* Call after creating global config instance -* Uses thread-local storage for thread safety -* 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`) +* Call after creating global config instance, in a process that registers no + ``ObjectState`` for it (a script, a compile worker, an execution server) +* One value per process: every thread reads it +* Not needed when an ``ObjectState`` of the global config is registered at scope + ``""``: that state's object is the global config, live and saved + (see :doc:`state_propagation`). Calling it then replaces that state's object. **Key Differences:** @@ -539,7 +537,7 @@ Sets the **instance** (concrete values) for resolution: * - Aspect - set_base_config_type - - ensure_global_config_context + - set_global_config * - What it sets - Type (class) - Instance (concrete values) @@ -549,17 +547,18 @@ Sets the **instance** (concrete values) for resolution: * - Purpose - Type registration - Enable lazy resolution - * - Thread safety - - Global registry - - Thread-local storage + * - Scope + - One per process + - One per process Thread Safety ------------- -The framework is fully thread-safe through: +Threads see: -1. **Thread-local storage** for global configs +1. **One global config per process**, read by every thread 2. **ContextVars** for temporary contexts 3. **Immutable frozen dataclasses** (when using ``frozen=True``) -This makes it safe to use in multi-threaded applications, including web servers and async applications. +Entered contexts are per thread or task. The global config and the registry are shared +and not synchronised: keep edits in one thread. diff --git a/docs/examples/basic.rst b/docs/examples/basic.rst index fb46b10..d6c9b85 100644 --- a/docs/examples/basic.rst +++ b/docs/examples/basic.rst @@ -174,7 +174,7 @@ Using the decorator pattern for automatic field injection and lazy class generat .. code-block:: python from dataclasses import dataclass - from objectstate import auto_create_decorator, ensure_global_config_context + from objectstate import auto_create_decorator, set_global_config # Create global config with auto_create_decorator @auto_create_decorator @@ -213,7 +213,7 @@ Using the decorator pattern for automatic field injection and lazy class generat ) # REQUIRED: Establish global context for lazy resolution - ensure_global_config_context(GlobalPipelineConfig, global_config) + set_global_config(GlobalPipelineConfig, global_config) # Now you can access injected configs print(f"Database: {global_config.database_config.host}:{global_config.database_config.port}") diff --git a/docs/index.rst b/docs/index.rst index 79785f3..d51ebbb 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -19,7 +19,7 @@ Key Features * **Contextvars-Based**: Uses Python's ``contextvars`` for clean context management * **UI Integration**: Placeholder text generation for configuration forms -* **Thread-Safe**: Thread-local global configuration storage +* **One global config**: held by the registered global state, or stored where no state exists * **100% Generic**: No application-specific dependencies * **Small dependency surface**: Uses ``python-introspect`` for signature and dataclass analysis diff --git a/docs/quickstart.rst b/docs/quickstart.rst index e580d4d..320a0a0 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -174,7 +174,7 @@ When using the decorator pattern with ``auto_create_decorator``, you need to est from objectstate import ( auto_create_decorator, - ensure_global_config_context, + set_global_config, ) from dataclasses import dataclass @@ -192,7 +192,7 @@ When using the decorator pattern with ``auto_create_decorator``, you need to est ) # REQUIRED: Establish global config context - ensure_global_config_context(GlobalPipelineConfig, global_config) + set_global_config(GlobalPipelineConfig, global_config) # Now lazy configs can resolve from the global context @@ -200,8 +200,8 @@ Understanding the Difference ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * ``set_base_config_type(MyConfig)``: Sets the **type** (class) for the framework -* ``ensure_global_config_context(GlobalConfig, instance)``: Sets the **instance** (concrete values) for resolution -* Call ``ensure_global_config_context()`` at application startup (GUI) or before pipeline execution +* ``set_global_config(GlobalConfig, instance)``: Sets the **instance** (concrete values) for resolution +* Call ``set_global_config()`` at application startup (GUI) or before pipeline execution Complete Example ---------------- diff --git a/docs/state_propagation.rst b/docs/state_propagation.rst index a1422df..a23c5d1 100644 --- a/docs/state_propagation.rst +++ b/docs/state_propagation.rst @@ -132,7 +132,7 @@ registered state than before, every field is. So no caller announces a change. Registering, unregistering or replacing an ancestor, editing, saving, restoring, undoing or redoing an ancestor's value, and replacing the -global config with ``set_live_global_config`` / ``set_saved_global_config`` all reach +global config with ``set_global_config`` all reach a state on its next read, whether the state is registered or not. A registered state is also told at once, so its subscribers hear of the change without a read; a state that is not registered is referenced by nothing and tells its subscribers on its next @@ -144,26 +144,47 @@ Limits: ``ObjectState.to_object`` returns the same function object for a state that wraps a plain function, so states registered below a function-backed state follow its edits only through the registry (that is, when they are registered themselves). -- 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. +- Nothing is synchronised. Every thread reads the same registry and the same global + config, so a thread that edits while another reads must be kept apart by the + application (in a GUI: edit in the GUI thread). 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. +The global config is the value every resolution starts from. There is one per config +type per process, read as edited (live) and as saved, and one thing holds it +(``objectstate.global_config``): + +- The state registered at the global scope (``scope_id=""``) whose object is of that + type. The live global config is the object its parameters describe + (``object_for_resolution()``) and the saved global config is the object it last + saved. Nothing copies them anywhere: ``get_live_global_config``, + ``get_saved_global_config``, ``config_context`` and every state's resolution ask the + registered state. So an edit, a save, a restore, a replaced object, an undo or a + redo of that state is that change of the global config, and its registered + descendants are told at once. +- While no such state is registered: the object given to ``set_global_config``. Nothing + edits it, so the live and the saved read are the same object. A process without + states (a compile worker, an execution server, a script) uses only this. + +Registering the global state is all it takes to make its object the global config; no +``set_global_config`` call is needed beside it. Unregistering it (or travelling to a +point in history where it was not registered) ends that at once: the global config is +then again what ``set_global_config`` last stored while no state held it, or the +declared defaults. + +``set_global_config(config_type, config)`` is the one way to replace the global config +from outside. While a state holds the global config the call is +``state.update_object_instance(config)``: the state takes the new object as its live +and saved value (unsaved edits are dropped) and its descendants and their subscribers +follow. A stored object that disagrees with a registered global state cannot be made. + +Threads play no part. The registry is one per process and so is the stored object, so +a worker thread reads the global config the GUI thread edits, with or without +``spawn_thread_with_context`` (which copies the entered ``config_context`` layers, not +the global config, into the new thread). + +A ``config_context`` block entered before the global config changes keeps the layers +it was entered with until it is left. + diff --git a/src/objectstate/__init__.py b/src/objectstate/__init__.py index fd97d16..62005b4 100644 --- a/src/objectstate/__init__.py +++ b/src/objectstate/__init__.py @@ -9,7 +9,7 @@ - Dual-axis inheritance (context hierarchy + sibling inheritance) - Contextvars-based context management - Placeholder text generation for UI -- Thread-local global configuration storage +- One global config per process, held by the registered global state Quick Start: >>> from objectstate import ( @@ -47,7 +47,7 @@ - dual_axis_resolver: Dual-axis inheritance resolver - context_manager: Contextvars-based context management - placeholder: Placeholder text generation for UI - - global_config: Thread-local global configuration storage + - global_config: The global config and the one thing that holds it - config: Framework configuration (pluggable types and behaviors) """ @@ -58,7 +58,6 @@ register_lazy_type_mapping, get_base_type_for_lazy, get_lazy_type_for_base, - ensure_global_config_context, # Constructor patching for code execution patch_lazy_constructors, # Global config type checking @@ -86,7 +85,6 @@ from objectstate.context_manager import ( config_context, get_current_temp_global, - clear_current_temp_global, merge_configs, extract_all_configs, get_base_global_config, @@ -111,10 +109,8 @@ # Global config from objectstate.global_config import ( - get_current_global_config, - set_global_config_for_editing, - set_saved_global_config, - set_live_global_config, + set_global_config, + forget_stored_global_config, get_saved_global_config, get_live_global_config, ) @@ -155,7 +151,6 @@ 'register_lazy_type_mapping', 'get_base_type_for_lazy', 'get_lazy_type_for_base', - 'ensure_global_config_context', # Constructor patching for code execution 'patch_lazy_constructors', # Global config type checking @@ -172,7 +167,6 @@ # Context 'config_context', 'get_current_temp_global', - 'clear_current_temp_global', 'merge_configs', 'extract_all_configs', 'get_base_global_config', @@ -182,8 +176,10 @@ # Placeholder 'LazyDefaultPlaceholderService', # Global config - 'get_current_global_config', - 'set_global_config_for_editing', + 'set_global_config', + 'forget_stored_global_config', + 'get_live_global_config', + 'get_saved_global_config', # Configuration 'set_base_config_type', 'get_base_config_type', @@ -210,7 +206,7 @@ 'ObjectStateEditSession', ] -__version__ = "3.0.4" +__version__ = "4.0.0" __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 a775af2..5218869 100644 --- a/src/objectstate/context_manager.py +++ b/src/objectstate/context_manager.py @@ -182,7 +182,7 @@ def config_context( mask_with_none: If True, None values override/mask base config values, and field resolution does not look at any layer outside this one. If False (default), None values are ignored (normal inheritance). - Use True when editing GlobalPipelineConfig to mask thread-local + Use True when editing GlobalPipelineConfig to mask the loaded instance with static class defaults. use_live_global: If True (default), use LIVE global config (UI sees unsaved edits). If False, use SAVED global config (compiler sees saved values). @@ -665,20 +665,21 @@ def get_base_global_config(use_live: bool = True): """ Get the global config a context starts from when none is active. - This is the config set with ensure_global_config_context(), or an instance of the - base config type holding its declared defaults when none was set. Raises when no - base config type is set or its defaults cannot be constructed. + This is the global config of the base config type (see ``objectstate.global_config``), + or an instance of that type holding its declared defaults when there is none. + Raises when no base config type is set or its defaults cannot be constructed. Args: use_live: If True (default), return LIVE config (UI sees unsaved edits). If False, return SAVED config (compiler sees saved values). """ from objectstate.config import get_base_config_type - from objectstate.global_config import get_current_global_config + from objectstate.global_config import get_live_global_config, get_saved_global_config base_config_type = get_base_config_type() - current_global = get_current_global_config(base_config_type, use_live=use_live) + read = get_live_global_config if use_live else get_saved_global_config + current_global = read(base_config_type) if current_global is not None: return current_global @@ -698,17 +699,6 @@ def get_current_temp_global(): return current_temp_global.get(None) -def clear_current_temp_global(): - """ - Leave the active context without restoring an outer one. - - After this no context is active: lazy fields fall back to their declared - defaults until the next ``config_context`` is entered. - """ - current_temp_global.set(None) - context_layer_stack.set(NO_LAYERS) - - # Utility functions for debugging and introspection def get_context_info() -> Dict[str, Any]: @@ -899,12 +889,10 @@ def spawn_thread_with_context( ): """Spawn a thread with current context propagated. - This ensures thread-local context (like GlobalPipelineConfig from - config framework) is accessible in spawned thread. - - Python's threading module creates isolated thread-local storage, so contextvars - don't automatically propagate to spawned threads. This utility wraps - threading.Thread to propagate the current context using contextvars.copy_context(). + A new thread starts with no ``config_context`` entered. This starts one with the + layers the caller has entered (``contextvars.copy_context()``), so plain lazy + objects read in it what they read in the caller. The global config and the + registry are one per process and need no copying. Args: target: The function to run in thread diff --git a/src/objectstate/docs/configuration_framework.rst b/src/objectstate/docs/configuration_framework.rst index 3a31ff2..e0c6818 100644 --- a/src/objectstate/docs/configuration_framework.rst +++ b/src/objectstate/docs/configuration_framework.rst @@ -126,7 +126,7 @@ Usage Example from objectstate import ( auto_create_decorator, config_context, - ensure_global_config_context, LazyDataclassFactory + set_global_config, LazyDataclassFactory ) # Create global config with concrete values @@ -139,7 +139,7 @@ Usage Example ) # Establish global context - ensure_global_config_context(GlobalAppConfig, global_config) + set_global_config(GlobalAppConfig, global_config) # Create app config with lazy configs for inheritance app_config = AppConfig( @@ -169,28 +169,18 @@ The ``objectstate`` package contains: UI placeholder generation showing inherited values **global_config.py** - Thread-local storage for global configuration + The global config and the one thing that holds it **object_state.py** ObjectState and ObjectStateRegistry for UI state management -Thread-Local Context Synchronization ------------------------------------- +Global Config and Editing +------------------------- -The configuration framework maintains thread-local context for global configuration to support lazy placeholder resolution across the application. - -**Context Lifecycle** - -1. **Initialization**: Context set during application startup -2. **Live Updates**: Context synchronized with form edits in real-time -3. **Restoration**: Original context restored on cancel -4. **Cleanup**: Context cleared on window close - -**UI Integration** - -The configuration window implements special handling for global configuration to ensure thread-local context stays synchronized with form edits. Each field change triggers immediate context update, ensuring placeholder values in other windows update immediately. - -When users cancel config editing, the original context is restored to prevent context pollution from experimental changes. +The global config is one value per process. While an ``ObjectState`` of it is +registered at scope ``""`` that state holds it: a form edit is an edit of the state +and is the live global config at once, cancel is ``restore_saved`` and save is +``mark_saved``. Nothing else is synchronised or restored. **Implementation Details** diff --git a/src/objectstate/docs/context_system.rst b/src/objectstate/docs/context_system.rst index a6d9767..910f54d 100644 --- a/src/objectstate/docs/context_system.rst +++ b/src/objectstate/docs/context_system.rst @@ -165,20 +165,19 @@ This flattens nested configs into a single dictionary keyed by nominal type. Global Config Context -------------------- -Global config uses thread-local storage for stability: +The global config is one value per config type per process: .. code-block:: python - # Thread-local storage for global config - _global_config_storage: Dict[Type, threading.local] = {} - - def ensure_global_config_context(config_type: Type, config_instance: Any): - """Store the instance as both the live and the saved global config of this thread.""" - set_global_config_for_editing(config_type, config_instance) + # What set_global_config stored while no state held the global config + _stored_global_configs: Dict[Type, Any] = {} -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 + def set_global_config(config_type: Type, config: Any): + """Make ``config`` the global config, live and saved.""" + +The global config is the base; contextvars hold the layers stacked on it. When an +``ObjectState`` of the global config is registered at scope ``""``, that state holds +the global config and nothing is stored beside it (``docs/state_propagation.rst``, "The state of the global config"). Resolution Integration @@ -229,11 +228,11 @@ Usage Pattern .. code-block:: python - from objectstate import config_context, ensure_global_config_context + from objectstate import config_context, set_global_config # Establish global context global_config = GlobalAppConfig(num_workers=4) - ensure_global_config_context(GlobalAppConfig, global_config) + set_global_config(GlobalAppConfig, global_config) # Create app config app_config = AppConfig( diff --git a/src/objectstate/global_config.py b/src/objectstate/global_config.py index e9a7ccf..ce44e43 100644 --- a/src/objectstate/global_config.py +++ b/src/objectstate/global_config.py @@ -1,174 +1,96 @@ +"""The global config: the value every resolution starts from. + +There is one global config per config type per process, read in two ways: live +(with unsaved edits) and saved. One thing holds it: + +- the state registered at the global scope (``scope_id=""``), when its object is + of that type. The live global config is the object its parameters describe and + the saved global config is the object it last saved. Editing, saving, restoring + or time-travelling that state is editing, saving, restoring or time-travelling + the global config; nothing else has to be told. +- otherwise the object given to ``set_global_config``. Nothing edits it, so its + live and saved reads are the same object. A process that registers no state + (a compile worker, an execution server, a script) uses only this. + +``set_global_config`` is the one way to replace the global config from outside. +While a state holds it, the call replaces that state's object +(``ObjectState.update_object_instance``), so the state, its descendants and their +subscribers all follow; a stored object the state would disagree with cannot be +created. A stored object is not read while a state holds the global config, and is +read again, unchanged, once that state is no longer registered. + +Threads play no part: the registry is one per process and so is the stored +object, so every thread reads the same global config. """ -Generic global configuration context management. -Provides thread-local storage for global configuration state. -This is used as the base context for all lazy configuration resolution. +from typing import Any -DUAL THREAD-LOCAL PATTERN: -- _saved_global_config_contexts: SAVED config (what descendants/compiler see) -- _live_global_config_contexts: LIVE config (what UI sees during editing) +from objectstate.scope_key import ScopeKey -Default behavior: UI uses LIVE (sees unsaved edits) -Explicit override: Compilation uses SAVED (via use_live_global=False) -""" - -import threading -from dataclasses import dataclass -from typing import Dict, Type, Optional, Any - -# Dual thread-local storage: saved (for descendants/compiler) and live (for UI editing) -_saved_global_config_contexts: Dict[Type, threading.local] = {} -_live_global_config_contexts: Dict[Type, threading.local] = {} - - -def _validate_global_config_value(config_type: Type, config_instance: Any) -> None: - """Validate one nominal global-config storage assignment.""" - - if not isinstance(config_type, type): - raise TypeError(f"Global config key must be a type, got {config_type!r}.") - if not isinstance(config_instance, config_type): - raise TypeError( - f"Global config value for {config_type.__name__} must be an " - f"instance of that type, got {type(config_instance).__name__}." - ) - - -def _assign_global_config_value( - contexts: Dict[Type, threading.local], config_type: Type, value: Any -) -> None: - """One storage boundary for installing or unsetting a context projection.""" - - if value is None: - local = contexts.get(config_type) - if local is not None and hasattr(local, "value"): - del local.value - return - _validate_global_config_value(config_type, value) - if config_type not in contexts: - contexts[config_type] = threading.local() - contexts[config_type].value = value - - -@dataclass(frozen=True) -class GlobalContextValues: - """Prepared saved/live projections, or exact local rollback context values.""" - - config_type: Type - saved: Any - live: Any - - @classmethod - def capture(cls, config_type: Type) -> "GlobalContextValues": - return cls( - config_type, - get_saved_global_config(config_type), - get_live_global_config(config_type), - ) - - def validate(self) -> None: - for value in (self.saved, self.live): - if value is not None: - _validate_global_config_value(self.config_type, value) - - def apply(self) -> None: - _assign_global_config_value( - _saved_global_config_contexts, self.config_type, self.saved - ) - _assign_global_config_value( - _live_global_config_contexts, self.config_type, self.live - ) +# What ``set_global_config`` stored while no state held the global config. +_stored_global_configs: dict[type, Any] = {} -def set_saved_global_config(config_type: Type, config_instance: Any) -> None: - """Set SAVED global config (what descendants/compiler see). +_registry_class: Any = None - Called when: - - App startup loads cached config - - User SAVES GlobalPipelineConfig in ConfigWindow - - Tests set up saved state - Args: - config_type: The config type to set - config_instance: The SAVED config instance - """ - _validate_global_config_value(config_type, config_instance) - _assign_global_config_value( - _saved_global_config_contexts, config_type, config_instance - ) - - -def set_live_global_config(config_type: Type, config_instance: Any) -> None: - """Set LIVE global config (what UI sees during editing). +def _registry(): + """``ObjectStateRegistry``, imported on first use (its module imports this one's users).""" + global _registry_class + if _registry_class is None: + from objectstate.object_state_registry import ObjectStateRegistry - Called when: - - User types in GlobalPipelineConfig field (every keystroke) - - UI needs to show live preview of unsaved changes - - Args: - config_type: The config type to set - config_instance: The LIVE (unsaved) config instance - """ - _validate_global_config_value(config_type, config_instance) - _assign_global_config_value( - _live_global_config_contexts, config_type, config_instance - ) + _registry_class = ObjectStateRegistry + return _registry_class -def get_saved_global_config(config_type: Type) -> Optional[Any]: - """Get SAVED global config (what descendants/compiler see). - - Args: - config_type: The config type to retrieve - - Returns: - Saved config instance or None - """ - context = _saved_global_config_contexts.get(config_type) - return getattr(context, "value", None) if context else None - +def _state_holding(config_type: type): + return _registry().global_config_state(config_type) -def get_live_global_config(config_type: Type) -> Optional[Any]: - """Get LIVE global config (what UI sees during editing). - Args: - config_type: The config type to retrieve +def set_global_config(config_type: type, config: Any) -> None: + """Make ``config`` the global config of ``config_type``, live and saved. - Returns: - Live config instance or None + With a state of that type registered at the global scope this replaces the + state's object and its unsaved edits; without one it stores ``config``. Either + way the registered states that inherit from it are told at once. """ - context = _live_global_config_contexts.get(config_type) - return getattr(context, "value", None) if context else None - + if not isinstance(config_type, type): + raise TypeError(f"Global config key must be a type, got {config_type!r}.") + if not isinstance(config, config_type): + raise TypeError( + f"Global config value for {config_type.__name__} must be an " + f"instance of that type, got {type(config).__name__}." + ) + state = _state_holding(config_type) + if state is None: + _stored_global_configs[config_type] = config + _registry().resolve_again_below((ScopeKey(""),)) + else: + state.update_object_instance(config) -def set_global_config_for_editing(config_type: Type, config_instance: Any) -> None: - """Set global config for editing (sets BOTH saved and live). - Use this for app startup and initial setup. - For live editing updates, use set_live_global_config(). - For saving, use set_saved_global_config(). +def forget_stored_global_config(config_type: type) -> None: + """Store no global config of ``config_type`` any more (a session reset, a test). - Args: - config_type: The config type to set - config_instance: The config instance to set + This is the stored object only. A state that holds the global config is + unregistered through the registry, not here. """ - set_saved_global_config(config_type, config_instance) - set_live_global_config(config_type, config_instance) + if _stored_global_configs.pop(config_type, None) is not None: + _registry().resolve_again_below((ScopeKey(""),)) -def get_current_global_config( - config_type: Type, use_live: bool = True -) -> Optional[Any]: - """Get current global config. +def get_live_global_config(config_type: type) -> Any | None: + """The global config with unsaved edits, or ``None`` when there is none.""" + state = _state_holding(config_type) + if state is None: + return _stored_global_configs.get(config_type) + return state.object_for_resolution() - Args: - config_type: The config type to retrieve - use_live: If True (default), return LIVE config (UI sees unsaved edits). - If False, return SAVED config (compiler sees saved values). - Returns: - Config instance or None - """ - if use_live: - return get_live_global_config(config_type) - else: - return get_saved_global_config(config_type) +def get_saved_global_config(config_type: type) -> Any | None: + """The global config as last saved, or ``None`` when there is none.""" + state = _state_holding(config_type) + if state is None: + return _stored_global_configs.get(config_type) + return state.object_for_resolution(use_saved=True) diff --git a/src/objectstate/lazy_factory.py b/src/objectstate/lazy_factory.py index 322d4be..ec9e7d4 100644 --- a/src/objectstate/lazy_factory.py +++ b/src/objectstate/lazy_factory.py @@ -1096,13 +1096,6 @@ def patched_init(self, **kwargs): lazy_type.__init__ = original_init -# Generic utility functions for clean thread-local storage management -def ensure_global_config_context(global_config_type: Type, global_config_instance: Any) -> None: - """Ensure proper thread-local storage setup for any global config type.""" - from objectstate.global_config import set_global_config_for_editing - set_global_config_for_editing(global_config_type, global_config_instance) - - # ContextProvider infrastructure removed - was dead code feeding broken frame.f_locals manipulation @@ -1211,58 +1204,6 @@ def create_dataclass_for_editing(dataclass_type: Type[T], source_config: Any, pr -def rebuild_lazy_config_with_new_global_reference( - existing_lazy_config: Any, - new_global_config: Any, - global_config_type: Optional[Type] = None -) -> Any: - """ - Rebuild lazy config to reference new global config while preserving field states. - - This function preserves the exact field state of the existing lazy config: - - Fields that are None (using lazy resolution) remain None - - Fields that have been explicitly set retain their concrete values - - Nested dataclass fields are recursively rebuilt to reference new global config - - The underlying global config reference is updated for None field resolution - - Args: - existing_lazy_config: Current lazy config instance - new_global_config: New global config to reference for lazy resolution - global_config_type: Type of the global config (defaults to type of new_global_config) - - Returns: - New lazy config instance with preserved field states and updated global reference - """ - if existing_lazy_config is None: - return None - - # Determine global config type - if global_config_type is None: - global_config_type = type(new_global_config) - - # Set new global config in thread-local storage - ensure_global_config_context(global_config_type, new_global_config) - - # Extract current field values without triggering lazy resolution - inline field processing pattern - def process_field_value(field_obj): - raw_value = object.__getattribute__(existing_lazy_config, field_obj.name) - - if raw_value is not None and hasattr(raw_value, '__dataclass_fields__'): - try: - # Rebuild nested dataclass recursively - # Decorated config types declare lazy resolution nominally. - nested_result = rebuild_lazy_config_with_new_global_reference(raw_value, new_global_config, global_config_type) - return nested_result - except Exception as e: - logger.debug(f"Failed to rebuild nested config {field_obj.name}: {e}") - return raw_value - return raw_value - - current_field_values = {f.name: process_field_value(f) for f in fields(existing_lazy_config)} - - return type(existing_lazy_config)(**current_field_values) - - # Declarative Global Config Field Injection System # Moved inline imports to top-level diff --git a/src/objectstate/object_state.py b/src/objectstate/object_state.py index 384a61f..59eb28d 100644 --- a/src/objectstate/object_state.py +++ b/src/objectstate/object_state.py @@ -12,7 +12,6 @@ import copy import logging -import threading from collections.abc import Callable, Sequence from dataclasses import fields as dataclass_fields from dataclasses import is_dataclass @@ -35,7 +34,6 @@ from objectstate.parameter_owner import ParameterOwner from objectstate.parameter_structure import ParameterStructure from objectstate.resolution_sources import ResolutionSources -from objectstate.global_config import GlobalContextValues from objectstate.lazy_factory import is_global_config_type from objectstate.subfield_semantics import ( MISSING, @@ -337,7 +335,7 @@ def _check_and_sync_delegate(self) -> bool: This implements auto-detection of delegate changes (Option 3 from architectural discussion). When object_instance's delegate attribute is replaced with a new instance (e.g., after - rebuild_lazy_config_with_new_global_reference()), this method detects the change and + code that rebuilds the config), this method detects the change and automatically re-extracts parameters from the new delegate. Returns: @@ -777,7 +775,7 @@ def _ensure_live_resolved(self, notify_flash: bool = True) -> set[str]: self._invalid_fields.clear() else: changed_paths = set() - self._resolved_from[False] = sources.recorded_after(self._resolved_from.get(False)) + self._resolved_from[False] = sources if notify_flash and (live_resolved_again or saved_resolved_again): self._sync_materialized_state(changed_value_fields=changed_paths) @@ -788,8 +786,8 @@ def _resolution_sources( self, *, sync_delegates: bool = False ) -> tuple[ResolutionSources, ResolutionSources]: """What this state's fields are resolved from today, live then saved (so the - pair is indexed by ``use_saved``): the global config stored for the running - thread and the objects of the other states registered above. + pair is indexed by ``use_saved``): the global config and the objects of the + other states registered above. This state's own object is left out (its own parameters are not an outside source), and so are global config objects of ancestors, for which @@ -810,16 +808,18 @@ def _resolution_sources( live.append( (scope, state.object_for_resolution(sync_delegate=sync_delegates)) ) - thread = threading.current_thread() global_config_type = get_base_config_type() return ( - ResolutionSources(thread, get_live_global_config(global_config_type), tuple(live)), - ResolutionSources(thread, get_saved_global_config(global_config_type), tuple(saved)), + ResolutionSources(get_live_global_config(global_config_type), tuple(live)), + ResolutionSources(get_saved_global_config(global_config_type), tuple(saved)), ) def object_for_resolution(self, use_saved: bool = False, sync_delegate: bool = False) -> Any: """The object a state below this one enters as a context layer. + For the state of a global config these two objects are the live and the saved + global config (``objectstate.global_config``). + Saved: the object as last saved, without delegate sync or reconstruction. Live: rebuilt from the current parameters when they changed since the last call; ``sync_delegate`` decides whether a replaced delegate is taken over @@ -864,7 +864,7 @@ def _resolve_saved_again_if_sources_changed(self, sources: ResolutionSources) -> """ stale = self._fields_with_other_sources(sources, use_saved=True) if stale is not None and not stale: - self._resolved_from[True] = sources.recorded_after(self._resolved_from.get(True)) + self._resolved_from[True] = sources return False self._saved_resolved = self._compute_resolved_snapshot(use_saved=True) return True @@ -1131,50 +1131,6 @@ def update_parameter(self, param_name: str, value: Any) -> set[str]: self._cached_object = None # Invalidate cached reconstructed object self._cached_object_applied = False - # GLOBAL CONFIG EXCEPTION: Update LIVE thread-local FIRST, BEFORE invalidating descendants! - # This is critical: descendants re-resolve during invalidation, so they need to see - # the NEW value in the LIVE thread-local, not the old one. - obj_type = type(self.object_instance) - from objectstate.lazy_factory import is_global_config_type - if is_global_config_type(obj_type): - try: - from objectstate.context_manager import clear_current_temp_global - from objectstate.global_config import get_live_global_config, set_live_global_config - from objectstate.lazy_factory import replace_raw - - # Get current LIVE config - current_live = get_live_global_config(obj_type) - if current_live is not None: - # Do a quick partial update to set the new value in LIVE thread-local - if '.' in param_name: - # Nested field like 'partition_filter_config.partition_filter' - parts = param_name.split('.') - nested_config_name = parts[0] - nested_field_name = '.'.join(parts[1:]) - - # Get nested config using object.__getattribute__ to avoid lazy resolution - try: - nested_config = object.__getattribute__(current_live, nested_config_name) - except AttributeError: - nested_config = None - - if nested_config is not None and is_dataclass(nested_config): - # Update the nested config with the new value - updated_nested = replace_raw(nested_config, **{nested_field_name: value}) - # Update LIVE thread-local with the updated nested config - temp_live = replace_raw(current_live, **{nested_config_name: updated_nested}) - set_live_global_config(obj_type, temp_live) - else: - # Top-level field - temp_live = replace_raw(current_live, **{param_name: value}) - set_live_global_config(obj_type, temp_live) - - # Clear cached context so resolution uses updated LIVE thread-local - clear_current_temp_global() - - except Exception as e: - logger.warning(f"Failed to update LIVE thread-local: {e}") - # Materialize the origin once after its live context is authoritative, # then cascade only to descendants. changed_paths = self._ensure_live_resolved(notify_flash=False) | changed_before_the_edit @@ -1729,20 +1685,6 @@ def apply_prepared_construction( self._cached_object_applied = False self._invalid_fields.clear() - def prepare_history_global_context(self) -> GlobalContextValues | None: - """Derive saved/live global projections from canonical raw state values.""" - - target_type = type(self._extraction_target) - if not is_global_config_type(target_type): - return None - contexts = GlobalContextValues( - target_type, - self._reconstruct_from_parameter_snapshot("", self._saved_parameters), - self._reconstruct_from_parameter_snapshot("", self.parameters), - ) - contexts.validate() - return contexts - def update_object_instance(self, new_instance: Any) -> None: """Replace object_instance with a new instance and re-extract parameters. @@ -1798,15 +1740,6 @@ def update_object_instance(self, new_instance: Any) -> None: self._cached_object_applied = False self._invalid_fields.clear() - obj_type = type(self._extraction_target) - from objectstate.lazy_factory import is_global_config_type - if is_global_config_type(obj_type): - from objectstate.context_manager import clear_current_temp_global - from objectstate.global_config import set_global_config_for_editing - - set_global_config_for_editing(obj_type, self._extraction_target) - clear_current_temp_global() - self._live_resolved = self._compute_resolved_snapshot(use_saved=False) self._saved_resolved = self._compute_resolved_snapshot(use_saved=True) @@ -2379,7 +2312,6 @@ 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 @@ -2481,36 +2413,6 @@ 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) - from objectstate.lazy_factory import is_global_config_type - if not is_global_config_type(obj_type): - return - - from objectstate.context_manager import clear_current_temp_global - from objectstate.global_config import set_live_global_config - - set_live_global_config(obj_type, copy.deepcopy(self._extraction_target)) - clear_current_temp_global() - def _restore_saved_impl(self, *, propagate_descendants: bool = True) -> None: """Internal restore implementation (wrapped by restore_saved atomic block).""" @@ -2549,7 +2451,6 @@ def _restore_saved_impl(self, *, propagate_descendants: bool = True) -> None: # CRITICAL: Also restore _saved_parameters to match current parameters # After restore, parameters == saved (both extracted from object_instance) self._saved_parameters = self._copy_parameters_for_saved_baseline(self.parameters) - self._restore_live_global_context_from_saved() self.invalidate_cache() # Invalidate cached reconstructed object (may contain unsaved edits) diff --git a/src/objectstate/object_state_registry.py b/src/objectstate/object_state_registry.py index 1c77e73..e23bf77 100644 --- a/src/objectstate/object_state_registry.py +++ b/src/objectstate/object_state_registry.py @@ -25,8 +25,6 @@ from objectstate.object_state_metadata import ObjectStateMetadataStore from objectstate.construction_binding import StateConstructionBinding from objectstate.scope_key import ScopeKey -from objectstate.global_config import GlobalContextValues -from objectstate.context_manager import clear_current_temp_global from objectstate.parameter_owner import ParameterOwner from objectstate.snapshot_model import Snapshot, StateSnapshot, Timeline from objectstate.time_travel_profile import TimeTravelProfiler @@ -88,7 +86,6 @@ class PreparedStateRestore: schema: "ObjectState" diff: "TimeTravelStateDiff" construction_changed: bool - context: GlobalContextValues | None @dataclass(frozen=True) @@ -410,7 +407,7 @@ def register(cls, state: 'ObjectState', _skip_snapshot: bool = False) -> None: cls._states[key] = state cls.mark_snapshot_dirty_scope(key) logger.debug(f"Registered ObjectState: scope={key}, type={type(state.object_instance).__name__}") - cls._resolve_again_below((key,)) + cls.resolve_again_below((key,)) # Fire callbacks for UI binding cls._fire_register_callbacks(key, state) @@ -435,7 +432,7 @@ def unregister(cls, state: 'ObjectState', _skip_snapshot: bool = False) -> None: # that reference this ObjectState. Graveyard persists for session lifetime. cls._graveyard[key] = removed_state logger.debug(f"Unregistered ObjectState: scope={key} (moved to graveyard)") - cls._resolve_again_below((key,)) + cls.resolve_again_below((key,)) # Fire callbacks for UI binding cls._fire_unregister_callbacks(key, state) @@ -444,8 +441,24 @@ def unregister(cls, state: 'ObjectState', _skip_snapshot: bool = False) -> None: # graveyard preserves state for explicit operation snapshots. @classmethod - def _resolve_again_below(cls, changed_scopes: Iterable[ScopeKey]) -> None: - """Tell registered states at or below ``changed_scopes`` that registration changed. + def global_config_state(cls, config_type: type) -> Optional['ObjectState']: + """The state that holds the global config of ``config_type``: the one + registered at the global scope, when its object is of exactly that type. + + ``objectstate.global_config`` reads and replaces the global config through + this state while there is one, so registering it is all it takes to make its + object the global config, and unregistering it (or time travel to a point + where it was not registered) ends that at once. + """ + state = cls._states.get("") + if state is not None and type(state._extraction_target) is config_type: + return state + return None + + @classmethod + def resolve_again_below(cls, changed_scopes: Iterable[ScopeKey]) -> None: + """Tell registered states at or below ``changed_scopes`` that what they are + resolved from may have changed (a registration, or a stored global config). Each state compares what it is resolved from now with what its resolved values were computed from (``ObjectState.resolve_again_if_sources_changed``), so a @@ -508,7 +521,7 @@ def unregister_scope_and_descendants(cls, scope_id: Optional[str], _skip_snapsho # When history is enabled we must preserve removed states for time-travel (undo/redo), # otherwise snapshots may reference scopes that can no longer be resurrected. removed = [(key, cls._states.pop(key)) for key in scopes_to_delete] - cls._resolve_again_below(scopes_to_delete) + cls.resolve_again_below(scopes_to_delete) for key, state in removed: if cls._history_enabled: cls._graveyard[key] = state @@ -1506,7 +1519,6 @@ def _prepare_time_travel_states(cls, snapshot: Snapshot) -> Dict[str, PreparedSt construction_changed=( state is not None and state.construction_binding() != binding ), - context=schema.prepare_history_global_context(), ) del pending[scope] progressed = True @@ -1942,18 +1954,6 @@ def time_travel_to_snapshot(cls, snapshot_id: str) -> bool: return False prepared = cls._prepare_time_travel_states(transaction.snapshot) - contexts = { - restoration.context.config_type: restoration.context - for restoration in prepared.values() - if restoration.context is not None - } - for state in cls._states.values(): - previous_context = state.prepare_history_global_context() - if previous_context is not None and previous_context.config_type not in contexts: - contexts[previous_context.config_type] = GlobalContextValues( - previous_context.config_type, None, None - ) - contexts_before = [GlobalContextValues.capture(config_type) for config_type in contexts] states_before = dict(cls._states) limbo_before = dict(cls._time_travel_limbo) graveyard_before = dict(cls._graveyard) @@ -1983,16 +1983,11 @@ def time_travel_to_snapshot(cls, snapshot_id: str) -> bool: reconciliation, prepared, ) - for context in contexts.values(): - context.apply() - clear_current_temp_global() except Exception: try: for scope, restoration in reversed(list(prepared.items())): checkpoints[scope].restore(restoration.state, emit_notifications=False) finally: - for context in contexts_before: - context.apply() cls._states = states_before cls._time_travel_limbo = limbo_before cls._graveyard = graveyard_before diff --git a/src/objectstate/resolution_sources.py b/src/objectstate/resolution_sources.py index eac3446..cfd7745 100644 --- a/src/objectstate/resolution_sources.py +++ b/src/objectstate/resolution_sources.py @@ -13,13 +13,12 @@ can inherit one of those pairs need to be resolved again. Objects are compared by identity. ``ObjectState.to_object`` builds a new object -whenever a parameter changed, and the global config setters store another +whenever a parameter changed, and ``set_global_config`` stores another instance, so another identity is the signal that values may differ. Every state below an edited ancestor asks about the same pair of objects, so the answer for a pair is kept and computed once per edit. """ -import threading from collections import OrderedDict from dataclasses import fields, is_dataclass from typing import Any, NamedTuple @@ -37,18 +36,13 @@ class ResolutionSources(NamedTuple): """What one resolved snapshot of a state is computed from. - ``global_config`` is the instance stored for the running thread, ``None`` when - none was set (the declared defaults are used then), and ``thread`` is that - thread: the global config is stored per thread, so two sources read in - different threads say nothing about each other's global config and are not - compared on it. A snapshot therefore follows a replaced global config in the - thread that resolved it, and a read from another thread never resolves it again - for that thread's global config. ``ancestors`` are the ``(scope id, object)`` - pairs of the states registered above the state, least specific first, without - global config objects: the global config stands for them. + ``global_config`` is the global config (``objectstate.global_config``), ``None`` + when there is none (the declared defaults are used then). ``ancestors`` are the + ``(scope id, object)`` pairs of the states registered above the state, least + specific first, without global config objects: the global config stands for + them. """ - thread: threading.Thread global_config: Any | None ancestors: tuple[tuple[str, Any], ...] @@ -56,7 +50,7 @@ def same_objects_as(self, other: "ResolutionSources | None") -> bool: """Whether ``other`` holds the same objects at the same scopes.""" if other is None: return False - if self.thread is other.thread and self.global_config is not other.global_config: + if self.global_config is not other.global_config: return False if len(self.ancestors) != len(other.ancestors): return False @@ -65,17 +59,6 @@ def same_objects_as(self, other: "ResolutionSources | None") -> bool: return False return True - def recorded_after(self, earlier: "ResolutionSources | None") -> "ResolutionSources": - """What a snapshot is recorded as computed from once it follows these sources. - - A snapshot brought up to date in another thread than the one that resolved it - was not compared on the global config, so it keeps the global config, and the - thread, it was resolved with. - """ - if earlier is None or earlier.thread is self.thread: - return self - return self._replace(thread=earlier.thread, global_config=earlier.global_config) - def fields_that_differ(self, earlier: "ResolutionSources | None") -> frozenset[FieldKey] | None: """The fields whose stored value differs between ``earlier`` and these sources. @@ -85,15 +68,14 @@ def fields_that_differ(self, earlier: "ResolutionSources | None") -> frozenset[F """ if earlier is None: return None - same_thread = self.thread is earlier.thread - if same_thread and (earlier.global_config is None) != (self.global_config is None): + if (earlier.global_config is None) != (self.global_config is None): return None if tuple(scope for scope, _ in earlier.ancestors) != tuple( scope for scope, _ in self.ancestors ): return None differing: frozenset[FieldKey] = frozenset() - if same_thread and self.global_config is not earlier.global_config: + if self.global_config is not earlier.global_config: differing |= _fields_that_differ(earlier.global_config, self.global_config) for (_, before), (_, now) in zip(earlier.ancestors, self.ancestors): if before is not now: diff --git a/tests/conftest.py b/tests/conftest.py index ff22975..b04c53b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -53,8 +53,7 @@ def reset_and_init_base_config(): """Reset and initialize base config type before each test.""" # Store original values original_base_type = config_module._base_config_type - original_saved = dict(global_config_module._saved_global_config_contexts) - original_live = dict(global_config_module._live_global_config_contexts) + original_stored = dict(global_config_module._stored_global_configs) # Set up base config type for all tests set_base_config_type(TestGlobalConfig) @@ -63,10 +62,8 @@ def reset_and_init_base_config(): # Restore original values after test config_module._base_config_type = original_base_type - global_config_module._saved_global_config_contexts.clear() - global_config_module._saved_global_config_contexts.update(original_saved) - global_config_module._live_global_config_contexts.clear() - global_config_module._live_global_config_contexts.update(original_live) + global_config_module._stored_global_configs.clear() + global_config_module._stored_global_configs.update(original_stored) @pytest.fixture diff --git a/tests/test_callable_baseline.py b/tests/test_callable_baseline.py index 206e164..8161e4e 100644 --- a/tests/test_callable_baseline.py +++ b/tests/test_callable_baseline.py @@ -8,7 +8,7 @@ ObjectState, ObjectStateRegistry, set_base_config_type, - set_global_config_for_editing, + set_global_config, ) @@ -164,7 +164,7 @@ class Submitted: options: LazyOptions set_base_config_type(GlobalConfig) - set_global_config_for_editing(GlobalConfig, GlobalConfig(Options())) + set_global_config(GlobalConfig, GlobalConfig(Options())) shared = ["saved"] func = RebuiltCallable() ancestor = Pipeline(Options(shared, shared, func)) diff --git a/tests/test_construction_history.py b/tests/test_construction_history.py index 2fd6ccd..dd148a1 100644 --- a/tests/test_construction_history.py +++ b/tests/test_construction_history.py @@ -13,7 +13,7 @@ get_saved_global_config, mark_global_config_type, set_base_config_type, - set_global_config_for_editing, + set_global_config, ) @@ -384,7 +384,7 @@ def test_history_restores_saved_live_global_context_after_real_invalidation( mark_global_config_type(ContextConfig) set_base_config_type(ContextConfig) target = ContextConfig(threshold=3) - set_global_config_for_editing(ContextConfig, target) + set_global_config(ContextConfig, target) parent = ObjectState(target, scope_id=None) ObjectStateRegistry.register(parent, _skip_snapshot=True) child = register(LazyContextConfig(), "child", parent_state=parent) @@ -395,7 +395,7 @@ def test_history_restores_saved_live_global_context_after_real_invalidation( path = tmp_path / "contexts.objectstate" ObjectStateRegistry.save_history_to_file(str(path)) ObjectStateRegistry.clear() - set_global_config_for_editing(ContextConfig, ContextConfig(threshold=77)) + set_global_config(ContextConfig, ContextConfig(threshold=77)) ObjectStateRegistry.load_history_from_file(str(path)) for snapshot, live in ((initial, 3), (edited, 9), (initial, 3)): assert ObjectStateRegistry.time_travel_to_snapshot(snapshot) diff --git a/tests/test_context_failures_surface.py b/tests/test_context_failures_surface.py index f05f4c0..eaa5316 100644 --- a/tests/test_context_failures_surface.py +++ b/tests/test_context_failures_surface.py @@ -11,7 +11,7 @@ mark_global_config_type, merge_configs, set_base_config_type, - set_global_config_for_editing, + set_global_config, ) from objectstate.context_manager import ( NoActiveContextError, @@ -47,7 +47,7 @@ class Pipeline: def checked_global(): set_base_config_type(CheckedGlobal) saved = CheckedGlobal() - set_global_config_for_editing(CheckedGlobal, saved) + set_global_config(CheckedGlobal, saved) return saved @@ -99,7 +99,7 @@ class RequiredGlobal: mark_global_config_type(RequiredGlobal) set_base_config_type(RequiredGlobal) - set_global_config_for_editing(RequiredGlobal, RequiredGlobal(workers=2)) + set_global_config(RequiredGlobal, RequiredGlobal(workers=2)) with pytest.raises(TypeError, match="workers"): with build_context_stack(RequiredGlobal(workers=3), current_scope_id=""): diff --git a/tests/test_context_manager.py b/tests/test_context_manager.py index 511c4b8..4c8f518 100644 --- a/tests/test_context_manager.py +++ b/tests/test_context_manager.py @@ -7,7 +7,6 @@ LazyDataclassFactory, config_context, get_current_temp_global, - clear_current_temp_global, merge_configs, extract_all_configs, set_base_config_type, @@ -102,15 +101,6 @@ def test_config_context_cleanup(global_config): pass -def test_clear_current_temp_global_leaves_no_active_context(global_config): - """After clearing inside a context, neither the merged config nor a layer is active.""" - from objectstate.context_manager import get_context_layer_stack - - with config_context(global_config): - clear_current_temp_global() - assert get_current_temp_global() is None - assert get_context_layer_stack().layers == () - def test_merge_configs(global_config): """Test merging overrides into base config. diff --git a/tests/test_global_config.py b/tests/test_global_config.py index e87fc43..a77451d 100644 --- a/tests/test_global_config.py +++ b/tests/test_global_config.py @@ -1,59 +1,28 @@ -"""Tests for global config module.""" -import pytest +"""The stored global config: what holds it while no state does.""" from dataclasses import dataclass -from objectstate import ( - get_current_global_config, - set_global_config_for_editing, - set_saved_global_config, - set_live_global_config, - get_saved_global_config, - get_live_global_config, -) +import pytest + +from objectstate import get_live_global_config, get_saved_global_config, set_global_config -def test_set_global_config_for_editing(global_config): - """Test setting global config for editing.""" +def test_a_stored_global_config_is_both_the_live_and_the_saved_one(global_config): config_type = type(global_config) - set_global_config_for_editing(config_type, global_config) - # Should set both saved and live - assert get_saved_global_config(config_type) == global_config - assert get_live_global_config(config_type) == global_config + set_global_config(config_type, global_config) + assert get_saved_global_config(config_type) is global_config + assert get_live_global_config(config_type) is global_config -def test_get_global_config_not_set(): - """Test getting global config when not set for a specific type.""" +def test_no_global_config_reads_as_none(): @dataclass - class UnregisteredConfig: + class NeverSet: value: str = "test" - # Getting config for unregistered type should return None - result = get_current_global_config(UnregisteredConfig) - assert result is None - - -def test_dual_config_pattern(global_config): - """Test the dual saved/live config pattern.""" - # Create two different configs - @dataclass - class DualTestConfig: - output_dir: str = "/tmp" - num_workers: int = 4 - - saved_config = DualTestConfig(output_dir="/saved", num_workers=4) - live_config = DualTestConfig(output_dir="/live", num_workers=8) - - set_saved_global_config(DualTestConfig, saved_config) - set_live_global_config(DualTestConfig, live_config) - - # get_current_global_config with use_live=True (default) returns live - assert get_current_global_config(DualTestConfig, use_live=True) == live_config - # get_current_global_config with use_live=False returns saved - assert get_current_global_config(DualTestConfig, use_live=False) == saved_config + assert get_live_global_config(NeverSet) is None + assert get_saved_global_config(NeverSet) is None -@pytest.mark.parametrize("setter", [set_saved_global_config, set_live_global_config]) -def test_global_config_storage_requires_matching_nominal_type(setter) -> None: +def test_a_global_config_must_be_an_instance_of_its_type() -> None: @dataclass class ExpectedConfig: value: str = "expected" @@ -63,6 +32,6 @@ class OtherConfig: value: str = "other" with pytest.raises(TypeError, match="ExpectedConfig"): - setter(ExpectedConfig, OtherConfig()) + set_global_config(ExpectedConfig, OtherConfig()) with pytest.raises(TypeError, match="key must be a type"): - setter("ExpectedConfig", ExpectedConfig()) + set_global_config("ExpectedConfig", ExpectedConfig()) diff --git a/tests/test_object_state_restore.py b/tests/test_object_state_restore.py index 74ca1a3..fa9c6c2 100644 --- a/tests/test_object_state_restore.py +++ b/tests/test_object_state_restore.py @@ -11,9 +11,8 @@ get_live_global_config, mark_global_config_type, set_base_config_type, - set_global_config_for_editing, + set_global_config, ) -from objectstate.global_config import set_live_global_config def test_restore_saved_resets_live_global_context_for_descendants(): @@ -28,7 +27,7 @@ class GlobalConfig: LazyGlobalConfig = LazyDataclassFactory.make_lazy_simple(GlobalConfig) saved_global = GlobalConfig(threshold=1) - set_global_config_for_editing(GlobalConfig, saved_global) + set_global_config(GlobalConfig, saved_global) global_state = ObjectState(saved_global, scope_id="") child_state = ObjectState(LazyGlobalConfig(), scope_id="plate::step") @@ -68,8 +67,9 @@ class Step: policy: LazyPolicy = field(default_factory=LazyPolicy) set_base_config_type(GlobalConfig) - set_global_config_for_editing(GlobalConfig, GlobalConfig(Policy(value=3))) - set_live_global_config(GlobalConfig, GlobalConfig(Policy(value=9))) + global_state = ObjectState(GlobalConfig(Policy(value=3)), scope_id="") + ObjectStateRegistry.register(global_state) + global_state.update_parameter("policy.value", 9) # unsaved: the saved value stays 3 authored_pipeline = Pipeline() submitted = Step() @@ -134,7 +134,7 @@ class Step: untouched: str | None = None set_base_config_type(GlobalConfig) - set_global_config_for_editing(GlobalConfig, GlobalConfig()) + set_global_config(GlobalConfig, GlobalConfig()) step = Step(domain=VolumeDomain()) resolved, provenance = ObjectState.resolve_saved_object( step, diff --git a/tests/test_object_state_update_instance.py b/tests/test_object_state_update_instance.py index 0eab449..cb115b0 100644 --- a/tests/test_object_state_update_instance.py +++ b/tests/test_object_state_update_instance.py @@ -15,7 +15,7 @@ get_live_global_config, mark_global_config_type, set_base_config_type, - set_global_config_for_editing, + set_global_config, ) @@ -334,7 +334,7 @@ class GlobalConfig: set_base_config_type(GlobalConfig) LazyGlobalConfig = LazyDataclassFactory.make_lazy_simple(GlobalConfig) - set_global_config_for_editing(GlobalConfig, GlobalConfig(threshold=1)) + set_global_config(GlobalConfig, GlobalConfig(threshold=1)) global_state = ObjectState(GlobalConfig(threshold=1), scope_id="") child_state = ObjectState(LazyGlobalConfig(), scope_id="plate::step") ObjectStateRegistry.register(global_state, _skip_snapshot=True) diff --git a/tests/test_one_field_resolution.py b/tests/test_one_field_resolution.py index 221becf..7a68545 100644 --- a/tests/test_one_field_resolution.py +++ b/tests/test_one_field_resolution.py @@ -15,12 +15,12 @@ from objectstate import ( LazyDataclassFactory, ObjectState, + ObjectStateRegistry, build_context_stack, config_context, mark_global_config_type, set_base_config_type, - set_global_config_for_editing, - set_live_global_config, + set_global_config, ) from objectstate.context_manager import get_base_global_config, get_context_layer_stack from objectstate.dual_axis_resolver import resolve_with_provenance @@ -60,11 +60,16 @@ class Step: path_planning_config: LazyPathPlanningConfig = field(default_factory=LazyPathPlanningConfig) -def _use_global(saved: GlobalConfig, live: GlobalConfig | None = None) -> None: +@pytest.fixture(autouse=True) +def _empty_registry(): + ObjectStateRegistry.clear() + yield + ObjectStateRegistry.clear() + + +def _use_global(config: GlobalConfig) -> None: set_base_config_type(GlobalConfig) - set_global_config_for_editing(GlobalConfig, saved) - if live is not None: - set_live_global_config(GlobalConfig, live) + set_global_config(GlobalConfig, config) def test_attribute_read_and_provenance_agree_when_a_layer_has_no_scope_id(): @@ -139,10 +144,11 @@ def _object_sets_its_own_value(): def _saved_global_differs_from_live(): - _use_global( - saved=GlobalConfig(WellFilterConfig(well_filter=1)), - live=GlobalConfig(WellFilterConfig(well_filter=9, mode="live-only")), - ) + _use_global(GlobalConfig(WellFilterConfig(well_filter=1))) + global_state = ObjectState(GlobalConfig(WellFilterConfig(well_filter=1)), scope_id="") + ObjectStateRegistry.register(global_state) + global_state.update_parameter("well_filter_config.well_filter", 9) + global_state.update_parameter("well_filter_config.mode", "live-only") return Step(), [("plate", Pipeline())], "plate::step" @@ -231,7 +237,7 @@ class StreamingGlobal: def test_answers_survive_another_thread_entering_and_leaving_a_context(): """Issue #33: a context's answers are not dropped by a context in another thread.""" - saved_global = GlobalConfig(WellFilterConfig(well_filter=1)) + _use_global(GlobalConfig(WellFilterConfig(well_filter=1))) first_resolved = threading.Event() other_thread_done = threading.Event() answers = [] @@ -239,7 +245,6 @@ def test_answers_survive_another_thread_entering_and_leaving_a_context(): def resolve_twice(): try: - _use_global(saved_global) # the global config is per thread with config_context(Pipeline(), scope_id="plate"): answers.append(resolve_with_provenance(LazyPathPlanningConfig, "well_filter")) first_resolved.set() @@ -252,7 +257,6 @@ def resolve_twice(): def enter_and_leave(): try: assert first_resolved.wait(timeout=10) - _use_global(saved_global) # the global config is per thread other = Pipeline(path_planning_config=LazyPathPlanningConfig(well_filter=7)) with config_context(other, scope_id="other"): answers.append(resolve_with_provenance(LazyPathPlanningConfig, "well_filter")) @@ -296,6 +300,6 @@ def test_answers_are_dropped_with_the_context_they_were_computed_from(): def test_base_global_config_is_never_silently_absent(monkeypatch): """A global config that cannot be looked up raises; it is not replaced by ``None``.""" - monkeypatch.delattr("objectstate.global_config.get_current_global_config") - with pytest.raises(ImportError, match="get_current_global_config"): + monkeypatch.delattr("objectstate.global_config.get_live_global_config") + with pytest.raises(ImportError, match="get_live_global_config"): get_base_global_config() diff --git a/tests/test_one_holder_of_the_global_config.py b/tests/test_one_holder_of_the_global_config.py new file mode 100644 index 0000000..ce45b1e --- /dev/null +++ b/tests/test_one_holder_of_the_global_config.py @@ -0,0 +1,389 @@ +"""The global config has one holder. + +While a state of the global config type is registered at the global scope, that +state holds the global config: the live global config is the object its parameters +describe and the saved one is the object it last saved. While none is registered, +the object given to ``set_global_config`` is both. There is nothing to keep in +step, so every history of operations must leave every reader agreeing. + +The first test runs every sequence of up to three operations (edit, save and +restore of the global state, replacing its object, ``set_global_config``, +registering and unregistering it), starting with and without the state +registered, with each operation made in the calling thread or in another one. +After every operation it requires, by every kind of read: + +- the global config is the holder's object, and holds the holder's values; +- a registered and an unregistered descendant read that value: stored on the + state and on the object it builds, by attribute access, as live and saved + resolved value, as reported source and as dirty fields; +- a lazy object no state owns reads it in a context entered from the live and + from the saved global config; +- another thread reads the same; +- the registered descendant was told of a changed live value at once, the + unregistered one is told the same on its next read, and neither is told + anything when the value did not change. + +A model of three values (stored, the state's live, the state's saved) predicts +the values, so agreement on a wrong value fails too. The tests after it cover +undo and redo, a global state that was never given to ``set_global_config``, and +a process that has no state at all. +""" + +import itertools +import threading +from dataclasses import dataclass + +import pytest + +from objectstate import ( + ObjectState, + ObjectStateRegistry, + config_context, + forget_stored_global_config, + get_base_global_config, + get_live_global_config, + get_saved_global_config, + set_base_config_type, + set_global_config, +) +from objectstate import global_config as global_config_module + +from .test_resolved_values_follow_registered_ancestors import ( + GLOBAL, + PATH, + STEP, + GlobalConfig, + Holder, + LazySettings, + Settings, + _inherited, + _reads, +) + +DEFAULT = None # ``Settings.value`` as declared + + +def _config(value: str | None) -> GlobalConfig: + return GlobalConfig(settings=Settings(value=value)) + + +def _in_a_thread(function): + result: list = [] + errors: list[BaseException] = [] + + def run() -> None: + try: + result.append(function()) + except BaseException as error: # noqa: BLE001 - raised again by the caller + errors.append(error) + + thread = threading.Thread(target=run) + thread.start() + thread.join() + if errors: + raise errors[0] + return result[0] + + +def _plain_reads() -> tuple: + """A lazy object no state owns, in contexts entered from the live and saved global config.""" + with config_context(Holder()): + live = LazySettings().value + with config_context(Holder(), use_live_global=False): + saved = LazySettings().value + return live, saved + + +def _global_config_values() -> tuple: + """The value of the global config by each way of asking for it, live then saved.""" + live = get_live_global_config(GlobalConfig) + saved = get_saved_global_config(GlobalConfig) + if live is None: # no global config: contexts start from the declared defaults + assert saved is None + live = saved = get_base_global_config() + assert live == get_base_global_config(use_live=False) == GlobalConfig() + else: + assert get_base_global_config() is live + assert get_base_global_config(use_live=False) is saved + values = live.settings.value, saved.settings.value + assert _plain_reads() == values + return values + + +@dataclass +class Model: + """What the reads must be, computed without the library.""" + + stored: str | None + live: str | None + saved: str | None + registered: bool + + @property + def values(self) -> tuple: + return (self.live, self.saved) if self.registered else (self.stored, self.stored) + + +class World: + """A global state, one registered and one unregistered descendant, and the model.""" + + def __init__(self, *, registered: bool, stored: bool = True) -> None: + ObjectStateRegistry.clear() + set_base_config_type(GlobalConfig) + forget_stored_global_config(GlobalConfig) + if stored: + set_global_config(GlobalConfig, _config("stored")) + self.model = Model("stored" if stored else DEFAULT, "initial", "initial", registered) + self.global_state = ObjectState(_config("initial"), scope_id=GLOBAL) + if registered: + ObjectStateRegistry.register(self.global_state) + self.fresh = (f"value {number}" for number in itertools.count()) + self.told: dict[str, list] = {"registered": [], "unregistered": []} + self.descendants = { + "registered": ObjectState(Holder(), scope_id=STEP), + "unregistered": ObjectState(Holder(), scope_id="plate::unregistered"), + } + ObjectStateRegistry.register(self.descendants["registered"]) + for name, state in self.descendants.items(): + state.get_resolved_value(PATH) + state.on_resolved_changed( + lambda paths, name=name: self.told[name].append(frozenset(paths)) + ) + self.values = self.model.values + self.agree("built") + + # --- operations --------------------------------------------------------------- + def edit(self) -> None: + self.model.live = next(self.fresh) + self.global_state.update_parameter(PATH, self.model.live) + + def save(self) -> None: + self.model.saved = self.model.live + self.global_state.mark_saved() + + def restore(self) -> None: + self.model.live = self.model.saved + self.global_state.restore_saved() + + def replace(self) -> None: + self.model.live = self.model.saved = next(self.fresh) + self.global_state.update_object_instance(_config(self.model.live)) + + def set(self) -> None: + value = next(self.fresh) + if self.model.registered: + self.model.live = self.model.saved = value + else: + self.model.stored = value + set_global_config(GlobalConfig, _config(value)) + + def register(self) -> None: + self.model.registered = True + ObjectStateRegistry.register(self.global_state) + + def unregister(self) -> None: + self.model.registered = False + ObjectStateRegistry.unregister(self.global_state) + + # --- what must hold after every operation --------------------------------------- + def agree(self, after: object) -> None: + live_before = self.values[0] + self.values = expected = self.model.values + changed = [frozenset({PATH})] if expected[0] != live_before else [] + + # Told at once, before anything is read. + assert self.told["registered"] == changed, (after, "registered descendant told") + assert self.told["unregistered"] == [], (after, "nothing holds it to tell it") + + self.holder_and_global_config_are_one(after) + assert _global_config_values() == expected, after + assert _in_a_thread(_global_config_values) == expected, after + + reads = _inherited(expected[0], GLOBAL, saved=expected[1]) + for name, state in self.descendants.items(): + assert _reads(state) == reads, (after, name) + assert _in_a_thread(lambda state=state: _reads(state)) == reads, (after, name, "thread") + + # The unregistered descendant heard the same, on its read. + assert self.told["unregistered"] == changed, (after, "unregistered descendant told") + assert self.told["registered"] == changed, (after, "a read tells nothing more") + for told in self.told.values(): + told.clear() + + def holder_and_global_config_are_one(self, after: object) -> None: + holder = ObjectStateRegistry.global_config_state(GlobalConfig) + live = get_live_global_config(GlobalConfig) + saved = get_saved_global_config(GlobalConfig) + stored = global_config_module._stored_global_configs.get(GlobalConfig) + assert (stored.settings.value if stored else DEFAULT) == self.model.stored, after + if holder is None: + assert not self.model.registered, after + assert live is saved is stored, after + return + assert holder is self.global_state and self.model.registered, after + assert live is holder.object_for_resolution(), after + assert saved is holder.object_for_resolution(use_saved=True), after + assert live.settings.value == holder.parameters[PATH], after + assert saved.settings.value == holder._saved_parameters[PATH], after + assert live is get_live_global_config(GlobalConfig), (after, "one object until a change") + dirty = {PATH} if self.model.live != self.model.saved else set() + assert holder.dirty_fields == dirty, after + + +OPERATIONS = ("edit", "save", "restore", "replace", "set", "register", "unregister") + + +def _histories(length: int): + for size in range(1, length + 1): + yield from itertools.product(OPERATIONS, repeat=size) + + +@pytest.fixture(autouse=True) +def _empty_registry_and_no_stored_global_config(): + yield + ObjectStateRegistry.clear() + forget_stored_global_config(GlobalConfig) + + +@pytest.mark.parametrize("registered", [True, False], ids=["state registered", "no state"]) +@pytest.mark.parametrize("threaded", [False, True], ids=["calling thread", "another thread"]) +def test_every_reader_agrees_after_every_history(registered: bool, threaded: bool): + for history in _histories(3 if not threaded else 2): + world = World(registered=registered) + for position, operation in enumerate(history): + act = getattr(world, operation) + _in_a_thread(act) if threaded else act() + world.agree((history, position)) + + +def test_registering_a_global_state_is_all_it_takes(): + """No ``set_global_config`` call: the registered state's object is the global config.""" + world = World(registered=False, stored=False) + assert get_live_global_config(GlobalConfig) is None + assert world.values == (DEFAULT, DEFAULT) + + world.register() + world.agree("registered") + assert world.values == ("initial", "initial") + + world.edit() + world.agree("edited") + world.unregister() + world.agree("unregistered") + assert get_live_global_config(GlobalConfig) is None + assert world.values == (DEFAULT, DEFAULT) + + world.set() + world.agree("stored") + forget_stored_global_config(GlobalConfig) + world.model.stored = DEFAULT + world.agree("stored object forgotten") # registered states are told at once + + +def test_a_process_without_states_reads_what_was_set(): + """A compile worker or a script: no state, plain lazy objects only, any thread.""" + ObjectStateRegistry.clear() + set_base_config_type(GlobalConfig) + set_global_config(GlobalConfig, _config("set")) + assert _global_config_values() == ("set", "set") + assert _in_a_thread(_global_config_values) == ("set", "set") + + _in_a_thread(lambda: set_global_config(GlobalConfig, _config("set in a thread"))) + assert _global_config_values() == ("set in a thread", "set in a thread") + + +def test_a_context_entered_before_a_global_edit_keeps_what_it_was_entered_with(): + """Editing the global state no longer leaves the caller's context.""" + world = World(registered=True) + with config_context(Holder()): + assert LazySettings().value == "initial" + world.edit() + assert LazySettings().value == "initial" + with config_context(Holder()): + assert LazySettings().value == "initial" + # States do not read the entered context: they follow the edit inside it too. + assert world.descendants["registered"].get_resolved_value(PATH) == world.model.live + world.agree("edited inside a context") + + +def test_global_state_of_another_type_does_not_hold_this_global_config(): + world = World(registered=False) + other = ObjectState(Holder(settings=LazySettings(value="not a global config")), scope_id=GLOBAL) + ObjectStateRegistry.register(other) + assert ObjectStateRegistry.global_config_state(GlobalConfig) is None + assert get_live_global_config(GlobalConfig).settings.value == "stored" + world.set() + assert get_live_global_config(GlobalConfig).settings.value == world.model.stored + + +# --- Undo and redo ---------------------------------------------------------------- + + +def _recorded(world: World, operation: str) -> tuple: + with ObjectStateRegistry.atomic(operation, scope_id=GLOBAL): + getattr(world, operation)() + world.agree(operation) + return world.model.values + + +@pytest.mark.parametrize( + "operations", + [ + ("edit", "edit", "save", "edit"), + ("edit", "save", "edit", "restore"), + ("edit", "replace", "edit", "save"), + ("edit", "set", "edit"), + ], + ids=" ".join, +) +def test_undo_and_redo_move_the_global_config_with_the_state(operations): + world = World(registered=True) + ObjectStateRegistry.record_snapshot("start", GLOBAL) + timeline = [world.model.values] + timeline += [_recorded(world, operation) for operation in operations] + + def travelled_to(index: int, direction: str) -> None: + world.model.live, world.model.saved = timeline[index] + world.agree((direction, index)) + + for index in reversed(range(len(timeline) - 1)): + assert ObjectStateRegistry.time_travel_back() + travelled_to(index, "back") + for index in range(1, len(timeline)): + assert ObjectStateRegistry.time_travel_forward() + travelled_to(index, "forward") + + # Operations after time travel start from where it ended. + for operation in ("edit", "save", "restore", "set", "unregister", "register"): + getattr(world, operation)() + world.agree(("after time travel", operation)) + + +def test_time_travel_to_before_the_global_state_was_registered(): + """Without its state the global config is again what was stored; redo brings the state back.""" + world = World(registered=False) + ObjectStateRegistry.record_snapshot("no global state", STEP) + with ObjectStateRegistry.atomic("register the global state", scope_id=GLOBAL): + world.register() + world.agree("registered") + with ObjectStateRegistry.atomic("edit", scope_id=GLOBAL): + world.edit() + world.agree("edited") + edited = world.model.live + + assert ObjectStateRegistry.time_travel_back() + world.model.live = "initial" + world.agree("back to the registration") + + assert ObjectStateRegistry.time_travel_back() + world.model.registered = False + world.agree("back to before the registration") + assert world.values == ("stored", "stored") + + assert ObjectStateRegistry.time_travel_forward() + world.model.registered = True + world.agree("forward to the registration") + + assert ObjectStateRegistry.time_travel_forward() + world.model.live = edited + world.agree("forward to the edit") diff --git a/tests/test_resolution_precedence_order.py b/tests/test_resolution_precedence_order.py index 1749917..4d4f20f 100644 --- a/tests/test_resolution_precedence_order.py +++ b/tests/test_resolution_precedence_order.py @@ -33,7 +33,7 @@ build_context_stack, mark_global_config_type, set_base_config_type, - set_global_config_for_editing, + set_global_config, ) from objectstate.dual_axis_resolver import resolve_with_provenance @@ -140,7 +140,7 @@ class Reads(NamedTuple): 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) + set_global_config(GlobalConfig, saved_global) ancestors = [(PLATE, plate)] with build_context_stack(step, ancestors, STEP, use_live=False): @@ -281,7 +281,7 @@ def test_read_kinds_of_an_editing_state_stay_distinct_and_agree_on_a_crossed_pai 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) + set_global_config(GlobalConfig, saved_global) states = [] try: # An ancestor is registered before the state below it is built. diff --git a/tests/test_resolved_values_follow_registered_ancestors.py b/tests/test_resolved_values_follow_registered_ancestors.py index 546883f..1f99eb7 100644 --- a/tests/test_resolved_values_follow_registered_ancestors.py +++ b/tests/test_resolved_values_follow_registered_ancestors.py @@ -47,9 +47,7 @@ mark_global_config_type, resolve_with_provenance, set_base_config_type, - set_global_config_for_editing, - set_live_global_config, - set_saved_global_config, + set_global_config, spawn_thread_with_context, ) @@ -148,7 +146,7 @@ def _step_state() -> ObjectState: def _global_config_and_empty_registry(): ObjectStateRegistry.clear() set_base_config_type(GlobalConfig) - set_global_config_for_editing(GlobalConfig, GlobalConfig(settings=Settings(value="global"))) + set_global_config(GlobalConfig, GlobalConfig(settings=Settings(value="global"))) yield ObjectStateRegistry.clear() @@ -486,12 +484,15 @@ def test_edit_two_levels_up(registered): assert _reads(inner) == _inherited("edited", PLATE) -def _replace_global(value: str, *, live: bool = True, saved: bool = False) -> None: - config = GlobalConfig(settings=Settings(value=value)) - if live: - set_live_global_config(GlobalConfig, config) - if saved: - set_saved_global_config(GlobalConfig, config) +def _replace_global(value: str) -> None: + set_global_config(GlobalConfig, GlobalConfig(settings=Settings(value=value))) + + +def _register_global_state() -> ObjectState: + """From here on the global config is this state's: an edit is live, a save is saved.""" + global_state = ObjectState(get_live_global_config(GlobalConfig), scope_id=GLOBAL) + ObjectStateRegistry.register(global_state) + return global_state @pytest.mark.parametrize("registered", [True, False], ids=["registered", "not registered"]) @@ -503,17 +504,17 @@ def test_global_config_replaced_after_the_build(registered): step.on_resolved_changed(changes.append) _replace_global("replaced") - assert _reads(step) == _inherited("replaced", GLOBAL, saved="global") + assert _reads(step) == _inherited("replaced", GLOBAL) assert changes == [{PATH}] - _replace_global("saved too", saved=True) - assert _reads(step) == _inherited("saved too", GLOBAL) - - _replace_global("only saved", live=False, saved=True) - assert _reads(step) == _inherited("saved too", GLOBAL, saved="only saved") + global_state = _register_global_state() + assert _reads(step) == _inherited("replaced", GLOBAL) + global_state.update_parameter(PATH, "edited") + assert _reads(step) == _inherited("edited", GLOBAL, saved="replaced") - set_global_config_for_editing(GlobalConfig, GlobalConfig(settings=Settings(value="both"))) - assert _reads(step) == _inherited("both", GLOBAL) + _replace_global("replaced again") # replaces the object of the state + assert _reads(step) == _inherited("replaced again", GLOBAL) + assert global_state.parameters[PATH] == "replaced again" assert changes == [{PATH}, {PATH}, {PATH}] @@ -522,7 +523,7 @@ def test_global_config_replaced_with_equal_values_resolves_nothing(resolved_fiel live_snapshot = step._live_resolved resolved_fields.clear() - set_global_config_for_editing(GlobalConfig, GlobalConfig(settings=Settings(value="global"))) + set_global_config(GlobalConfig, GlobalConfig(settings=Settings(value="global"))) assert step.get_resolved_value(PATH) == "global" assert step.get_saved_resolved_value(PATH) == "global" @@ -545,10 +546,10 @@ def test_global_config_replaced_then_an_ancestor_edited(step_under_plate, resolv assert step.get_resolved_value("settings.other") == "edited other" assert step.get_provenance(PATH) == (GLOBAL, Settings) assert step.get_provenance("settings.other") == (PLATE, Settings) - assert step.dirty_fields == {PATH, "settings.other"} + assert step.dirty_fields == {"settings.other"} plate.update_parameter(PATH, "plate again") - assert _reads(step) == _inherited("plate again", PLATE, saved="global")._replace( + assert _reads(step) == _inherited("plate again", PLATE, saved="replaced")._replace( dirty={PATH, "settings.other"} ) @@ -567,13 +568,18 @@ def test_global_state_edit_reaches_states_two_levels_down(step_under_plate): assert _reads(step) == _inherited("typed", GLOBAL) -def test_another_thread_does_not_make_a_state_resolve_again(step_under_plate, resolved_fields): - """The global config is stored per thread; a snapshot follows the thread it was resolved in. +def _plain_read() -> str | None: + """A lazy object no state owns, read in a context entered from the global config.""" + with config_context(Holder()): + return LazySettings().value - A thread started without one (``threading.Thread`` copies no thread-local value) - reads the values the state has, and a thread that sets its own does not change - them. The registry's stores are one per process and not synchronised, so the - threads here run one after the other. + +def test_every_thread_reads_the_same_global_config(step_under_plate, resolved_fields): + """The global config is one per process, like the registry. + + A thread reads the values the state has without resolving anything again, and + a global config a thread sets is the global config of every thread. Nothing here + is synchronised, so the threads run one after the other. """ step, plate = step_under_plate plate.update_parameter(PATH, None) @@ -584,28 +590,34 @@ def test_another_thread_does_not_make_a_state_resolve_again(step_under_plate, re resolved_fields.clear() seen: list[tuple] = [] - def read_without_a_global_config(): - seen.append((step.get_resolved_value(PATH), step.get_saved_resolved_value(PATH))) - - def read_with_its_own_global_config(): - set_global_config_for_editing( - GlobalConfig, GlobalConfig(settings=Settings(value="other thread")) + def read(): + seen.append( + ( + step.get_resolved_value(PATH), + step.get_saved_resolved_value(PATH), + _plain_read(), + ) ) - read_without_a_global_config() - for target in (read_without_a_global_config, read_with_its_own_global_config): + def set_and_read(): + _replace_global("other thread") + read() + + def in_a_thread(target): thread = threading.Thread(target=target) thread.start() thread.join() - assert seen == [expected, expected] - assert (step.get_resolved_value(PATH), step.get_saved_resolved_value(PATH)) == expected + in_a_thread(read) + assert seen == [(*expected, "global")] assert resolved_fields == [] and changes == [] - # An ancestor edit is followed from any thread: ancestors are one per process. - thread = threading.Thread(target=lambda: plate.update_parameter(PATH, "from a thread")) - thread.start() - thread.join() + in_a_thread(set_and_read) + assert seen[1] == ("other thread", "plate", "other thread") + assert (step.get_resolved_value(PATH), _plain_read()) == ("other thread", "other thread") + assert changes == [{PATH}] + + in_a_thread(lambda: plate.update_parameter(PATH, "from a thread")) assert step.get_resolved_value(PATH) == "from a thread" @@ -655,7 +667,7 @@ def _ambient_global_config(): def _start_over() -> None: ObjectStateRegistry.clear() set_base_config_type(GlobalConfig) - set_global_config_for_editing(GlobalConfig, GlobalConfig(settings=Settings(value="global"))) + set_global_config(GlobalConfig, GlobalConfig(settings=Settings(value="global"))) def _history(entered) -> list: @@ -666,6 +678,7 @@ def _history(entered) -> list: """ plate = ObjectState(Holder(settings=LazySettings(other="plate other")), scope_id=PLATE) ObjectStateRegistry.register(plate) + global_state = _register_global_state() states: dict[str, ObjectState] = {} told: list[tuple] = [] history: list[tuple] = [] @@ -702,7 +715,7 @@ def record(event: str) -> None: late = _step_state_at(f"{PLATE}::late") record("built") - _replace_global("replaced") + global_state.update_parameter(PATH, "replaced") record("global config replaced") plate.update_parameter(PATH, "edited") @@ -714,7 +727,8 @@ def record(event: str) -> None: plate.update_parameter(OTHER_PATH, None) record("ancestor gives a field up") - _replace_global("saved too", saved=True) + global_state.update_parameter(PATH, "saved too") + global_state.mark_saved() record("saved global config replaced") before.invalidate_cache() @@ -740,9 +754,6 @@ def record(event: str) -> None: 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") @@ -751,6 +762,16 @@ def record(event: str) -> None: global_state.mark_saved() record("global state saved") + global_state.update_parameter(PATH, "given up") + global_state.restore_saved() + record("global state restored") + + _replace_global("set from outside") + record("global config set from outside") + + ObjectStateRegistry.unregister(global_state) + record("global state unregistered") + follow("first read after the block", late) record("after the block") return history @@ -777,7 +798,14 @@ def value_reads(event: str) -> set: } 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)} + assert value_reads("global state saved") == {_inherited("typed", GLOBAL)} + assert value_reads("global state restored") == {_inherited("typed", GLOBAL)} + assert value_reads("global config set from outside") == { + _inherited("set from outside", GLOBAL) + } + # What was stored before the state was registered is the global config again. + assert value_reads("global state unregistered") == {FROM_GLOBAL} + assert value_reads("after the block") == {FROM_GLOBAL} @pytest.mark.parametrize("entered", ENTERED) @@ -877,12 +905,9 @@ def hold_a_context() -> None: 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): @@ -891,81 +916,3 @@ def read() -> None: 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" diff --git a/tests/test_scopeless_state_resolution.py b/tests/test_scopeless_state_resolution.py index 32a3088..4fa65ce 100644 --- a/tests/test_scopeless_state_resolution.py +++ b/tests/test_scopeless_state_resolution.py @@ -15,7 +15,7 @@ config_context, mark_global_config_type, set_base_config_type, - set_global_config_for_editing, + set_global_config, ) from objectstate.inspection import FieldInspection @@ -54,7 +54,7 @@ class Pipeline: @pytest.fixture(params=[None, "plate"], ids=["no scope id", "scope id"]) def state(request) -> ObjectState: set_base_config_type(GlobalConfig) - set_global_config_for_editing( + set_global_config( GlobalConfig, GlobalConfig(WellFilterConfig(well_filter=1, mode="global")) ) return ObjectState(Pipeline(), scope_id=request.param)