objectstate 3.0.1__tar.gz → 3.0.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {objectstate-3.0.1 → objectstate-3.0.2}/.github/badges/coverage.svg +1 -1
- {objectstate-3.0.1 → objectstate-3.0.2}/PKG-INFO +1 -1
- {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/__init__.py +1 -1
- {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/docs/configuration_framework.rst +1 -1
- {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/docs/context_system.rst +31 -4
- {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/dual_axis_resolver.py +9 -4
- {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/object_state.py +68 -7
- {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/object_state_registry.py +54 -39
- objectstate-3.0.2/tests/test_resolution_precedence_order.py +315 -0
- objectstate-3.0.2/tests/test_resolved_values_follow_registered_ancestors.py +341 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/.github/workflows/ci.yml +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/.github/workflows/coverage-pages.yml +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/.github/workflows/publish.yml +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/.gitignore +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/.readthedocs.yaml +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/BADGE_SETUP.md +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/LICENSE +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/README.md +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/docs/.gitignore +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/docs/Makefile +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/docs/api/modules.rst +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/docs/architectural_fixes.rst +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/docs/architecture.rst +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/docs/archive/DOCUMENTATION_AUDIT.md +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/docs/archive/DOCUMENTATION_FIXES.md +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/docs/archive/README.md +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/docs/conf.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/docs/examples/atomic_operations.rst +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/docs/examples/basic.rst +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/docs/examples/dual_axis.rst +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/docs/examples/index.rst +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/docs/examples/ui.rst +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/docs/index.rst +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/docs/make.bat +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/docs/performance/canonical-context-type-resolution-20261005.json +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/docs/provenance.rst +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/docs/quickstart.rst +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/docs/state_management.rst +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/docs/state_propagation.rst +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/docs/undo_redo.rst +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/paper.bib +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/paper.md +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/plans/n_dimensional_inheritance.md +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/pyproject.toml +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/scripts/check_ruff_added_lines.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/scripts/release.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/scripts/update_and_release.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/scripts/update_version.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/scripts/verify_release_ready.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/codegen.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/collection_containers.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/config.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/construction_binding.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/context_manager.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/docs/object_state_lifecycle.rst +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/edit_session.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/field_access.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/global_config.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/inspection.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/lazy_factory.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/live_context_resolver.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/object_state_metadata.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/parameter_owner.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/parameter_structure.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/parametric_axes.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/placeholder.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/reified_generics.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/scope_key.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/snapshot_model.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/subfield_semantics.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/time_travel_profile.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/token_cache.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/transaction_checkpoint.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/ui_visibility.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/src/objectstate/value_semantics.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/__init__.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/conftest.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_atomic_block_context.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_authored_paths.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_callable_baseline.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_codegen.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_config.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_construction_history.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_context_failures_surface.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_context_manager.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_field_access.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_global_config.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_history_persistence.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_inspection.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_integration.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_lazy_counterpart_registry.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_lazy_factory.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_live_context_resolver_answers.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_object_state_metadata_contracts.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_object_state_restore.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_object_state_update_instance.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_one_field_resolution.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_parameter_owner.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_parametric_axes.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_placeholder.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_registry_subscription.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_reified_generics.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_release_readiness.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_resolved_change_notifications.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_scope_key.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_scopeless_state_resolution.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_subfield_semantics.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_timeline_projection.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_transaction_checkpoint.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_ui_visible_field_projection.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_undo_redo_cascade.py +0 -0
- {objectstate-3.0.1 → objectstate-3.0.2}/tests/test_value_semantics.py +0 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="114" height="20" role="img" aria-label="coverage:
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="114" height="20" role="img" aria-label="coverage: 81.07%"><title>coverage: 81.07%</title><filter id="blur"><feGaussianBlur stdDeviation="16"/></filter><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="114" height="20" rx="3"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="53" height="20" fill="#67ac09"/><rect width="114" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><g transform="scale(.1)"><g aria-hidden="true" fill="#010101"><text x="315" y="150" fill-opacity=".8" filter="url(#blur)" textLength="510">coverage</text><text x="315" y="150" fill-opacity=".3" textLength="510">coverage</text></g><text x="315" y="140" textLength="510">coverage</text></g><g transform="scale(.1)"><g aria-hidden="true" fill="#010101"><text x="865" y="150" fill-opacity=".8" filter="url(#blur)" textLength="430">81.07%</text><text x="865" y="150" fill-opacity=".3" textLength="430">81.07%</text></g><text x="865" y="140" textLength="430">81.07%</text></g></g></svg>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: objectstate
|
|
3
|
-
Version: 3.0.
|
|
3
|
+
Version: 3.0.2
|
|
4
4
|
Summary: Generic lazy dataclass configuration framework with dual-axis inheritance and contextvars-based resolution
|
|
5
5
|
Project-URL: Homepage, https://github.com/OpenHCSDev/objectstate
|
|
6
6
|
Project-URL: Documentation, https://objectstate.readthedocs.io
|
|
@@ -149,7 +149,7 @@ Usage Example
|
|
|
149
149
|
)
|
|
150
150
|
)
|
|
151
151
|
|
|
152
|
-
The lazy configs resolve
|
|
152
|
+
The lazy configs resolve class first: ``DatabaseConfig`` is looked up in the ``AppConfig`` layer, then in the ``GlobalAppConfig`` layer, and only if neither holds a value is a base class of ``DatabaseConfig`` tried in those layers. A value on the more specific class in the global config therefore beats a value on a base class in the app config. :ref:`precedence-order` states the order with an example.
|
|
153
153
|
|
|
154
154
|
Framework Modules
|
|
155
155
|
----------------
|
|
@@ -195,10 +195,37 @@ The dual-axis resolver reads the layer stack of the active context:
|
|
|
195
195
|
|
|
196
196
|
value, source_scope, source_type = resolve_with_provenance(LazyDatabaseConfig, "port")
|
|
197
197
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
198
|
+
Attribute access on a lazy config returns the ``value`` of this same result.
|
|
199
|
+
|
|
200
|
+
.. _precedence-order:
|
|
201
|
+
|
|
202
|
+
Precedence Order
|
|
203
|
+
~~~~~~~~~~~~~~~~
|
|
204
|
+
|
|
205
|
+
Resolution is class first. For each class in the MRO of the type being read, most specific
|
|
206
|
+
first, the resolver asks every layer from the innermost scope to the outermost for a config
|
|
207
|
+
of that class and returns the first value that is not ``None``, together with the layer's
|
|
208
|
+
scope id and the class it was read from. Only when no layer holds a value for a class is
|
|
209
|
+
the next, less specific class tried.
|
|
210
|
+
|
|
211
|
+
So the class decides before the scope does. Take ``Leaf(Base)``, a field ``value`` read on
|
|
212
|
+
``Leaf`` in a step, and two layers that hold a value:
|
|
213
|
+
|
|
214
|
+
========================== ====================== =====================
|
|
215
|
+
Layer (scope) ``Base.value`` ``Leaf.value``
|
|
216
|
+
========================== ====================== =====================
|
|
217
|
+
plate (inner) ``"base at plate"`` ``None``
|
|
218
|
+
global (outer) ``None`` ``"leaf at global"``
|
|
219
|
+
========================== ====================== =====================
|
|
220
|
+
|
|
221
|
+
The read returns ``"leaf at global"`` with source ``("", Leaf)``: ``Leaf`` is tried in
|
|
222
|
+
every scope before ``Base`` is tried in any, although the plate is the nearer scope. A
|
|
223
|
+
value on the same class in a nearer scope does win: with ``Leaf.value`` also set on the
|
|
224
|
+
plate, the plate's value is returned.
|
|
225
|
+
|
|
226
|
+
This is the only statement of the order. ``objectstate.dual_axis_resolver`` implements it
|
|
227
|
+
in ``_resolve_in_layers``; ``tests/test_resolution_precedence_order.py`` checks it for
|
|
228
|
+
every set of (class, scope) sites.
|
|
202
229
|
|
|
203
230
|
Usage Pattern
|
|
204
231
|
------------
|
|
@@ -151,11 +151,16 @@ def resolve_in_stack(stack, container_type: type, field_name: str) -> ResolvedFi
|
|
|
151
151
|
|
|
152
152
|
|
|
153
153
|
def _resolve_in_layers(layers: tuple, container_type: type, field_name: str) -> ResolvedField:
|
|
154
|
-
"""
|
|
154
|
+
"""Resolve class first: the MRO is the outer loop, the layers the inner one.
|
|
155
155
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
156
|
+
For each class in the container type's MRO, most specific first, every layer is
|
|
157
|
+
asked from the innermost scope to the outermost; the first value that is not
|
|
158
|
+
``None`` wins. ``None`` in an inner layer means "inherit", so a less specific
|
|
159
|
+
class is tried only after every layer was asked for the more specific one.
|
|
160
|
+
A layer that masks the layers outside it ends the walk outward.
|
|
161
|
+
|
|
162
|
+
The order is stated with an example in ``docs/context_system.rst`` under
|
|
163
|
+
"Precedence Order"; this function is its implementation.
|
|
159
164
|
"""
|
|
160
165
|
container_base = _normalize_to_base(container_type)
|
|
161
166
|
|
|
@@ -226,6 +226,10 @@ class ObjectState:
|
|
|
226
226
|
# Maps dotted_path → (source_scope_id, source_type) for inherited fields
|
|
227
227
|
# source_type may differ from local container_type due to MRO inheritance
|
|
228
228
|
self._live_provenance: dict[str, tuple[str | None, type | None]] = {}
|
|
229
|
+
# The registered ancestors each resolved snapshot was computed from, keyed by
|
|
230
|
+
# ``use_saved``. A snapshot is current only while ``_registered_ancestors()``
|
|
231
|
+
# still returns the same states.
|
|
232
|
+
self._ancestors_resolved_from: dict[bool, tuple[ObjectState, ...]] = {}
|
|
229
233
|
|
|
230
234
|
# === Saved baseline (2 attributes) ===
|
|
231
235
|
self._saved_resolved: dict[str, Any] = {}
|
|
@@ -476,8 +480,7 @@ class ObjectState:
|
|
|
476
480
|
def to_saved_resolved_object(self) -> Any:
|
|
477
481
|
"""Reconstruct this state as an object with saved resolved values."""
|
|
478
482
|
self._check_and_sync_delegate()
|
|
479
|
-
|
|
480
|
-
self._saved_resolved = self._compute_resolved_snapshot(use_saved=True)
|
|
483
|
+
self._ensure_saved_resolved()
|
|
481
484
|
return self._reconstruct_from_resolved(
|
|
482
485
|
"", self._saved_resolved, self._extraction_target, self._parameter_structure
|
|
483
486
|
)
|
|
@@ -742,6 +745,12 @@ class ObjectState:
|
|
|
742
745
|
self._invalid_fields.clear()
|
|
743
746
|
return set() # First populate - no "changes" to flash
|
|
744
747
|
|
|
748
|
+
if not self._resolved_from_registered_ancestors():
|
|
749
|
+
# Registration changed above this state: every inherited field may have
|
|
750
|
+
# another source, so both snapshots are resolved again in full. Pending
|
|
751
|
+
# field invalidations are covered by the full pass.
|
|
752
|
+
return self._resolve_again_from_registered_ancestors(notify=notify_flash)
|
|
753
|
+
|
|
745
754
|
# Partial recompute for invalid fields only
|
|
746
755
|
if self._invalid_fields:
|
|
747
756
|
logger.debug(f"🔄 _ensure_live_resolved: scope={self.scope_id}, recomputing {len(self._invalid_fields)} invalid fields: {list(self._invalid_fields)}")
|
|
@@ -759,6 +768,60 @@ class ObjectState:
|
|
|
759
768
|
|
|
760
769
|
return changed_paths
|
|
761
770
|
|
|
771
|
+
def _registered_ancestors(self) -> tuple[ObjectState, ...]:
|
|
772
|
+
"""The other states this state's resolved values are computed from.
|
|
773
|
+
|
|
774
|
+
This state is left out: registering it adds no value it does not hold.
|
|
775
|
+
"""
|
|
776
|
+
return tuple(
|
|
777
|
+
state
|
|
778
|
+
for _, state in ObjectStateRegistry.registered_ancestors(self.scope_id)
|
|
779
|
+
if state is not self
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
def _resolved_from_registered_ancestors(self) -> bool:
|
|
783
|
+
"""Whether both resolved snapshots were computed from today's ancestors."""
|
|
784
|
+
ancestors = self._registered_ancestors()
|
|
785
|
+
return all(
|
|
786
|
+
self._ancestors_resolved_from.get(use_saved) == ancestors
|
|
787
|
+
for use_saved in (False, True)
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
def _resolve_again_from_registered_ancestors(self, *, notify: bool) -> set[str]:
|
|
791
|
+
"""Resolve the live and saved snapshots in full; return the changed live paths."""
|
|
792
|
+
previous = self._live_resolved or {}
|
|
793
|
+
self._live_resolved = self._compute_resolved_snapshot()
|
|
794
|
+
self._invalid_fields.clear()
|
|
795
|
+
self._saved_resolved = self._compute_resolved_snapshot(use_saved=True)
|
|
796
|
+
changed_values = {
|
|
797
|
+
path: (previous.get(path), value)
|
|
798
|
+
for path, value in self._live_resolved.items()
|
|
799
|
+
if previous.get(path) != value
|
|
800
|
+
}
|
|
801
|
+
if changed_values:
|
|
802
|
+
self._set_last_changed_values(changed_values)
|
|
803
|
+
changed_paths = self.notification_fields_for_value_changes(changed_values)
|
|
804
|
+
self._sync_materialized_state(
|
|
805
|
+
changed_value_fields=changed_paths, emit_notifications=notify
|
|
806
|
+
)
|
|
807
|
+
return changed_paths
|
|
808
|
+
|
|
809
|
+
def resolve_again_if_ancestors_changed(self) -> None:
|
|
810
|
+
"""Resolve again when other states are registered above this one than before.
|
|
811
|
+
|
|
812
|
+
Every resolved read makes this check itself. ``ObjectStateRegistry`` calls it
|
|
813
|
+
when registration changes so subscribers are told at once.
|
|
814
|
+
"""
|
|
815
|
+
if self._live_resolved is not None:
|
|
816
|
+
self._ensure_live_resolved()
|
|
817
|
+
|
|
818
|
+
def _ensure_saved_resolved(self) -> None:
|
|
819
|
+
"""Make ``_saved_resolved`` hold every field, resolved from today's ancestors."""
|
|
820
|
+
if self._live_resolved is not None and not self._resolved_from_registered_ancestors():
|
|
821
|
+
self._resolve_again_from_registered_ancestors(notify=True)
|
|
822
|
+
elif not self._saved_resolved:
|
|
823
|
+
self._saved_resolved = self._compute_resolved_snapshot(use_saved=True)
|
|
824
|
+
|
|
762
825
|
# DELETED: _create_nested_states() - No longer needed with flat storage
|
|
763
826
|
# Nested ObjectStates are no longer created - flat storage handles all parameters
|
|
764
827
|
|
|
@@ -1157,9 +1220,7 @@ class ObjectState:
|
|
|
1157
1220
|
# Auto-detect delegate changes before resolving values
|
|
1158
1221
|
self._check_and_sync_delegate()
|
|
1159
1222
|
|
|
1160
|
-
|
|
1161
|
-
if not self._saved_resolved:
|
|
1162
|
-
self._saved_resolved = self._compute_resolved_snapshot(use_saved=True)
|
|
1223
|
+
self._ensure_saved_resolved()
|
|
1163
1224
|
|
|
1164
1225
|
# Check if this is a container/dataclass field (has subfields in _saved_resolved)
|
|
1165
1226
|
prefix = f"{param_name}."
|
|
@@ -1190,8 +1251,7 @@ class ObjectState:
|
|
|
1190
1251
|
self._check_and_sync_delegate()
|
|
1191
1252
|
self._ensure_live_resolved()
|
|
1192
1253
|
assert self._live_resolved is not None
|
|
1193
|
-
|
|
1194
|
-
self._saved_resolved = self._compute_resolved_snapshot(use_saved=True)
|
|
1254
|
+
self._ensure_saved_resolved()
|
|
1195
1255
|
|
|
1196
1256
|
path = owner_field_path
|
|
1197
1257
|
raw_value = self.parameters.get(path.value, MISSING)
|
|
@@ -2092,6 +2152,7 @@ class ObjectState:
|
|
|
2092
2152
|
raise TypeError("ObjectState.parameters must remain a canonical flat dictionary.")
|
|
2093
2153
|
if not isinstance(self._saved_parameters, dict):
|
|
2094
2154
|
raise TypeError("ObjectState._saved_parameters must remain a canonical flat dictionary.")
|
|
2155
|
+
self._ancestors_resolved_from[use_saved] = self._registered_ancestors()
|
|
2095
2156
|
ancestors = ObjectStateRegistry.get_ancestor_objects_with_scopes(
|
|
2096
2157
|
self.scope_id, use_saved=use_saved
|
|
2097
2158
|
)
|
|
@@ -17,6 +17,7 @@ from typing import (
|
|
|
17
17
|
Tuple,
|
|
18
18
|
TYPE_CHECKING,
|
|
19
19
|
Generator,
|
|
20
|
+
Iterable,
|
|
20
21
|
TypeAlias,
|
|
21
22
|
)
|
|
22
23
|
import copy
|
|
@@ -409,6 +410,7 @@ class ObjectStateRegistry:
|
|
|
409
410
|
cls._states[key] = state
|
|
410
411
|
cls.mark_snapshot_dirty_scope(key)
|
|
411
412
|
logger.debug(f"Registered ObjectState: scope={key}, type={type(state.object_instance).__name__}")
|
|
413
|
+
cls._resolve_again_below((key,))
|
|
412
414
|
|
|
413
415
|
# Fire callbacks for UI binding
|
|
414
416
|
cls._fire_register_callbacks(key, state)
|
|
@@ -433,6 +435,7 @@ class ObjectStateRegistry:
|
|
|
433
435
|
# that reference this ObjectState. Graveyard persists for session lifetime.
|
|
434
436
|
cls._graveyard[key] = removed_state
|
|
435
437
|
logger.debug(f"Unregistered ObjectState: scope={key} (moved to graveyard)")
|
|
438
|
+
cls._resolve_again_below((key,))
|
|
436
439
|
|
|
437
440
|
# Fire callbacks for UI binding
|
|
438
441
|
cls._fire_unregister_callbacks(key, state)
|
|
@@ -440,6 +443,36 @@ class ObjectStateRegistry:
|
|
|
440
443
|
# Structural unregistration is not a user-edit boundary. The
|
|
441
444
|
# graveyard preserves state for explicit operation snapshots.
|
|
442
445
|
|
|
446
|
+
@classmethod
|
|
447
|
+
def _resolve_again_below(cls, changed_scopes: Iterable[ScopeKey]) -> None:
|
|
448
|
+
"""Tell registered states at or below ``changed_scopes`` that registration changed.
|
|
449
|
+
|
|
450
|
+
Each state compares the ancestors registered now with the ones its resolved
|
|
451
|
+
values were computed from (``ObjectState.resolve_again_if_ancestors_changed``),
|
|
452
|
+
so a state whose ancestors are the same does no work. This call only makes
|
|
453
|
+
the comparison happen now, so subscribers hear of changed values without
|
|
454
|
+
waiting for the next read.
|
|
455
|
+
"""
|
|
456
|
+
changed_scopes = tuple(changed_scopes)
|
|
457
|
+
for state in tuple(cls._states.values()):
|
|
458
|
+
if any(scope.contains(state.scope_id) for scope in changed_scopes):
|
|
459
|
+
state.resolve_again_if_ancestors_changed()
|
|
460
|
+
|
|
461
|
+
@classmethod
|
|
462
|
+
def registered_ancestors(
|
|
463
|
+
cls, scope_id: str | None
|
|
464
|
+
) -> tuple[tuple[ScopeKey, 'ObjectState'], ...]:
|
|
465
|
+
"""The states registered at the global scope, each prefix of ``scope_id`` and
|
|
466
|
+
``scope_id`` itself, least specific first.
|
|
467
|
+
|
|
468
|
+
Resolved values of a state at ``scope_id`` are computed from these states.
|
|
469
|
+
"""
|
|
470
|
+
return tuple(
|
|
471
|
+
(ancestor_key, cls._states[ancestor_key])
|
|
472
|
+
for ancestor_key in cls._normalize_scope_id(scope_id).ancestors()
|
|
473
|
+
if ancestor_key in cls._states
|
|
474
|
+
)
|
|
475
|
+
|
|
443
476
|
@classmethod
|
|
444
477
|
def unregister_scope_and_descendants(cls, scope_id: Optional[str], _skip_snapshot: bool = False) -> int:
|
|
445
478
|
"""Unregister an ObjectState and all its descendants from the registry.
|
|
@@ -474,8 +507,9 @@ class ObjectStateRegistry:
|
|
|
474
507
|
# NOTE: Unlike plain unregister(), this is often used for hierarchical deletes.
|
|
475
508
|
# When history is enabled we must preserve removed states for time-travel (undo/redo),
|
|
476
509
|
# otherwise snapshots may reference scopes that can no longer be resurrected.
|
|
477
|
-
for key in scopes_to_delete
|
|
478
|
-
|
|
510
|
+
removed = [(key, cls._states.pop(key)) for key in scopes_to_delete]
|
|
511
|
+
cls._resolve_again_below(scopes_to_delete)
|
|
512
|
+
for key, state in removed:
|
|
479
513
|
if cls._history_enabled:
|
|
480
514
|
cls._graveyard[key] = state
|
|
481
515
|
logger.debug(f"Unregistered ObjectState (cascade): scope={key} (moved to graveyard)")
|
|
@@ -669,21 +703,10 @@ class ObjectStateRegistry:
|
|
|
669
703
|
List of objects from ancestor scopes, ordered least→most specific.
|
|
670
704
|
Each object is from state.object_instance (saved) or state.to_object() (live).
|
|
671
705
|
"""
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
for ancestor_key in scope_key.ancestors():
|
|
677
|
-
state = cls._states.get(ancestor_key)
|
|
678
|
-
if state:
|
|
679
|
-
if use_saved:
|
|
680
|
-
# Return saved baseline (saved_object handles delegation correctly)
|
|
681
|
-
objects.append(state.saved_object)
|
|
682
|
-
else:
|
|
683
|
-
# Return live state with current edits
|
|
684
|
-
objects.append(state.to_object(update_delegate=False))
|
|
685
|
-
|
|
686
|
-
return objects
|
|
706
|
+
return [
|
|
707
|
+
state.saved_object if use_saved else state.to_object(update_delegate=False)
|
|
708
|
+
for _, state in cls.registered_ancestors(scope_id)
|
|
709
|
+
]
|
|
687
710
|
|
|
688
711
|
@classmethod
|
|
689
712
|
def get_ancestor_objects_with_scopes(
|
|
@@ -714,28 +737,20 @@ class ObjectStateRegistry:
|
|
|
714
737
|
Returns:
|
|
715
738
|
List of (scope_id, object) tuples from ancestor scopes, ordered least→most specific.
|
|
716
739
|
"""
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
#
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
else:
|
|
732
|
-
# For live state, reconstruct from current parameters
|
|
733
|
-
# Control whether delegate sync happens
|
|
734
|
-
obj = state.to_object(update_delegate=False, sync_delegate=not skip_delegate_sync)
|
|
735
|
-
|
|
736
|
-
results.append((ancestor_key, obj))
|
|
737
|
-
|
|
738
|
-
return results
|
|
740
|
+
# Saved: the object as last saved, without delegate sync or reconstruction.
|
|
741
|
+
# Live: rebuilt from the current parameters; ``skip_delegate_sync`` decides
|
|
742
|
+
# whether the delegate is synchronised first.
|
|
743
|
+
return [
|
|
744
|
+
(
|
|
745
|
+
ancestor_key,
|
|
746
|
+
state._extraction_target
|
|
747
|
+
if use_saved
|
|
748
|
+
else state.to_object(
|
|
749
|
+
update_delegate=False, sync_delegate=not skip_delegate_sync
|
|
750
|
+
),
|
|
751
|
+
)
|
|
752
|
+
for ancestor_key, state in cls.registered_ancestors(scope_id)
|
|
753
|
+
]
|
|
739
754
|
|
|
740
755
|
# ========== SCOPE + TYPE + FIELD AWARE INVALIDATION ==========
|
|
741
756
|
|