objectstate 3.0.3__tar.gz → 3.0.4__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 (113) hide show
  1. {objectstate-3.0.3 → objectstate-3.0.4}/.github/badges/coverage.svg +1 -1
  2. {objectstate-3.0.3 → objectstate-3.0.4}/PKG-INFO +1 -1
  3. {objectstate-3.0.3 → objectstate-3.0.4}/docs/architecture.rst +3 -0
  4. {objectstate-3.0.3 → objectstate-3.0.4}/docs/provenance.rst +3 -1
  5. {objectstate-3.0.3 → objectstate-3.0.4}/docs/state_propagation.rst +41 -7
  6. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/__init__.py +1 -1
  7. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/context_manager.py +20 -5
  8. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/docs/context_system.rst +15 -12
  9. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/object_state.py +22 -2
  10. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_resolved_values_follow_registered_ancestors.py +368 -27
  11. {objectstate-3.0.3 → objectstate-3.0.4}/.github/workflows/ci.yml +0 -0
  12. {objectstate-3.0.3 → objectstate-3.0.4}/.github/workflows/coverage-pages.yml +0 -0
  13. {objectstate-3.0.3 → objectstate-3.0.4}/.github/workflows/publish.yml +0 -0
  14. {objectstate-3.0.3 → objectstate-3.0.4}/.gitignore +0 -0
  15. {objectstate-3.0.3 → objectstate-3.0.4}/.readthedocs.yaml +0 -0
  16. {objectstate-3.0.3 → objectstate-3.0.4}/BADGE_SETUP.md +0 -0
  17. {objectstate-3.0.3 → objectstate-3.0.4}/LICENSE +0 -0
  18. {objectstate-3.0.3 → objectstate-3.0.4}/README.md +0 -0
  19. {objectstate-3.0.3 → objectstate-3.0.4}/docs/.gitignore +0 -0
  20. {objectstate-3.0.3 → objectstate-3.0.4}/docs/Makefile +0 -0
  21. {objectstate-3.0.3 → objectstate-3.0.4}/docs/api/modules.rst +0 -0
  22. {objectstate-3.0.3 → objectstate-3.0.4}/docs/architectural_fixes.rst +0 -0
  23. {objectstate-3.0.3 → objectstate-3.0.4}/docs/archive/DOCUMENTATION_AUDIT.md +0 -0
  24. {objectstate-3.0.3 → objectstate-3.0.4}/docs/archive/DOCUMENTATION_FIXES.md +0 -0
  25. {objectstate-3.0.3 → objectstate-3.0.4}/docs/archive/README.md +0 -0
  26. {objectstate-3.0.3 → objectstate-3.0.4}/docs/conf.py +0 -0
  27. {objectstate-3.0.3 → objectstate-3.0.4}/docs/examples/atomic_operations.rst +0 -0
  28. {objectstate-3.0.3 → objectstate-3.0.4}/docs/examples/basic.rst +0 -0
  29. {objectstate-3.0.3 → objectstate-3.0.4}/docs/examples/dual_axis.rst +0 -0
  30. {objectstate-3.0.3 → objectstate-3.0.4}/docs/examples/index.rst +0 -0
  31. {objectstate-3.0.3 → objectstate-3.0.4}/docs/examples/ui.rst +0 -0
  32. {objectstate-3.0.3 → objectstate-3.0.4}/docs/index.rst +0 -0
  33. {objectstate-3.0.3 → objectstate-3.0.4}/docs/make.bat +0 -0
  34. {objectstate-3.0.3 → objectstate-3.0.4}/docs/performance/canonical-context-type-resolution-20261005.json +0 -0
  35. {objectstate-3.0.3 → objectstate-3.0.4}/docs/quickstart.rst +0 -0
  36. {objectstate-3.0.3 → objectstate-3.0.4}/docs/state_management.rst +0 -0
  37. {objectstate-3.0.3 → objectstate-3.0.4}/docs/undo_redo.rst +0 -0
  38. {objectstate-3.0.3 → objectstate-3.0.4}/paper.bib +0 -0
  39. {objectstate-3.0.3 → objectstate-3.0.4}/paper.md +0 -0
  40. {objectstate-3.0.3 → objectstate-3.0.4}/plans/n_dimensional_inheritance.md +0 -0
  41. {objectstate-3.0.3 → objectstate-3.0.4}/pyproject.toml +0 -0
  42. {objectstate-3.0.3 → objectstate-3.0.4}/scripts/check_ruff_added_lines.py +0 -0
  43. {objectstate-3.0.3 → objectstate-3.0.4}/scripts/release.py +0 -0
  44. {objectstate-3.0.3 → objectstate-3.0.4}/scripts/update_and_release.py +0 -0
  45. {objectstate-3.0.3 → objectstate-3.0.4}/scripts/update_version.py +0 -0
  46. {objectstate-3.0.3 → objectstate-3.0.4}/scripts/verify_release_ready.py +0 -0
  47. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/codegen.py +0 -0
  48. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/collection_containers.py +0 -0
  49. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/config.py +0 -0
  50. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/construction_binding.py +0 -0
  51. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/docs/configuration_framework.rst +0 -0
  52. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/docs/object_state_lifecycle.rst +0 -0
  53. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/dual_axis_resolver.py +0 -0
  54. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/edit_session.py +0 -0
  55. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/field_access.py +0 -0
  56. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/global_config.py +0 -0
  57. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/inspection.py +0 -0
  58. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/lazy_factory.py +0 -0
  59. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/live_context_resolver.py +0 -0
  60. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/object_state_metadata.py +0 -0
  61. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/object_state_registry.py +0 -0
  62. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/parameter_owner.py +0 -0
  63. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/parameter_structure.py +0 -0
  64. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/parametric_axes.py +0 -0
  65. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/placeholder.py +0 -0
  66. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/reified_generics.py +0 -0
  67. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/resolution_sources.py +0 -0
  68. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/scope_key.py +0 -0
  69. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/snapshot_model.py +0 -0
  70. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/subfield_semantics.py +0 -0
  71. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/time_travel_profile.py +0 -0
  72. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/token_cache.py +0 -0
  73. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/transaction_checkpoint.py +0 -0
  74. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/ui_visibility.py +0 -0
  75. {objectstate-3.0.3 → objectstate-3.0.4}/src/objectstate/value_semantics.py +0 -0
  76. {objectstate-3.0.3 → objectstate-3.0.4}/tests/__init__.py +0 -0
  77. {objectstate-3.0.3 → objectstate-3.0.4}/tests/conftest.py +0 -0
  78. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_atomic_block_context.py +0 -0
  79. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_authored_paths.py +0 -0
  80. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_callable_baseline.py +0 -0
  81. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_codegen.py +0 -0
  82. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_config.py +0 -0
  83. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_construction_history.py +0 -0
  84. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_context_failures_surface.py +0 -0
  85. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_context_manager.py +0 -0
  86. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_field_access.py +0 -0
  87. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_global_config.py +0 -0
  88. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_history_persistence.py +0 -0
  89. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_inspection.py +0 -0
  90. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_integration.py +0 -0
  91. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_lazy_counterpart_registry.py +0 -0
  92. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_lazy_factory.py +0 -0
  93. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_live_context_resolver_answers.py +0 -0
  94. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_object_state_metadata_contracts.py +0 -0
  95. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_object_state_restore.py +0 -0
  96. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_object_state_update_instance.py +0 -0
  97. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_one_field_resolution.py +0 -0
  98. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_parameter_owner.py +0 -0
  99. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_parametric_axes.py +0 -0
  100. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_placeholder.py +0 -0
  101. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_registry_subscription.py +0 -0
  102. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_reified_generics.py +0 -0
  103. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_release_readiness.py +0 -0
  104. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_resolution_precedence_order.py +0 -0
  105. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_resolved_change_notifications.py +0 -0
  106. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_scope_key.py +0 -0
  107. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_scopeless_state_resolution.py +0 -0
  108. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_subfield_semantics.py +0 -0
  109. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_timeline_projection.py +0 -0
  110. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_transaction_checkpoint.py +0 -0
  111. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_ui_visible_field_projection.py +0 -0
  112. {objectstate-3.0.3 → objectstate-3.0.4}/tests/test_undo_redo_cascade.py +0 -0
  113. {objectstate-3.0.3 → objectstate-3.0.4}/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: 82.11%"><title>coverage: 82.11%</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">82.11%</text><text x="865" y="150" fill-opacity=".3" textLength="430">82.11%</text></g><text x="865" y="140" textLength="430">82.11%</text></g></g></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="114" height="20" role="img" aria-label="coverage: 82.63%"><title>coverage: 82.63%</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">82.63%</text><text x="865" y="150" fill-opacity=".3" textLength="430">82.63%</text></g><text x="865" y="140" textLength="430">82.63%</text></g></g></svg>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: objectstate
