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.
Files changed (103) hide show
  1. {objectstate-1.3.1 → objectstate-2.0.0}/PKG-INFO +1 -1
  2. {objectstate-1.3.1 → objectstate-2.0.0}/scripts/verify_release_ready.py +3 -8
  3. {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/__init__.py +5 -6
  4. {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/context_manager.py +0 -50
  5. objectstate-2.0.0/src/objectstate/inspection.py +162 -0
  6. {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/lazy_factory.py +43 -0
  7. {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/object_state.py +13 -21
  8. {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/object_state_registry.py +31 -71
  9. objectstate-2.0.0/src/objectstate/scope_key.py +88 -0
  10. objectstate-2.0.0/tests/test_authored_paths.py +29 -0
  11. {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_construction_history.py +0 -20
  12. objectstate-2.0.0/tests/test_inspection.py +51 -0
  13. objectstate-2.0.0/tests/test_release_readiness.py +17 -0
  14. objectstate-2.0.0/tests/test_scope_key.py +47 -0
  15. objectstate-1.3.1/src/objectstate/history_migration.py +0 -19
  16. {objectstate-1.3.1 → objectstate-2.0.0}/.github/badges/coverage.svg +0 -0
  17. {objectstate-1.3.1 → objectstate-2.0.0}/.github/workflows/ci.yml +0 -0
  18. {objectstate-1.3.1 → objectstate-2.0.0}/.github/workflows/coverage-pages.yml +0 -0
  19. {objectstate-1.3.1 → objectstate-2.0.0}/.github/workflows/publish.yml +0 -0
  20. {objectstate-1.3.1 → objectstate-2.0.0}/.gitignore +0 -0
  21. {objectstate-1.3.1 → objectstate-2.0.0}/.readthedocs.yaml +0 -0
  22. {objectstate-1.3.1 → objectstate-2.0.0}/BADGE_SETUP.md +0 -0
  23. {objectstate-1.3.1 → objectstate-2.0.0}/LICENSE +0 -0
  24. {objectstate-1.3.1 → objectstate-2.0.0}/README.md +0 -0
  25. {objectstate-1.3.1 → objectstate-2.0.0}/docs/.gitignore +0 -0
  26. {objectstate-1.3.1 → objectstate-2.0.0}/docs/Makefile +0 -0
  27. {objectstate-1.3.1 → objectstate-2.0.0}/docs/api/modules.rst +0 -0
  28. {objectstate-1.3.1 → objectstate-2.0.0}/docs/architectural_fixes.rst +0 -0
  29. {objectstate-1.3.1 → objectstate-2.0.0}/docs/architecture.rst +0 -0
  30. {objectstate-1.3.1 → objectstate-2.0.0}/docs/archive/DOCUMENTATION_AUDIT.md +0 -0
  31. {objectstate-1.3.1 → objectstate-2.0.0}/docs/archive/DOCUMENTATION_FIXES.md +0 -0
  32. {objectstate-1.3.1 → objectstate-2.0.0}/docs/archive/README.md +0 -0
  33. {objectstate-1.3.1 → objectstate-2.0.0}/docs/conf.py +0 -0
  34. {objectstate-1.3.1 → objectstate-2.0.0}/docs/examples/atomic_operations.rst +0 -0
  35. {objectstate-1.3.1 → objectstate-2.0.0}/docs/examples/basic.rst +0 -0
  36. {objectstate-1.3.1 → objectstate-2.0.0}/docs/examples/dual_axis.rst +0 -0
  37. {objectstate-1.3.1 → objectstate-2.0.0}/docs/examples/index.rst +0 -0
  38. {objectstate-1.3.1 → objectstate-2.0.0}/docs/examples/ui.rst +0 -0
  39. {objectstate-1.3.1 → objectstate-2.0.0}/docs/index.rst +0 -0
  40. {objectstate-1.3.1 → objectstate-2.0.0}/docs/make.bat +0 -0
  41. {objectstate-1.3.1 → objectstate-2.0.0}/docs/performance/canonical-context-type-resolution-20261005.json +0 -0
  42. {objectstate-1.3.1 → objectstate-2.0.0}/docs/provenance.rst +0 -0
  43. {objectstate-1.3.1 → objectstate-2.0.0}/docs/quickstart.rst +0 -0
  44. {objectstate-1.3.1 → objectstate-2.0.0}/docs/state_management.rst +0 -0
  45. {objectstate-1.3.1 → objectstate-2.0.0}/docs/state_propagation.rst +0 -0
  46. {objectstate-1.3.1 → objectstate-2.0.0}/docs/undo_redo.rst +0 -0
  47. {objectstate-1.3.1 → objectstate-2.0.0}/paper.bib +0 -0
  48. {objectstate-1.3.1 → objectstate-2.0.0}/paper.md +0 -0
  49. {objectstate-1.3.1 → objectstate-2.0.0}/plans/n_dimensional_inheritance.md +0 -0
  50. {objectstate-1.3.1 → objectstate-2.0.0}/pyproject.toml +0 -0
  51. {objectstate-1.3.1 → objectstate-2.0.0}/scripts/check_ruff_added_lines.py +0 -0
  52. {objectstate-1.3.1 → objectstate-2.0.0}/scripts/release.py +0 -0
  53. {objectstate-1.3.1 → objectstate-2.0.0}/scripts/update_and_release.py +0 -0
  54. {objectstate-1.3.1 → objectstate-2.0.0}/scripts/update_version.py +0 -0
  55. {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/collection_containers.py +0 -0
  56. {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/config.py +0 -0
  57. {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/construction_binding.py +0 -0
  58. {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/docs/configuration_framework.rst +0 -0
  59. {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/docs/context_system.rst +0 -0
  60. {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/docs/object_state_lifecycle.rst +0 -0
  61. {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/dual_axis_resolver.py +0 -0
  62. {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/edit_session.py +0 -0
  63. {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/field_access.py +0 -0
  64. {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/global_config.py +0 -0
  65. {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/live_context_resolver.py +0 -0
  66. {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/object_state_metadata.py +0 -0
  67. {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/parameter_owner.py +0 -0
  68. {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/parameter_structure.py +0 -0
  69. {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/parametric_axes.py +0 -0
  70. {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/placeholder.py +0 -0
  71. {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/reified_generics.py +0 -0
  72. {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/snapshot_model.py +0 -0
  73. {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/subfield_semantics.py +0 -0
  74. {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/time_travel_profile.py +0 -0
  75. {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/token_cache.py +0 -0
  76. {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/transaction_checkpoint.py +0 -0
  77. {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/ui_visibility.py +0 -0
  78. {objectstate-1.3.1 → objectstate-2.0.0}/src/objectstate/value_semantics.py +0 -0
  79. {objectstate-1.3.1 → objectstate-2.0.0}/tests/__init__.py +0 -0
  80. {objectstate-1.3.1 → objectstate-2.0.0}/tests/conftest.py +0 -0
  81. {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_callable_baseline.py +0 -0
  82. {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_config.py +0 -0
  83. {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_context_manager.py +0 -0
  84. {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_field_access.py +0 -0
  85. {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_global_config.py +0 -0
  86. {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_history_persistence.py +0 -0
  87. {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_integration.py +0 -0
  88. {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_lazy_factory.py +0 -0
  89. {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_object_state_metadata_contracts.py +0 -0
  90. {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_object_state_restore.py +0 -0
  91. {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_object_state_update_instance.py +0 -0
  92. {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_parameter_owner.py +0 -0
  93. {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_parametric_axes.py +0 -0
  94. {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_placeholder.py +0 -0
  95. {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_registry_subscription.py +0 -0
  96. {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_reified_generics.py +0 -0
  97. {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_resolved_change_notifications.py +0 -0
  98. {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_subfield_semantics.py +0 -0
  99. {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_timeline_projection.py +0 -0
  100. {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_transaction_checkpoint.py +0 -0
  101. {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_ui_visible_field_projection.py +0 -0
  102. {objectstate-1.3.1 → objectstate-2.0.0}/tests/test_undo_redo_cascade.py +0 -0
  103. {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: 1.3.1
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
- content = workflow_file.read_text()
215
- if 'PYPI_API_TOKEN' not in content:
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. Set PYPI_API_TOKEN in GitHub secrets")
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__ = "1.3.1"
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
- parts = self.scope_id.split('::')
699
- if len(parts) >= 2:
700
- last = parts[-1]
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
- if changed_scope == "":
2313
- # Global baseline change affects ALL other states.
2314
- descendant_scopes = [
2315
- s.scope_id for s in ObjectStateRegistry._states.values()
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
- if propagate_descendants:
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 scope.startswith(f"{self.scope_id}::")
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.history_migration import HistoryMigration
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: Optional[str]) -> str:
352
- """Normalize scope_id: None and "" both represent global scope."""
353
- return scope_id if scope_id is not None else ""
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
- for key in cls._states.keys():
431
- # Exact match
432
- if key == scope_key:
433
- scopes_to_delete.append(key)
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 = "main"
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
- # Build list of ancestor scope keys (least-specific to most-specific)
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
- # Global scope (empty string) affects ALL states
813
- if changed_scope == "":
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
- # "main" is always created automatically on first snapshot
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 = "main"
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 "main"
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 == "main" else "",
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: (cls._scope_depth(item[0]), item[0]),
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, constructions = copy.deepcopy(
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), migration=migration)
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: