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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/api/modules.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``
Expand Down
39 changes: 19 additions & 20 deletions docs/architecture.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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:**

Expand All @@ -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)
Expand All @@ -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.
4 changes: 2 additions & 2 deletions docs/examples/basic.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}")
Expand Down
2 changes: 1 addition & 1 deletion docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions docs/quickstart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -192,16 +192,16 @@ 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

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
----------------
Expand Down
59 changes: 40 additions & 19 deletions docs/state_propagation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.


Loading
Loading