3
- Version: 3.0.3
3
+ Version: 3.0.4
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
@@ -527,6 +527,9 @@ Sets the **instance** (concrete values) for resolution:
527
527
  * Required for lazy resolution to work
528
528
  * Internally calls ``set_global_config_for_editing()``
529
529
  * Should be called at application startup (GUI) or before pipeline execution
530
+ * Not needed after an edit or a save made through the ``ObjectState`` of the global
531
+ config: that state stores the live and the saved instance itself
532
+ (see :doc:`state_propagation`)
530
533
 
531
534
  **Key Differences:**
532
535
 
@@ -119,7 +119,9 @@ A ``ResolvedField`` named tuple:
119
119
 
120
120
  * ``value``: The resolved field value (may be ``None``)
121
121
  * ``source_scope``: The scope id of the layer that supplied the value. ``None`` for a layer
122
- entered with ``config_context(obj)`` without a ``scope_id``.
122
+ entered with ``config_context(obj)`` without a ``scope_id``. ``ObjectState.get_provenance``
123
+ never reports such a layer: a state resolves against the global config and its registered
124
+ ancestors only (:ref:`state-resolution-inputs`).
123
125
  * ``source_type``: The type the value was read from: the container type or one of its bases.
124
126
 
125
127
  When no layer holds a value, ``value`` is the declared default and the source names the
@@ -95,17 +95,35 @@ Use parent forwarding when all of the following are true:
95
95
  For a nested dataclass field in the same state, call ``update_parameter()`` on
96
96
  the dotted path instead.
97
97
 
