objectstate 1.3.1__tar.gz → 2.0.0__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-1.3.1 → objectstate-2.0.0}/PKG-INFO +1 -1
- {objectstate-1.3.1 → objectstate-2.0.0}/scripts/verify_release_ready.py +3 -8
- {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/__init__.py +5 -6
- {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/context_manager.py +0 -50
- objectstate-2.0.0/src/objectstate/inspection.py +162 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/lazy_factory.py +43 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/object_state.py +13 -21
- {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/object_state_registry.py +31 -71
- objectstate-2.0.0/src/objectstate/scope_key.py +88 -0
- objectstate-2.0.0/tests/test_authored_paths.py +29 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_construction_history.py +0 -20
- objectstate-2.0.0/tests/test_inspection.py +51 -0
- objectstate-2.0.0/tests/test_release_readiness.py +17 -0
- objectstate-2.0.0/tests/test_scope_key.py +47 -0
- objectstate-1.3.1/src/objectstate/history_migration.py +0 -19
- {objectstate-1.3.1 → objectstate-2.0.0}/.github/badges/coverage.svg +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/.github/workflows/ci.yml +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/.github/workflows/coverage-pages.yml +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/.github/workflows/publish.yml +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/.gitignore +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/.readthedocs.yaml +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/BADGE_SETUP.md +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/LICENSE +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/README.md +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/docs/.gitignore +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/docs/Makefile +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/docs/api/modules.rst +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/docs/architectural_fixes.rst +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/docs/architecture.rst +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/docs/archive/DOCUMENTATION_AUDIT.md +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/docs/archive/DOCUMENTATION_FIXES.md +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/docs/archive/README.md +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/docs/conf.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/docs/examples/atomic_operations.rst +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/docs/examples/basic.rst +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/docs/examples/dual_axis.rst +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/docs/examples/index.rst +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/docs/examples/ui.rst +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/docs/index.rst +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/docs/make.bat +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/docs/performance/canonical-context-type-resolution-20261005.json +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/docs/provenance.rst +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/docs/quickstart.rst +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/docs/state_management.rst +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/docs/state_propagation.rst +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/docs/undo_redo.rst +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/paper.bib +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/paper.md +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/plans/n_dimensional_inheritance.md +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/pyproject.toml +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/scripts/check_ruff_added_lines.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/scripts/release.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/scripts/update_and_release.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/scripts/update_version.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/collection_containers.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/config.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/construction_binding.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/docs/configuration_framework.rst +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/docs/context_system.rst +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/docs/object_state_lifecycle.rst +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/dual_axis_resolver.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/edit_session.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/field_access.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/global_config.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/live_context_resolver.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/object_state_metadata.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/parameter_owner.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/parameter_structure.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/parametric_axes.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/placeholder.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/reified_generics.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/snapshot_model.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/subfield_semantics.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/time_travel_profile.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/token_cache.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/transaction_checkpoint.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/ui_visibility.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/value_semantics.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/tests/__init__.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/tests/conftest.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_callable_baseline.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_config.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_context_manager.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_field_access.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_global_config.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_history_persistence.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_integration.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_lazy_factory.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_object_state_metadata_contracts.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_object_state_restore.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_object_state_update_instance.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_parameter_owner.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_parametric_axes.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_placeholder.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_registry_subscription.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_reified_generics.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_resolved_change_notifications.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_subfield_semantics.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_timeline_projection.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_transaction_checkpoint.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_ui_visible_field_projection.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_undo_redo_cascade.py +0 -0
- {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_value_semantics.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: objectstate
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.0
|
|
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
|
|
@@ -211,13 +211,8 @@ def check_github_workflow():
|
|
|
211
211
|
print(" ❌ .github/workflows/publish.yml not found")
|
|
212
212
|
return False
|
|
213
213
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
print(" ❌ PYPI_API_TOKEN not referenced in workflow")
|
|
217
|
-
return False
|
|
218
|
-
|
|
219
|
-
print(" ✅ GitHub Actions workflow configured")
|
|
220
|
-
print(" Remember to set PYPI_API_TOKEN secret in GitHub!")
|
|
214
|
+
print(" ✅ Publication workflow present")
|
|
215
|
+
print(" Authentication is checked by the workflow's publisher, not artifact readiness.")
|
|
221
216
|
return True
|
|
222
217
|
|
|
223
218
|
|
|
@@ -261,7 +256,7 @@ def main():
|
|
|
261
256
|
if passed == total:
|
|
262
257
|
print("\n🎉 All checks passed! Ready for release!")
|
|
263
258
|
print("\nNext steps:")
|
|
264
|
-
print(" 1.
|
|
259
|
+
print(" 1. Verify the publication workflow's PyPI authorization")
|
|
265
260
|
print(" 2. Run: python scripts/update_and_release.py")
|
|
266
261
|
return 0
|
|
267
262
|
else:
|
|
@@ -73,6 +73,7 @@ from objectstate.lazy_factory import (
|
|
|
73
73
|
is_global_config_instance,
|
|
74
74
|
# Raw-value-preserving utilities
|
|
75
75
|
replace_raw,
|
|
76
|
+
retain_only_authored_paths,
|
|
76
77
|
)
|
|
77
78
|
|
|
78
79
|
# Resolver
|
|
@@ -100,9 +101,6 @@ from objectstate.context_manager import (
|
|
|
100
101
|
register_hierarchy_relationship,
|
|
101
102
|
unregister_hierarchy_relationship,
|
|
102
103
|
get_ancestors_from_hierarchy,
|
|
103
|
-
# Scope key utilities
|
|
104
|
-
get_root_from_scope_key,
|
|
105
|
-
is_scope_affected,
|
|
106
104
|
# Context stack building (framework-agnostic)
|
|
107
105
|
build_context_stack,
|
|
108
106
|
# Threading utility for context propagation
|
|
@@ -181,8 +179,6 @@ __all__ = [
|
|
|
181
179
|
'extract_all_configs',
|
|
182
180
|
'get_base_global_config',
|
|
183
181
|
'get_context_type_stack',
|
|
184
|
-
'get_root_from_scope_key',
|
|
185
|
-
'is_scope_affected',
|
|
186
182
|
'build_context_stack',
|
|
187
183
|
'spawn_thread_with_context',
|
|
188
184
|
# Placeholder
|
|
@@ -216,13 +212,14 @@ __all__ = [
|
|
|
216
212
|
'ObjectStateEditSession',
|
|
217
213
|
]
|
|
218
214
|
|
|
219
|
-
__version__ = "
|
|
215
|
+
__version__ = "2.0.0"
|
|
220
216
|
__author__ = 'OpenHCS Team'
|
|
221
217
|
__description__ = 'Generic configuration framework for lazy dataclass resolution'
|
|
222
218
|
|
|
223
219
|
|
|
224
220
|
# Object state management
|
|
225
221
|
from objectstate.object_state_registry import ObjectStateRegistry
|
|
222
|
+
from objectstate.scope_key import ScopeKey
|
|
226
223
|
from objectstate.object_state import ObjectState
|
|
227
224
|
from objectstate.object_state_metadata import (
|
|
228
225
|
ObjectStateMetadataContract,
|
|
@@ -259,6 +256,8 @@ __all__ += [
|
|
|
259
256
|
# Object state
|
|
260
257
|
'ObjectState',
|
|
261
258
|
'ObjectStateRegistry',
|
|
259
|
+
'ScopeKey',
|
|
260
|
+
'retain_only_authored_paths',
|
|
262
261
|
'ObjectStateMetadataContract',
|
|
263
262
|
'ObjectStateMetadataContractRegistry',
|
|
264
263
|
'ObjectStateMetadataStore',
|
|
@@ -329,56 +329,6 @@ def _is_global_type(t):
|
|
|
329
329
|
_known_hierarchy: dict = {}
|
|
330
330
|
|
|
331
331
|
|
|
332
|
-
def get_root_from_scope_key(scope_key: str) -> str:
|
|
333
|
-
"""Extract root (plate path) from scope_key for visibility checks.
|
|
334
|
-
|
|
335
|
-
scope_key format:
|
|
336
|
-
- Pipeline-level: just plate path (e.g., "/path/to/plate")
|
|
337
|
-
- Step-level: plate_path::step_token (e.g., "/path/to/plate::step_a")
|
|
338
|
-
- Global: empty string
|
|
339
|
-
|
|
340
|
-
Returns the portion before "::" (or the whole string if no "::" present).
|
|
341
|
-
"""
|
|
342
|
-
if not scope_key:
|
|
343
|
-
return ""
|
|
344
|
-
return scope_key.split("::")[0]
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
def is_scope_affected(target_scope_id: str | None, editing_scope_id: str | None) -> bool:
|
|
348
|
-
"""Check if target scope is affected by edit at editing scope.
|
|
349
|
-
|
|
350
|
-
Uses scope_id hierarchy - no type introspection needed:
|
|
351
|
-
- None/"" (global) → affects all
|
|
352
|
-
- "plate_path" (pipeline) → affects same plate + all its steps
|
|
353
|
-
- "plate_path::token" (step) → affects only that step
|
|
354
|
-
|
|
355
|
-
Args:
|
|
356
|
-
target_scope_id: The scope being checked for affectedness (None = global)
|
|
357
|
-
editing_scope_id: The scope where the edit occurred (None = global)
|
|
358
|
-
|
|
359
|
-
Returns:
|
|
360
|
-
True if target should refresh when editing_scope changes
|
|
361
|
-
"""
|
|
362
|
-
# Normalize None to empty string
|
|
363
|
-
target = target_scope_id or ""
|
|
364
|
-
editing = editing_scope_id or ""
|
|
365
|
-
|
|
366
|
-
# Global edit affects all
|
|
367
|
-
if not editing:
|
|
368
|
-
return True
|
|
369
|
-
|
|
370
|
-
# Different plate roots = not affected
|
|
371
|
-
target_root = get_root_from_scope_key(target)
|
|
372
|
-
editing_root = get_root_from_scope_key(editing)
|
|
373
|
-
if target_root != editing_root:
|
|
374
|
-
return False
|
|
375
|
-
|
|
376
|
-
# Same root: parent affects children, not vice versa
|
|
377
|
-
# editing="plate" affects target="plate::step" ✓
|
|
378
|
-
# editing="plate::step" affects target="plate" ✗
|
|
379
|
-
return target == editing or target.startswith(editing + "::")
|
|
380
|
-
|
|
381
|
-
|
|
382
332
|
def _normalize_type_for_hierarchy(t):
|
|
383
333
|
"""Normalize a type for hierarchy registry, but preserve lazy-global distinction.
|
|
384
334
|
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Read-only inspection of registered ObjectStates and their fields."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from objectstate.object_state import ObjectState
|
|
10
|
+
from objectstate.object_state_registry import ObjectStateRegistry
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
DIRTY_MARKER = "*"
|
|
15
|
+
"""Marks a field (or a group of fields) whose value differs from the saved value."""
|
|
16
|
+
|
|
17
|
+
SIGNATURE_DIFF_MARKER = "_"
|
|
18
|
+
"""Marks a field whose raw value differs from its declared signature default."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def field_markers(*, dirty: bool, signature_diff: bool) -> tuple[str, ...]:
|
|
22
|
+
"""The markers views prefix to a field, or to a group of fields, in this order."""
|
|
23
|
+
|
|
24
|
+
return tuple(
|
|
25
|
+
marker
|
|
26
|
+
for marker, present in ((DIRTY_MARKER, dirty), (SIGNATURE_DIFF_MARKER, signature_diff))
|
|
27
|
+
if present
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def state_markers(state: ObjectState) -> tuple[str, ...]:
|
|
32
|
+
"""The markers of a whole ObjectState: any dirty field, any signature difference."""
|
|
33
|
+
|
|
34
|
+
return field_markers(
|
|
35
|
+
dirty=bool(state.dirty_fields),
|
|
36
|
+
signature_diff=bool(state.signature_diff_fields),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def type_name(value: object) -> str:
|
|
41
|
+
return "None" if value is None else type(value).__name__
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def type_qualname(value_type: type | Callable[..., object]) -> str:
|
|
45
|
+
return f"{value_type.__module__}.{value_type.__qualname__}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class FieldProvenance:
|
|
50
|
+
"""The ancestor scope and declaring type a field's inherited value comes from."""
|
|
51
|
+
|
|
52
|
+
source_scope_id: str | None
|
|
53
|
+
source_type: type
|
|
54
|
+
field_name: str
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def source_field_path(self) -> str | None:
|
|
58
|
+
"""The field's path in the source scope's own state, if that scope is registered."""
|
|
59
|
+
|
|
60
|
+
source_state = ObjectStateRegistry.get_by_scope(self.source_scope_id)
|
|
61
|
+
if source_state is None:
|
|
62
|
+
return None
|
|
63
|
+
return source_state.project_ui_visible_field_path(self.source_type, self.field_name)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class FieldInspection:
|
|
68
|
+
"""One field of one ObjectState: raw and resolved value, edit state and provenance."""
|
|
69
|
+
|
|
70
|
+
state: ObjectState
|
|
71
|
+
field_path: str
|
|
72
|
+
raw_value: object
|
|
73
|
+
resolved_value: object
|
|
74
|
+
resolved_value_available: bool
|
|
75
|
+
provenance: FieldProvenance | None
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def of(cls, state: ObjectState, field_path: str):
|
|
79
|
+
"""Inspect ``field_path``; a value that fails to resolve is reported unavailable."""
|
|
80
|
+
|
|
81
|
+
resolved_value = None
|
|
82
|
+
resolved_value_available = False
|
|
83
|
+
try:
|
|
84
|
+
resolved_value = state.get_resolved_value(field_path)
|
|
85
|
+
resolved_value_available = True
|
|
86
|
+
except Exception:
|
|
87
|
+
logger.debug(
|
|
88
|
+
"Failed to resolve ObjectState field %s on scope %r.",
|
|
89
|
+
field_path,
|
|
90
|
+
state.scope_id,
|
|
91
|
+
exc_info=True,
|
|
92
|
+
)
|
|
93
|
+
provenance = None
|
|
94
|
+
match state.get_provenance(field_path):
|
|
95
|
+
case (source_scope_id, source_type) if source_type is not None:
|
|
96
|
+
provenance = FieldProvenance(
|
|
97
|
+
source_scope_id=source_scope_id,
|
|
98
|
+
source_type=source_type,
|
|
99
|
+
field_name=field_path.rsplit(".", 1)[-1],
|
|
100
|
+
)
|
|
101
|
+
return cls(
|
|
102
|
+
state=state,
|
|
103
|
+
field_path=field_path,
|
|
104
|
+
raw_value=state.parameters[field_path],
|
|
105
|
+
resolved_value=resolved_value,
|
|
106
|
+
resolved_value_available=resolved_value_available,
|
|
107
|
+
provenance=provenance,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def dirty(self) -> bool:
|
|
112
|
+
return self.field_path in self.state.dirty_fields
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def signature_diff(self) -> bool:
|
|
116
|
+
return self.field_path in self.state.signature_diff_fields
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def last_changed(self) -> bool:
|
|
120
|
+
return self.field_path == self.state.last_changed_field
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def raw_value_is_none(self) -> bool:
|
|
124
|
+
return self.raw_value is None
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def resolved_value_is_none(self) -> bool:
|
|
128
|
+
return self.resolved_value_available and self.resolved_value is None
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def inherited_value(self) -> bool:
|
|
132
|
+
"""The field holds no value of its own and inherits a resolved one."""
|
|
133
|
+
|
|
134
|
+
return self.raw_value is None and self.resolved_value_available and (
|
|
135
|
+
self.resolved_value is not None
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def semantic_markers(self) -> tuple[str, ...]:
|
|
140
|
+
return field_markers(dirty=self.dirty, signature_diff=self.signature_diff)
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def field_name(self) -> str:
|
|
144
|
+
return self.field_path.rsplit(".", 1)[-1]
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def container_path(self) -> str:
|
|
148
|
+
return self.field_path.rsplit(".", 1)[0] if "." in self.field_path else ""
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def raw_value_type(self) -> str:
|
|
152
|
+
return type_name(self.raw_value)
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def resolved_value_type(self) -> str | None:
|
|
156
|
+
return type_name(self.resolved_value) if self.resolved_value_available else None
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def declaring_type(self) -> str:
|
|
160
|
+
"""Qualified name of the dataclass that declares this field."""
|
|
161
|
+
|
|
162
|
+
return type_qualname(self.state.type_for_path(self.field_path))
|
|
@@ -250,6 +250,49 @@ def replace_raw(instance, **changes):
|
|
|
250
250
|
return type(instance)(**field_values)
|
|
251
251
|
|
|
252
252
|
|
|
253
|
+
|
|
254
|
+
def retain_only_authored_paths(value, authored_field_paths) -> Any:
|
|
255
|
+
"""Copy a lazy dataclass, marking only ``authored_field_paths`` as explicitly set.
|
|
256
|
+
|
|
257
|
+
An ObjectState rebuild constructs every nested lazy dataclass from all its
|
|
258
|
+
flattened fields, so every constructor keyword reads as explicit. Clean
|
|
259
|
+
source generation needs the authored paths ObjectState already tracks
|
|
260
|
+
(its signature-diff fields); this applies them to the lazy metadata without
|
|
261
|
+
changing any field value.
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
from objectstate.field_access import DottedFieldPath
|
|
265
|
+
|
|
266
|
+
paths = tuple(DottedFieldPath(path) for path in authored_field_paths)
|
|
267
|
+
return _copy_with_only_authored_paths(value, DottedFieldPath(""), paths)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _copy_with_only_authored_paths(value, prefix, authored_field_paths):
|
|
271
|
+
from objectstate.field_access import DataclassFieldAccess
|
|
272
|
+
|
|
273
|
+
field_values = {}
|
|
274
|
+
for field_definition in fields(value):
|
|
275
|
+
field_value = DataclassFieldAccess.raw_value(value, field_definition.name)
|
|
276
|
+
if isinstance(field_value, LazyDataclass):
|
|
277
|
+
field_value = _copy_with_only_authored_paths(
|
|
278
|
+
field_value,
|
|
279
|
+
prefix.child(field_definition.name),
|
|
280
|
+
authored_field_paths,
|
|
281
|
+
)
|
|
282
|
+
field_values[field_definition.name] = field_value
|
|
283
|
+
|
|
284
|
+
projected = type(value)(**field_values)
|
|
285
|
+
object.__setattr__(
|
|
286
|
+
projected,
|
|
287
|
+
"_explicitly_set_fields",
|
|
288
|
+
{
|
|
289
|
+
field_definition.name
|
|
290
|
+
for field_definition in fields(projected)
|
|
291
|
+
if prefix.child(field_definition.name).contains_any(authored_field_paths)
|
|
292
|
+
},
|
|
293
|
+
)
|
|
294
|
+
return projected
|
|
295
|
+
|
|
253
296
|
# ContextEventCoordinator removed - replaced with contextvars-based context system
|
|
254
297
|
|
|
255
298
|
|
|
@@ -30,6 +30,7 @@ from objectstate.object_state_metadata import (
|
|
|
30
30
|
ObjectStateMetadataStore,
|
|
31
31
|
)
|
|
32
32
|
from objectstate.object_state_registry import ObjectStateRegistry
|
|
33
|
+
from objectstate.scope_key import ScopeKey
|
|
33
34
|
from objectstate.parameter_owner import ParameterOwner
|
|
34
35
|
from objectstate.parameter_structure import ParameterStructure
|
|
35
36
|
from objectstate.global_config import GlobalContextValues
|
|
@@ -695,9 +696,9 @@ class ObjectState:
|
|
|
695
696
|
parent_field = field_path or self._parent_field_name
|
|
696
697
|
if parent_field is None:
|
|
697
698
|
# Auto-detect from a numeric scope suffix.
|
|
698
|
-
|
|
699
|
-
if
|
|
700
|
-
last =
|
|
699
|
+
scope = ScopeKey(self.scope_id)
|
|
700
|
+
if scope.depth >= 2:
|
|
701
|
+
last = scope.last
|
|
701
702
|
import re
|
|
702
703
|
m = re.match(r'^(.+?)_\d+$', last)
|
|
703
704
|
parent_field = m.group(1) if m else last
|
|
@@ -2304,23 +2305,11 @@ class ObjectState:
|
|
|
2304
2305
|
logger.debug(f"🔧 mark_saved: Total states in registry: {len(ObjectStateRegistry._states)}")
|
|
2305
2306
|
|
|
2306
2307
|
# Collect descendant scopes first to avoid modifying registry during iteration.
|
|
2307
|
-
#
|
|
2308
|
-
# IMPORTANT: Global scope is represented by "" (empty string).
|
|
2309
|
-
# In that case, *every* non-global scope is a descendant, but the naive
|
|
2310
|
-
# prefix check ("" + "::" == "::") matches nothing.
|
|
2311
2308
|
changed_scope = ObjectStateRegistry._normalize_scope_id(self.scope_id)
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
if ObjectStateRegistry._normalize_scope_id(s.scope_id) != ""
|
|
2317
|
-
]
|
|
2318
|
-
else:
|
|
2319
|
-
prefix = changed_scope + "::"
|
|
2320
|
-
descendant_scopes = [
|
|
2321
|
-
s.scope_id for s in ObjectStateRegistry._states.values()
|
|
2322
|
-
if s.scope_id is not None and ObjectStateRegistry._normalize_scope_id(s.scope_id).startswith(prefix)
|
|
2323
|
-
]
|
|
2309
|
+
descendant_scopes = [
|
|
2310
|
+
s.scope_id for s in ObjectStateRegistry._states.values()
|
|
2311
|
+
if changed_scope.strictly_contains(s.scope_id)
|
|
2312
|
+
]
|
|
2324
2313
|
logger.debug(f"🔧 mark_saved: Found {len(descendant_scopes)} descendant scopes: {descendant_scopes}")
|
|
2325
2314
|
|
|
2326
2315
|
for descendant_scope in descendant_scopes:
|
|
@@ -2456,11 +2445,14 @@ class ObjectState:
|
|
|
2456
2445
|
# However, for delegation-based parents that act as context providers (e.g. orchestrator -> pipeline_config),
|
|
2457
2446
|
# restoring the parent should generally *not* restore descendant raw parameters (steps). Descendants should
|
|
2458
2447
|
# only have their resolved caches invalidated so they re-resolve against the restored context.
|
|
2459
|
-
|
|
2448
|
+
# The global scope only provides context to every other scope; it owns none of
|
|
2449
|
+
# their parameters, so restoring it never restores them.
|
|
2450
|
+
own_scope = ScopeKey(self.scope_id)
|
|
2451
|
+
if propagate_descendants and not own_scope.is_global:
|
|
2460
2452
|
descendant_scopes = [
|
|
2461
2453
|
scope
|
|
2462
2454
|
for scope in ObjectStateRegistry._states.keys()
|
|
2463
|
-
if
|
|
2455
|
+
if own_scope.strictly_contains(scope)
|
|
2464
2456
|
]
|
|
2465
2457
|
for descendant_scope in descendant_scopes:
|
|
2466
2458
|
state = ObjectStateRegistry._states.get(descendant_scope)
|
|
@@ -22,7 +22,7 @@ import copy
|
|
|
22
22
|
|
|
23
23
|
from objectstate.object_state_metadata import ObjectStateMetadataStore
|
|
24
24
|
from objectstate.construction_binding import StateConstructionBinding
|
|
25
|
-
from objectstate.
|
|
25
|
+
from objectstate.scope_key import ScopeKey
|
|
26
26
|
from objectstate.global_config import GlobalContextValues
|
|
27
27
|
from objectstate.context_manager import clear_current_temp_global
|
|
28
28
|
from objectstate.dual_axis_resolver import clear_mro_resolution_cache
|
|
@@ -348,9 +348,9 @@ class ObjectStateRegistry:
|
|
|
348
348
|
cls._snapshot_dirty_scopes.add(cls._normalize_scope_id(scope_id))
|
|
349
349
|
|
|
350
350
|
@classmethod
|
|
351
|
-
def _normalize_scope_id(cls, scope_id:
|
|
352
|
-
"""
|
|
353
|
-
return scope_id
|
|
351
|
+
def _normalize_scope_id(cls, scope_id: str | None) -> ScopeKey:
|
|
352
|
+
"""Return the registry key: None and "" both represent the global scope."""
|
|
353
|
+
return ScopeKey(scope_id)
|
|
354
354
|
|
|
355
355
|
@classmethod
|
|
356
356
|
def register(cls, state: 'ObjectState', _skip_snapshot: bool = False) -> None:
|
|
@@ -426,14 +426,11 @@ class ObjectStateRegistry:
|
|
|
426
426
|
scope_key = cls._normalize_scope_id(scope_id)
|
|
427
427
|
|
|
428
428
|
# Find all scopes to delete: exact match + descendants
|
|
429
|
-
scopes_to_delete = [
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
if key == scope_key
|
|
433
|
-
|
|
434
|
-
# Descendant (starts with scope_key::)
|
|
435
|
-
elif scope_key and key.startswith(scope_key + "::"):
|
|
436
|
-
scopes_to_delete.append(key)
|
|
429
|
+
scopes_to_delete = [
|
|
430
|
+
key
|
|
431
|
+
for key in cls._states.keys()
|
|
432
|
+
if key == scope_key or (scope_key and scope_key.contains(key))
|
|
433
|
+
]
|
|
437
434
|
|
|
438
435
|
# Delete all matching scopes and fire callbacks
|
|
439
436
|
# NOTE: Unlike plain unregister(), this is often used for hierarchical deletes.
|
|
@@ -506,7 +503,7 @@ class ObjectStateRegistry:
|
|
|
506
503
|
cls._snapshots.clear()
|
|
507
504
|
cls._timelines.clear()
|
|
508
505
|
cls._current_head = None
|
|
509
|
-
cls._current_timeline =
|
|
506
|
+
cls._current_timeline = cls.MAIN_BRANCH
|
|
510
507
|
cls._atomic_depth = 0
|
|
511
508
|
cls._atomic_label = None
|
|
512
509
|
cls._atomic_triggering_scope = None
|
|
@@ -640,17 +637,9 @@ class ObjectStateRegistry:
|
|
|
640
637
|
"""
|
|
641
638
|
scope_key = cls._normalize_scope_id(scope_id)
|
|
642
639
|
|
|
643
|
-
#
|
|
644
|
-
# e.g., "/plate::step_0" -> ["", "/plate", "/plate::step_0"]
|
|
645
|
-
ancestors = [""] # Global scope always included
|
|
646
|
-
if scope_key:
|
|
647
|
-
parts = scope_key.split("::")
|
|
648
|
-
for i in range(len(parts)):
|
|
649
|
-
ancestors.append("::".join(parts[:i+1]))
|
|
650
|
-
|
|
651
|
-
# Get objects from ancestor scopes
|
|
640
|
+
# Get objects from ancestor scopes, least-specific to most-specific
|
|
652
641
|
objects = []
|
|
653
|
-
for ancestor_key in ancestors:
|
|
642
|
+
for ancestor_key in scope_key.ancestors():
|
|
654
643
|
state = cls._states.get(ancestor_key)
|
|
655
644
|
if state:
|
|
656
645
|
if use_saved:
|
|
@@ -693,16 +682,9 @@ class ObjectStateRegistry:
|
|
|
693
682
|
"""
|
|
694
683
|
scope_key = cls._normalize_scope_id(scope_id)
|
|
695
684
|
|
|
696
|
-
# Build list of ancestor scope keys (least-specific to most-specific)
|
|
697
|
-
ancestors = [""] # Global scope always included
|
|
698
|
-
if scope_key:
|
|
699
|
-
parts = scope_key.split("::")
|
|
700
|
-
for i in range(len(parts)):
|
|
701
|
-
ancestors.append("::".join(parts[:i+1]))
|
|
702
|
-
|
|
703
685
|
# Get (scope_id, object) tuples from ancestor scopes
|
|
704
686
|
results: List[Tuple[str, Any]] = []
|
|
705
|
-
for ancestor_key in ancestors:
|
|
687
|
+
for ancestor_key in scope_key.ancestors():
|
|
706
688
|
state = cls._states.get(ancestor_key)
|
|
707
689
|
if state:
|
|
708
690
|
# Get object - either with or without delegate sync
|
|
@@ -809,20 +791,8 @@ class ObjectStateRegistry:
|
|
|
809
791
|
continue
|
|
810
792
|
|
|
811
793
|
# SCOPE CHECK: must be at or below changed scope
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
# Global scope - always a descendant (or self if also global)
|
|
815
|
-
if field_name == 'well_filter':
|
|
816
|
-
logger.debug(f"🔍 Checking state: scope={state_scope!r}, obj_type={type(state.object_instance).__name__}")
|
|
817
|
-
logger.debug(f"[SCOPE] Global change affects state scope={state_scope!r}")
|
|
818
|
-
else:
|
|
819
|
-
# Non-global: check exact match or descendant
|
|
820
|
-
prefix = changed_scope + "::"
|
|
821
|
-
is_descendant = state_scope.startswith(prefix)
|
|
822
|
-
if not (is_self or is_descendant):
|
|
823
|
-
logger.debug(f"[SCOPE] SKIP: changed_scope={changed_scope!r} does not affect state_scope={state_scope!r}")
|
|
824
|
-
continue
|
|
825
|
-
logger.debug(f"[SCOPE] MATCH: changed_scope={changed_scope!r} affects state_scope={state_scope!r}")
|
|
794
|
+
if not changed_scope.contains(state_scope):
|
|
795
|
+
continue
|
|
826
796
|
|
|
827
797
|
# TYPE + FIELD CHECK: find matching nested state and invalidate field
|
|
828
798
|
cls._invalidate_field_in_matching_states(state, base_changed_type, field_name, invalidate_saved)
|
|
@@ -926,11 +896,12 @@ class ObjectStateRegistry:
|
|
|
926
896
|
# This allows time travel to snapshots that reference deleted objects.
|
|
927
897
|
_graveyard: Dict[str, 'ObjectState'] = {}
|
|
928
898
|
|
|
929
|
-
# Timelines (branches): Named branches that point to a head snapshot
|
|
930
|
-
#
|
|
899
|
+
# Timelines (branches): Named branches that point to a head snapshot.
|
|
900
|
+
# MAIN_BRANCH is created automatically on the first snapshot.
|
|
931
901
|
# head_id always points to a valid key in _snapshots (guaranteed by construction)
|
|
902
|
+
MAIN_BRANCH = "main"
|
|
932
903
|
_timelines: Dict[str, Timeline] = {}
|
|
933
|
-
_current_timeline: str =
|
|
904
|
+
_current_timeline: str = MAIN_BRANCH
|
|
934
905
|
|
|
935
906
|
@classmethod
|
|
936
907
|
@contextmanager
|
|
@@ -1033,7 +1004,7 @@ class ObjectStateRegistry:
|
|
|
1033
1004
|
del cls._snapshots[snapshot_id]
|
|
1034
1005
|
cls._timelines = timelines_before
|
|
1035
1006
|
cls._current_head = current_head_before
|
|
1036
|
-
cls._current_timeline = current_timeline_before or
|
|
1007
|
+
cls._current_timeline = current_timeline_before or cls.MAIN_BRANCH
|
|
1037
1008
|
cls._in_time_travel = bool(in_time_travel_before)
|
|
1038
1009
|
if snapshot_dirty_scopes_before is not None:
|
|
1039
1010
|
cls._snapshot_dirty_scopes = snapshot_dirty_scopes_before
|
|
@@ -1306,7 +1277,7 @@ class ObjectStateRegistry:
|
|
|
1306
1277
|
name=cls._current_timeline,
|
|
1307
1278
|
head_id=snapshot.id,
|
|
1308
1279
|
base_id=snapshot.id,
|
|
1309
|
-
description="Main timeline" if cls._current_timeline ==
|
|
1280
|
+
description="Main timeline" if cls._current_timeline == cls.MAIN_BRANCH else "",
|
|
1310
1281
|
)
|
|
1311
1282
|
logger.info(f"⏱️ BRANCH: Created '{cls._current_timeline}' timeline")
|
|
1312
1283
|
else:
|
|
@@ -1353,18 +1324,10 @@ class ObjectStateRegistry:
|
|
|
1353
1324
|
return tuple(
|
|
1354
1325
|
sorted(
|
|
1355
1326
|
cls._states.items(),
|
|
1356
|
-
key=lambda item: (
|
|
1327
|
+
key=lambda item: (item[0].depth, item[0]),
|
|
1357
1328
|
)
|
|
1358
1329
|
)
|
|
1359
1330
|
|
|
1360
|
-
@staticmethod
|
|
1361
|
-
def _scope_depth(scope_id: str) -> int:
|
|
1362
|
-
"""Return hierarchy depth for ObjectState scope identifiers."""
|
|
1363
|
-
|
|
1364
|
-
if not scope_id:
|
|
1365
|
-
return 0
|
|
1366
|
-
return scope_id.count("::") + 1
|
|
1367
|
-
|
|
1368
1331
|
@classmethod
|
|
1369
1332
|
def _prune_unreachable_snapshots(cls) -> None:
|
|
1370
1333
|
"""Remove snapshots not reachable from any branch head.
|
|
@@ -2193,6 +2156,11 @@ class ObjectStateRegistry:
|
|
|
2193
2156
|
"""Check if currently viewing historical state (not at head)."""
|
|
2194
2157
|
return cls._current_head is not None
|
|
2195
2158
|
|
|
2159
|
+
@classmethod
|
|
2160
|
+
def is_restoring_snapshot(cls) -> bool:
|
|
2161
|
+
"""Whether a snapshot restore is in progress, so views refresh restored values."""
|
|
2162
|
+
return cls._in_time_travel
|
|
2163
|
+
|
|
2196
2164
|
# ========== BRANCH OPERATIONS ==========
|
|
2197
2165
|
|
|
2198
2166
|
@classmethod
|
|
@@ -2319,9 +2287,7 @@ class ObjectStateRegistry:
|
|
|
2319
2287
|
}
|
|
2320
2288
|
|
|
2321
2289
|
@classmethod
|
|
2322
|
-
def import_history_from_dict(
|
|
2323
|
-
cls, data: Dict[str, Any], *, migration: HistoryMigration | None = None
|
|
2324
|
-
) -> None:
|
|
2290
|
+
def import_history_from_dict(cls, data: dict[str, Any]) -> None:
|
|
2325
2291
|
"""Import a complete typed history document payload.
|
|
2326
2292
|
|
|
2327
2293
|
Version 2 includes exact construction declarations. Older documents
|
|
@@ -2330,11 +2296,7 @@ class ObjectStateRegistry:
|
|
|
2330
2296
|
Args:
|
|
2331
2297
|
data: Dict with 'snapshots', 'timelines', 'current_head', 'current_timeline'.
|
|
2332
2298
|
"""
|
|
2333
|
-
document
|
|
2334
|
-
(data, {scope: state.construction_binding() for scope, state in cls._states.items()})
|
|
2335
|
-
)
|
|
2336
|
-
if document.get("format_version") != 2 and migration is not None:
|
|
2337
|
-
document = migration.migrate(document, constructions)
|
|
2299
|
+
document = copy.deepcopy(data)
|
|
2338
2300
|
if document.get("format_version") != 2:
|
|
2339
2301
|
raise ValueError(
|
|
2340
2302
|
"Unsupported ObjectState history version; construction ownership is required."
|
|
@@ -2389,9 +2351,7 @@ class ObjectStateRegistry:
|
|
|
2389
2351
|
logger.info(f"⏱️ Saved {len(cls._snapshots)} snapshots to {filepath}")
|
|
2390
2352
|
|
|
2391
2353
|
@classmethod
|
|
2392
|
-
def load_history_from_file(
|
|
2393
|
-
cls, filepath: str, *, migration: HistoryMigration | None = None
|
|
2394
|
-
) -> None:
|
|
2354
|
+
def load_history_from_file(cls, filepath: str) -> None:
|
|
2395
2355
|
"""Load and materialize a trusted ObjectState history document.
|
|
2396
2356
|
|
|
2397
2357
|
Args:
|
|
@@ -2407,7 +2367,7 @@ class ObjectStateRegistry:
|
|
|
2407
2367
|
)
|
|
2408
2368
|
try:
|
|
2409
2369
|
with open(filepath, "rb") as history_file:
|
|
2410
|
-
cls.import_history_from_dict(dill.load(history_file)
|
|
2370
|
+
cls.import_history_from_dict(dill.load(history_file))
|
|
2411
2371
|
restored_at_head = cls._current_head is None
|
|
2412
2372
|
snapshot_id = cls._current_snapshot_id()
|
|
2413
2373
|
if snapshot_id is not None:
|