98
+ .. _state-resolution-inputs:
99
+
98
100
  What a resolved value is computed from
99
101
  --------------------------------------
100
102
 
101
- A resolved value of a state is computed from three things: which states are
102
- registered at the scopes above it, what each of them stores for the field's config
103
- type and field name, and what the global config stores. Each resolved snapshot (live
103
+ A resolved value of a state depends only on the state's place in the registered
104
+ hierarchy. It is computed from three things: which states are registered at the
105
+ scopes above it, what each of them stores for the field's config type and field name,
106
+ and what the global config stores. Nothing else is an input. In particular a
107
+ ``config_context`` that the caller has entered is not: a state built, read or resolved
108
+ again inside such a block has the values, saved values, reported sources, dirty
109
+ fields and notifications it has outside it, during the block and after it. The state
110
+ resolves against a layer stack of its own (``build_context_stack``) that starts from
111
+ the global config and holds the registered ancestors and the state's object; the
112
+ caller's context is set aside while it does and is active again afterwards. So the
113
+ reported source of a resolved value is always a scope where a state is registered, or
114
+ ``""`` for the global config.
115
+
116
+ This is the only statement of the rule. A lazy config object that no state owns is a
117
+ different route: plain attribute access on it resolves against whatever
118
+ ``config_context`` is entered (see ``context_system.rst`` in the package docs), which
119
+ is how a compiler resolves a pipeline's objects.
120
+
121
+ Each resolved snapshot (live
104
122
  and saved) keeps the objects it was computed from (``ResolutionSources``): the global
105
123
  config instance of the thread that resolved it and the object of every state
106
124
  registered above. Every resolved read (``get_resolved_value``,
107
125
  ``get_saved_resolved_value``, ``get_provenance``, ``to_resolved_object``,
108
- ``state.fields``) first compares those objects with the ones the state would be given
126
+ ``state.fields``, ``dirty_fields``) first compares those objects with the ones the state would be given
109
127
  now. When they are the same objects the read answers from the snapshot. When one is
110
128
  another object, the two are compared field by field, the way field resolution reads a
111
129
  context layer, and only the fields of the state that are unset and can inherit a
@@ -129,7 +147,23 @@ Limits:
129
147
  - The global config is stored per thread. A snapshot follows the global config of the
130
148
  thread that resolved it; a read from another thread answers from the snapshot and
131
149
  does not resolve it again for that thread's global config.
132
- - A state built or resolved inside a ``config_context`` the caller entered takes that
133
- context's layers into its snapshot and keeps them after the block
134
- (`issue #53 <https://github.com/OpenHCSDev/ObjectState/issues/53>`_).
150
+
151
+ The state of the global config
152
+ ------------------------------
153
+
154
+ The global config is stored per thread, once as edited (live) and once as saved. A
155
+ state whose object is a global config writes that store when its own object changes:
156
+ ``update_parameter`` applies the edit to the live global config,
157
+ ``mark_saved`` stores the saved object as the saved global config, ``restore_saved``
158
+ stores the saved object as the live one again, ``update_object_instance`` and time
159
+ travel store both. Each is stored before any descendant resolves again, so saving the
160
+ global state is one call: its registered descendants resolve their saved values
161
+ against the object just saved and are told at once.
162
+
163
+ ``set_live_global_config``, ``set_saved_global_config`` and
164
+ ``ensure_global_config_context`` (both at once) store a global config where no state
165
+ does it: at startup before a state exists, and in a thread or process that resolves
166
+ plain lazy objects without a registry, such as a compile worker. Calling one with the
167
+ values the global state has just stored, before or after ``mark_saved``, changes
168
+ nothing. A setter tells no state; states find the replaced object on their next read.
135
169
 
@@ -210,7 +210,7 @@ __all__ = [
210
210
  'ObjectStateEditSession',
211
211
  ]
212
212
 
213
- __version__ = "3.0.3"
213
+ __version__ = "3.0.4"
214
214
  __author__ = 'OpenHCS Team'
215
215
  __description__ = 'Generic configuration framework for lazy dataclass resolution'
216
216
 
@@ -585,13 +585,20 @@ def build_context_stack(
585
585
  use_live: bool = True,
586
586
  ):
587
587
  """
588
- Enter every context layer needed to resolve ``object_instance``; leave them on exit.
588
+ Enter the complete layer stack that resolves ``object_instance``; leave it on exit.
589
+
590
+ The stack is built from the arguments and the global config alone. A context the
591
+ caller has entered is set aside for the duration of the block and is active again
592
+ after it: none of its layers is below, between or above the layers entered here.
593
+ This is how an ``ObjectState`` resolves, so a state's resolved values depend on
594
+ its object, the states registered above it and the global config, and never on
595
+ where the read was made. A lazy object that no state owns is resolved by plain
596
+ attribute access against whatever ``config_context`` is entered.
589
597
 
590
598
  Layers, outermost first:
591
- 1. The global config, under scope ``""``. ``config_context`` puts it below the
592
- first layer entered while no context is active. When ``object_instance`` is
593
- itself a global config, a fresh instance of its type holding the declared
594
- defaults is entered above it, also under scope ``""``, and masks it.
599
+ 1. The global config, under scope ``""``. When ``object_instance`` is itself a
600
+ global config, a fresh instance of its type holding the declared defaults is
601
+ entered above it, also under scope ``""``, and masks it.
595
602
  2. Each ancestor, under its own scope id. Global-config ancestors are left out
596
603
  because layer 1 already stands for them.
597
604
  3. ``object_instance``, under ``current_scope_id``.
@@ -613,6 +620,14 @@ def build_context_stack(
613
620
  )
614
621
 
615
622
  with ExitStack() as stack:
623
+ # With no context active, the first layer entered starts from the global config.
624
+ for variable, no_context in (
625
+ (current_temp_global, None),
626
+ (context_type_stack, ()),
627
+ (context_layer_stack, NO_LAYERS),
628
+ ):
629
+ stack.callback(variable.reset, variable.set(no_context))
630
+
616
631
  if isinstance(object_instance, GlobalConfigBase):
617
632
  enter(stack, type(object_instance)(), "", mask=True)
618
633
 
@@ -173,16 +173,13 @@ Global config uses thread-local storage for stability:
173
173
  _global_config_storage: Dict[Type, threading.local] = {}
174
174
 
175
175
  def ensure_global_config_context(config_type: Type, config_instance: Any):
176
- """Establish global config as base context."""
177
- # Store in thread-local
176
+ """Store the instance as both the live and the saved global config of this thread."""
178
177
  set_global_config_for_editing(config_type, config_instance)
179
-
180
- # Also inject into contextvars base
181
- with config_context(config_instance):
182
- # Global context now available
183
- pass
184
178
 
185
- This hybrid approach uses thread-local for the global base and contextvars for dynamic stacking.
179
+ The global config is the base in thread-local storage; contextvars hold the layers
180
+ stacked on it. When a registered ``ObjectState`` holds the global config, that state
181
+ writes this storage on edit, save and restore (``docs/state_propagation.rst``, "The
182
+ state of the global config").
186
183
 
187
184
  Resolution Integration
188
185
  ---------------------
@@ -268,8 +265,13 @@ No manual cleanup needed - Python's context manager protocol handles it.
268
265
  Framework-Agnostic Context Stack Building
269
266
  -----------------------------------------
270
267
 
271
- ``build_context_stack()`` is a context manager that enters every layer needed to
272
- resolve one object, and leaves them again when the block ends:
268
+ ``build_context_stack()`` is a context manager that enters the complete layer stack
269
+ that resolves one object, and leaves it again when the block ends. It is how an
270
+ ``ObjectState`` resolves. The stack is built from the arguments and the global config
271
+ alone: a context the caller has entered is set aside for the duration of the block and
272
+ is active again after it. ``docs/state_propagation.rst`` ("What a resolved value is
273
+ computed from") states the rule this implements; plain attribute access on a lazy
274
+ object, everywhere else on this page, reads the entered context.
273
275
 
274
276
  .. code-block:: python
275
277
 
@@ -292,5 +294,6 @@ Layers are entered outermost first:
292
294
 
293
295
  Every layer carries a scope id, so ``resolve_with_provenance()`` can name the scope
294
296
  that supplied a value. The global layer is the one ``config_context()`` puts below the
295
- first layer entered while no context is active. Nothing is set until the ``with`` block is entered. A layer
296
- that cannot be built raises; the block never runs with a layer missing.
297
+ first layer entered while no context is active, which is always the case inside this
298
+ block. Nothing is set until the ``with`` block is entered. A layer that cannot be built
299
+ raises; the block never runs with a layer missing.
@@ -2049,7 +2049,8 @@ class ObjectState:
2049
2049
 
2050
2050
  @property
2051
2051
  def dirty_fields(self) -> set[str]:
2052
- """Fields where resolved_live != resolved_saved."""
2052
+ """Fields whose live and saved resolved values differ, among today's sources."""
2053
+ self._ensure_live_resolved()
2053
2054
  return self._dirty_fields
2054
2055
 
2055
2056
  @property
@@ -2309,7 +2310,8 @@ class ObjectState:
2309
2310
  def mark_saved(self) -> None:
2310
2311
  """Mark current state as saved baseline.
2311
2312
 
2312
- UNIFIED: Works for any object_instance type.
2313
+ UNIFIED: Works for any object_instance type. For the state of a global
2314
+ config, the saved object also becomes the saved global config.
2313
2315
 
2314
2316
  CRITICAL: Invalidates descendant caches for any parameters that changed.
2315
2317
  This ensures that when saving, other windows that inherited from the
@@ -2377,6 +2379,7 @@ class ObjectState:
2377
2379
 
2378
2380
  # Update saved parameters (after object_instance update, before invalidation)
2379
2381
  self._saved_parameters = self._copy_parameters_for_saved_baseline(self.parameters)
2382
+ self._store_saved_object_as_saved_global_config()
2380
2383
 
2381
2384
  # NOW invalidate descendant caches AFTER object_instance is updated
2382
2385
  # This ensures descendants see the NEW object_instance when they recompute
@@ -2478,6 +2481,23 @@ class ObjectState:
2478
2481
  self._restore_saved_impl(propagate_descendants=propagate_descendants)
2479
2482
  return
2480
2483
 
2484
+ def _store_saved_object_as_saved_global_config(self) -> None:
2485
+ """Saving the state of a global config makes its saved object the saved global config.
2486
+
2487
+ Stored before any descendant resolves its saved values again, so they resolve
2488
+ against the object that was just saved. An application that also calls
2489
+ ``set_saved_global_config`` with the same values, before or after
2490
+ ``mark_saved``, changes nothing.
2491
+ """
2492
+ obj_type = type(self._extraction_target)
2493
+ from objectstate.lazy_factory import is_global_config_type
2494
+ if not is_global_config_type(obj_type):
2495
+ return
2496
+
2497
+ from objectstate.global_config import set_saved_global_config
2498
+
2499
+ set_saved_global_config(obj_type, self._extraction_target)
2500
+
2481
2501
  def _restore_live_global_context_from_saved(self) -> None:
2482
2502
  """Keep global live thread-local aligned after cancelling edits."""
2483
2503
  obj_type = type(self.object_instance)
@@ -16,14 +16,20 @@ clearing the registry), registered again with another value, replaced at its sco
16
16
  registered with unsaved edits; an ancestor's value edited, saved, restored, undone
17
17
  and redone, one and two levels up, with the descendant registered or not; the
18
18
  global config replaced through its setters; the descendant's ``scope_id`` moved
19
- under another ancestor; a context entered after the build. Only the fields a change
20
- can reach are resolved again. The case at the end of the file is known to fail and
21
- says why.
19
+ under another ancestor. Only the fields a change can reach are resolved again.
20
+
21
+ A context the caller has entered is not one of the sources: the second half of the
22
+ file runs one sequence of builds, edits, saves and replacements outside any context
23
+ and inside several kinds of entered context, and requires the same reads and the
24
+ same notifications. A lazy object that no state owns does resolve against the
25
+ entered context. The last section saves the state of the global config and stores
26
+ the saved global config in both orders.
22
27
  """
23
28
 
24
29
  import gc
25
30
  import threading
26
31
  import weakref
32
+ from contextlib import ExitStack, nullcontext
27
33
  from dataclasses import dataclass, field
28
34
  from typing import NamedTuple
29
35
 
@@ -36,13 +42,19 @@ from objectstate import (
36
42
  build_context_stack,
37
43
  config_context,
38
44
  dual_axis_resolver,
45
+ get_live_global_config,
46
+ get_saved_global_config,
39
47
  mark_global_config_type,
48
+ resolve_with_provenance,
40
49
  set_base_config_type,
41
50
  set_global_config_for_editing,
42
51
  set_live_global_config,
43
52
  set_saved_global_config,
53
+ spawn_thread_with_context,
44
54
  )
45
55
 
56
+ from objectstate.context_manager import get_context_layer_stack
57
+
46
58
 
47
59
  @dataclass
48
60
  class Settings:
@@ -297,20 +309,6 @@ def test_scope_moved_under_another_ancestor(step_and_plate):
297
309
  assert _reads(step) == _inherited("plate", PLATE)
298
310
 
299
311
 
300
- def test_context_entered_after_the_build_changes_no_read(step_and_plate):
301
- step, _ = step_and_plate
302
- expected = _inherited("plate", PLATE)
303
- assert _reads(step) == expected
304
-
305
- with config_context(Holder(settings=LazySettings(value="ambient")), scope_id="ambient"):
306
- inside = _reads(step)
307
-
308
- # ``uncached`` is resolved inside the entered context on purpose; see the last
309
- # test in this file for what that shows.
310
- assert inside._replace(uncached="plate") == expected
311
- assert _reads(step) == expected
312
-
313
-
314
312
  # --- Sources other than registration --------------------------------------------
315
313
  # A resolved snapshot is computed from the global config and from the objects of
316
314
  # the states registered above the state. The cases below change one of those
@@ -565,10 +563,7 @@ def test_global_state_edit_reaches_states_two_levels_down(step_under_plate):
565
563
  global_state.update_parameter(PATH, "typed")
566
564
  assert _reads(step) == _inherited("typed", GLOBAL, saved="global")
567
565
 
568
- # Saving the global state does not store the saved global config; the
569
- # application does, with the setter.
570
566
  global_state.mark_saved()
571
- set_saved_global_config(GlobalConfig, global_state.to_object())
572
567
  assert _reads(step) == _inherited("typed", GLOBAL)
573
568
 
574
569
 
@@ -614,17 +609,363 @@ def test_another_thread_does_not_make_a_state_resolve_again(step_under_plate, re
614
609
  assert step.get_resolved_value(PATH) == "from a thread"
615
610
 
616
611
 
617
- # --- Known to fail --------------------------------------------------------------
618
- # Open in issue #53: an owner decision on whether a state's values may depend on a
619
- # context the caller has entered.
612
+ # --- A context the caller has entered is not a source ---------------------------
613
+ # A state resolves against the global config, the states registered above it and its
614
+ # own object. ``config_context`` blocks entered around a build, a read or a change
615
+ # supply nothing, whatever they hold and whatever scope they claim.
616
+
617
+ OTHER_PATH = "settings.other"
618
+ AMBIENT = "ambient" # a scope where no state is registered
619
+
620
+
621
+ def _ambient(value: str = "ambient", **entered):
622
+ return config_context(
623
+ Holder(settings=LazySettings(value=value, other=f"{value} other")), **entered
624
+ )
625
+
626
+
627
+ def _nested():
628
+ contexts = ExitStack()
629
+ contexts.enter_context(_ambient("outer", scope_id="outer"))
630
+ contexts.enter_context(_ambient("inner", scope_id="inner"))
631
+ return contexts
620
632
 
621
- OPEN = "open, see https://github.com/OpenHCSDev/ObjectState/issues/53: "
633
+
634
+ def _ambient_global_config():
635
+ return config_context(
636
+ GlobalConfig(settings=Settings(value="ambient", other="ambient other")), scope_id=GLOBAL
637
+ )
638
+
639
+
640
+ ENTERED = {
641
+ "one context": lambda: _ambient(scope_id=AMBIENT),
642
+ "without a scope id": _ambient,
643
+ "under the scope of a registered ancestor": lambda: _ambient(scope_id=PLATE),
644
+ "under the scope of the state itself": lambda: _ambient(scope_id=STEP),
645
+ "under the global scope": lambda: _ambient(scope_id=GLOBAL),
646
+ "a global config": _ambient_global_config,
647
+ "masking the layers outside it": lambda: _ambient(scope_id=AMBIENT, mask_with_none=True),
648
+ "started from the saved global config": lambda: _ambient(
649
+ scope_id=AMBIENT, use_live_global=False
650
+ ),
651
+ "nested": _nested,
652
+ }
653
+
654
+
655
+ def _start_over() -> None:
656
+ ObjectStateRegistry.clear()
657
+ set_base_config_type(GlobalConfig)
658
+ set_global_config_for_editing(GlobalConfig, GlobalConfig(settings=Settings(value="global")))
659
+
660
+
661
+ def _history(entered) -> list:
662
+ """Every read and notification of three steps while their sources change inside ``entered()``.
663
+
664
+ One step is built and registered before the block, one is built inside it and
665
+ never registered, one is built inside it and first read after it.
666
+ """
667
+ plate = ObjectState(Holder(settings=LazySettings(other="plate other")), scope_id=PLATE)
668
+ ObjectStateRegistry.register(plate)
669
+ states: dict[str, ObjectState] = {}
670
+ told: list[tuple] = []
671
+ history: list[tuple] = []
672
+
673
+ def follow(name: str, state: ObjectState) -> ObjectState:
674
+ states[name] = state
675
+ state.on_resolved_changed(lambda paths: told.append((name, "resolved", frozenset(paths))))
676
+ state.on_state_changed(lambda paths: told.append((name, "state", frozenset(paths))))
677
+ return state
678
+
679
+ def record(event: str) -> None:
680
+ registered = {state.scope_id for state in ObjectStateRegistry.get_all()} | {GLOBAL}
681
+ seen = {}
682
+ for name, state in states.items():
683
+ # ``dirty_fields`` first: it is a read like the others and must not need one.
684
+ dirty = frozenset(state.dirty_fields)
685
+ other = (
686
+ state.get_resolved_value(OTHER_PATH),
687
+ state.get_saved_resolved_value(OTHER_PATH),
688
+ state.get_provenance(OTHER_PATH),
689
+ )
690
+ seen[name] = (dirty, _reads(state), other)
691
+ for path in (PATH, OTHER_PATH):
692
+ source = state.get_provenance(path)
693
+ assert source is None or source[0] in registered, (event, name, path, source)
694
+ history.append((event, seen, tuple(told)))
695
+
696
+ before = follow("built before", _step_state())
697
+ ObjectStateRegistry.register(before)
698
+ record("before the block")
699
+
700
+ with entered():
701
+ follow("built inside", _step_state_at(f"{PLATE}::inside"))
702
+ late = _step_state_at(f"{PLATE}::late")
703
+ record("built")
704
+
705
+ _replace_global("replaced")
706
+ record("global config replaced")
707
+
708
+ plate.update_parameter(PATH, "edited")
709
+ record("ancestor edited")
710
+
711
+ plate.mark_saved()
712
+ record("ancestor saved")
713
+
714
+ plate.update_parameter(OTHER_PATH, None)
715
+ record("ancestor gives a field up")
716
+
717
+ _replace_global("saved too", saved=True)
718
+ record("saved global config replaced")
719
+
720
+ before.invalidate_cache()
721
+ record("every field resolved again")
722
+
723
+ with _ambient("deeper", scope_id="deeper"):
724
+ plate.update_parameter(PATH, "edited deeper")
725
+ record("ancestor edited one block further in")
726
+
727
+ ObjectStateRegistry.unregister(plate)
728
+ record("ancestor unregistered")
729
+
730
+ ObjectStateRegistry.register(plate)
731
+ record("ancestor registered again")
732
+
733
+ before.update_parameter(PATH, "own")
734
+ record("own value set")
735
+
736
+ before.update_parameter(PATH, None)
737
+ before.mark_saved()
738
+ record("own value given up and saved")
739
+
740
+ plate.restore_saved()
741
+ record("ancestor restored")
742
+
743
+ # Last: editing the state of the global config leaves the entered context.
744
+ global_state = ObjectState(get_live_global_config(GlobalConfig), scope_id=GLOBAL)
745
+ ObjectStateRegistry.register(global_state)
746
+ plate.update_parameter(PATH, None)
747
+ plate.mark_saved()
748
+ global_state.update_parameter(PATH, "typed")
749
+ record("global state edited")
750
+
751
+ global_state.mark_saved()
752
+ record("global state saved")
753
+
754
+ follow("first read after the block", late)
755
+ record("after the block")
756
+ return history
757
+
758
+
759
+ def test_the_changes_of_the_history_are_seen_outside_any_context():
760
+ """The sequence the next test repeats inside contexts does change what is read."""
761
+ history = {event: seen for event, seen, _ in _history(nullcontext)}
762
+
763
+ def value_reads(event: str) -> set:
764
+ return {reads for _, reads, _ in history[event].values()}
765
+
766
+ assert value_reads("built") == {FROM_GLOBAL}
767
+ assert value_reads("global config replaced") == {
768
+ _inherited("replaced", GLOBAL, saved="global")
769
+ }
770
+ assert value_reads("ancestor edited") == {_inherited("edited", PLATE, saved="global")}
771
+ assert value_reads("ancestor saved") == {_inherited("edited", PLATE)}
772
+ assert {other for _, _, other in history["ancestor saved"].values()} == {
773
+ ("plate other", "plate other", (PLATE, Settings))
774
+ }
775
+ assert {other for _, _, other in history["ancestor gives a field up"].values()} == {
776
+ (None, "plate other", (GLOBAL, Settings))
777
+ }
778
+ assert value_reads("ancestor unregistered") == {_inherited("saved too", GLOBAL)}
779
+ assert value_reads("global state edited") == {_inherited("typed", GLOBAL, saved="saved too")}
780
+ assert value_reads("after the block") == {_inherited("typed", GLOBAL)}
781
+
782
+
783
+ @pytest.mark.parametrize("entered", ENTERED)
784
+ def test_reads_and_notifications_inside_an_entered_context_are_those_outside(entered):
785
+ outside = _history(nullcontext)
786
+ _start_over()
787
+
788
+ assert _history(ENTERED[entered]) == outside
622
789
 
623
790
 
624
- @pytest.mark.xfail(strict=True, reason=OPEN + "a state resolved inside a context keeps its values")
625
791
  def test_resolving_inside_an_entered_context_does_not_take_its_values():
626
- entered = Holder(settings=LazySettings(value="ambient"))
627
- with config_context(entered, scope_id="ambient"):
792
+ with _ambient(scope_id=AMBIENT):
628
793
  step = _step_state()
794
+ assert _reads(step) == FROM_GLOBAL
629
795
 
630
796
  assert _reads(step) == FROM_GLOBAL
797
+
798
+
799
+ def test_layers_a_state_resolves_against_start_from_the_global_config(step_under_plate):
800
+ step, plate = step_under_plate
801
+ ancestors = ObjectStateRegistry.get_ancestor_objects_with_scopes(step.scope_id)
802
+
803
+ with _nested():
804
+ entered = get_context_layer_stack()
805
+ with build_context_stack(step.to_object(), ancestors, step.scope_id):
806
+ layers = get_context_layer_stack().layers
807
+ assert [(layer.scope_id, layer.obj) for layer in layers] == [
808
+ (GLOBAL, get_live_global_config(GlobalConfig)),
809
+ *ancestors,
810
+ (STEP, step.to_object()),
811
+ ]
812
+ assert ancestors[0] == (PLATE, plate.to_object())
813
+ assert get_context_layer_stack() is entered
814
+
815
+ with pytest.raises(ZeroDivisionError):
816
+ with build_context_stack(step.to_object(), ancestors, step.scope_id):
817
+ 1 / 0
818
+ assert get_context_layer_stack() is entered
819
+
820
+
821
+ def test_lazy_object_no_state_owns_resolves_against_the_entered_context(step_under_plate):
822
+ """Plain attribute access reads the entered context, before and after a state resolves in it."""
823
+ step, plate = step_under_plate
824
+ plain = LazySettings()
825
+
826
+ with _ambient(scope_id=AMBIENT):
827
+ entered = get_context_layer_stack()
828
+
829
+ def reads_the_entered_context() -> None:
830
+ assert (plain.value, Holder().settings.other) == ("ambient", "ambient other")
831
+ assert tuple(resolve_with_provenance(LazySettings, "value")) == (
832
+ "ambient",
833
+ AMBIENT,
834
+ Settings,
835
+ )
836
+ assert get_context_layer_stack() is entered
837
+
838
+ reads_the_entered_context()
839
+
840
+ built_inside = _step_state_at(f"{PLATE}::inside")
841
+ assert _reads(built_inside) == _inherited("plate", PLATE)
842
+ reads_the_entered_context()
843
+
844
+ plate.update_parameter(PATH, "edited")
845
+ assert step.get_resolved_value(PATH) == "edited"
846
+ reads_the_entered_context()
847
+
848
+ step.invalidate_cache()
849
+ _replace_global("replaced")
850
+ assert step.get_resolved_value(PATH) == "edited"
851
+ reads_the_entered_context()
852
+
853
+ with _ambient("inner", scope_id="inner"):
854
+ assert built_inside.get_provenance(PATH) == (PLATE, Settings)
855
+ assert plain.value == "inner"
856
+ reads_the_entered_context()
857
+
858
+
859
+ def test_context_entered_in_another_thread_is_not_a_source():
860
+ entered, read = threading.Event(), threading.Event()
861
+
862
+ def hold_a_context() -> None:
863
+ with _ambient(scope_id=AMBIENT):
864
+ entered.set()
865
+ read.wait(timeout=10)
866
+
867
+ thread = threading.Thread(target=hold_a_context)
868
+ thread.start()
869
+ try:
870
+ assert entered.wait(timeout=10)
871
+ assert _reads(_step_state()) == FROM_GLOBAL
872
+ finally:
873
+ read.set()
874
+ thread.join()
875
+
876
+
877
+ def test_thread_started_with_the_entered_context_reads_states_as_outside():
878
+ """``spawn_thread_with_context`` copies the entered context into the thread."""
879
+ step = _step_state()
880
+ configs = get_live_global_config(GlobalConfig), get_saved_global_config(GlobalConfig)
881
+ seen: list = []
882
+
883
+ def read() -> None:
884
+ set_live_global_config(GlobalConfig, configs[0]) # stored per thread
885
+ set_saved_global_config(GlobalConfig, configs[1])
886
+ seen.extend([LazySettings().value, _reads(step), _reads(_step_state())])
887
+
888
+ with _ambient(scope_id=AMBIENT):
889
+ spawn_thread_with_context(read).join()
890
+
891
+ assert seen == ["ambient", FROM_GLOBAL, FROM_GLOBAL]
892
+
893
+
894
+ # --- Saving the state of the global config --------------------------------------
895
+ # Saving that state makes its saved object the saved global config. An application
896
+ # that also stores it with the setter, before or after, ends in the same place.
897
+
898
+ SAVE_ORDERS = {
899
+ "save the state": ("save",),
900
+ "save the state, then store": ("save", "store"),
901
+ "store, then save the state": ("store", "save"),
902
+ }
903
+
904
+
905
+ def _save_a_global_edit(order: tuple[str, ...], *, registered: bool):
906
+ """Edit ``value`` in the global state, save in ``order``; what its descendants then read."""
907
+ global_state = ObjectState(get_live_global_config(GlobalConfig), scope_id=GLOBAL)
908
+ ObjectStateRegistry.register(global_state)
909
+ descendants = [ObjectState(Holder(), scope_id=PLATE), _step_state()]
910
+ told: list[tuple] = []
911
+ for state in descendants:
912
+ if registered:
913
+ ObjectStateRegistry.register(state)
914
+ state.on_state_changed(
915
+ lambda paths, state=state: told.append(
916
+ (state.scope_id, frozenset(paths), frozenset(state.dirty_fields))
917
+ )
918
+ )
919
+
920
+ global_state.update_parameter(PATH, "typed")
921
+ assert get_live_global_config(GlobalConfig).settings.value == "typed"
922
+ assert get_saved_global_config(GlobalConfig).settings.value == "global"
923
+ edited = global_state.to_object()
924
+
925
+ for operation in order:
926
+ if operation == "save":
927
+ global_state.mark_saved()
928
+ else:
929
+ set_saved_global_config(GlobalConfig, edited)
930
+
931
+ told_dirty = {scope: dirty for scope, _, dirty in told} # the last each was told
932
+ dirty = [set(state.dirty_fields) for state in (global_state, *descendants)]
933
+ reads = [_reads(state) for state in descendants]
934
+ return (
935
+ dirty,
936
+ reads,
937
+ told_dirty,
938
+ tuple(told),
939
+ get_saved_global_config(GlobalConfig).settings.value,
940
+ )
941
+
942
+
943
+ @pytest.mark.parametrize("registered", [True, False], ids=["registered", "not registered"])
944
+ def test_saving_the_global_state_in_any_order_leaves_nothing_dirty(registered):
945
+ results = {}
946
+ for name, order in SAVE_ORDERS.items():
947
+ _start_over()
948
+ results[name] = _save_a_global_edit(order, registered=registered)
949
+
950
+ saved = _inherited("typed", GLOBAL)
951
+ # A state that is not registered is told on its next read, and by then it is
952
+ # as clean as it was before the edit.
953
+ told_clean = {PLATE: frozenset(), STEP: frozenset()} if registered else {}
954
+ expected = ([set(), set(), set()], [saved, saved], told_clean, "typed")
955
+ for name, (dirty, reads, told_dirty, _, saved_global) in results.items():
956
+ assert (dirty, reads, told_dirty, saved_global) == expected, name
957
+ assert len({told for *_, told, _ in results.values()}) == 1 # told the same, in the same order
958
+
959
+
960
+ def test_registered_descendants_are_told_of_the_save_without_a_read():
961
+ global_state = ObjectState(get_live_global_config(GlobalConfig), scope_id=GLOBAL)
962
+ step = _step_state()
963
+ for state in (global_state, step):
964
+ ObjectStateRegistry.register(state)
965
+ global_state.update_parameter(PATH, "typed")
966
+ assert step._dirty_fields == {PATH}
967
+
968
+ global_state.mark_saved()
969
+
970
+ assert step._dirty_fields == set()
971
+ assert step._saved_resolved[PATH] == "typed"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes