objectstate 1.2.0__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.2.0 → objectstate-2.0.0}/.github/workflows/publish.yml +11 -0
  2. {objectstate-1.2.0 → objectstate-2.0.0}/PKG-INFO +2 -2
  3. objectstate-2.0.0/docs/performance/canonical-context-type-resolution-20261005.json +45 -0
  4. {objectstate-1.2.0 → objectstate-2.0.0}/pyproject.toml +1 -1
  5. {objectstate-1.2.0 → objectstate-2.0.0}/scripts/verify_release_ready.py +3 -8
  6. {objectstate-1.2.0 → objectstate-2.0.0}/src/objectstate/__init__.py +5 -6
  7. {objectstate-1.2.0 → objectstate-2.0.0}/src/objectstate/context_manager.py +0 -50
  8. {objectstate-1.2.0 → objectstate-2.0.0}/src/objectstate/dual_axis_resolver.py +31 -33
  9. objectstate-2.0.0/src/objectstate/inspection.py +162 -0
  10. {objectstate-1.2.0 → objectstate-2.0.0}/src/objectstate/lazy_factory.py +43 -0
  11. {objectstate-1.2.0 → objectstate-2.0.0}/src/objectstate/object_state.py +28 -76
  12. {objectstate-1.2.0 → objectstate-2.0.0}/src/objectstate/object_state_registry.py +31 -71
  13. {objectstate-1.2.0 → objectstate-2.0.0}/src/objectstate/parameter_structure.py +15 -2
  14. objectstate-2.0.0/src/objectstate/scope_key.py +88 -0
  15. objectstate-2.0.0/tests/test_authored_paths.py +29 -0
  16. {objectstate-1.2.0 → objectstate-2.0.0}/tests/test_construction_history.py +2 -21
  17. objectstate-2.0.0/tests/test_inspection.py +51 -0
  18. objectstate-2.0.0/tests/test_release_readiness.py +17 -0
  19. objectstate-2.0.0/tests/test_scope_key.py +47 -0
  20. objectstate-1.2.0/src/objectstate/history_migration.py +0 -19
  21. {objectstate-1.2.0 → objectstate-2.0.0}/.github/badges/coverage.svg +0 -0
  22. {objectstate-1.2.0 → objectstate-2.0.0}/.github/workflows/ci.yml +0 -0
  23. {objectstate-1.2.0 → objectstate-2.0.0}/.github/workflows/coverage-pages.yml +0 -0
  24. {objectstate-1.2.0 → objectstate-2.0.0}/.gitignore +0 -0
  25. {objectstate-1.2.0 → objectstate-2.0.0}/.readthedocs.yaml +0 -0
  26. {objectstate-1.2.0 → objectstate-2.0.0}/BADGE_SETUP.md +0 -0
  27. {objectstate-1.2.0 → objectstate-2.0.0}/LICENSE +0 -0
  28. {objectstate-1.2.0 → objectstate-2.0.0}/README.md +0 -0
  29. {objectstate-1.2.0 → objectstate-2.0.0}/docs/.gitignore +0 -0
  30. {objectstate-1.2.0 → objectstate-2.0.0}/docs/Makefile +0 -0
  31. {objectstate-1.2.0 → objectstate-2.0.0}/docs/api/modules.rst +0 -0
  32. {objectstate-1.2.0 → objectstate-2.0.0}/docs/architectural_fixes.rst +0 -0
  33. {objectstate-1.2.0 → objectstate-2.0.0}/docs/architecture.rst +0 -0
  34. {objectstate-1.2.0 → objectstate-2.0.0}/docs/archive/DOCUMENTATION_AUDIT.md +0 -0
  35. {objectstate-1.2.0 → objectstate-2.0.0}/docs/archive/DOCUMENTATION_FIXES.md +0 -0
  36. {objectstate-1.2.0 → objectstate-2.0.0}/docs/archive/README.md +0 -0
  37. {objectstate-1.2.0 → objectstate-2.0.0}/docs/conf.py +0 -0
  38. {objectstate-1.2.0 → objectstate-2.0.0}/docs/examples/atomic_operations.rst +0 -0
  39. {objectstate-1.2.0 → objectstate-2.0.0}/docs/examples/basic.rst +0 -0
  40. {objectstate-1.2.0 → objectstate-2.0.0}/docs/examples/dual_axis.rst +0 -0
  41. {objectstate-1.2.0 → objectstate-2.0.0}/docs/examples/index.rst +0 -0
  42. {objectstate-1.2.0 → objectstate-2.0.0}/docs/examples/ui.rst +0 -0
  43. {objectstate-1.2.0 → objectstate-2.0.0}/docs/index.rst +0 -0
  44. {objectstate-1.2.0 → objectstate-2.0.0}/docs/make.bat +0 -0
  45. {objectstate-1.2.0 → objectstate-2.0.0}/docs/provenance.rst +0 -0
  46. {objectstate-1.2.0 → objectstate-2.0.0}/docs/quickstart.rst +0 -0
  47. {objectstate-1.2.0 → objectstate-2.0.0}/docs/state_management.rst +0 -0
  48. {objectstate-1.2.0 → objectstate-2.0.0}/docs/state_propagation.rst +0 -0
  49. {objectstate-1.2.0 → objectstate-2.0.0}/docs/undo_redo.rst +0 -0
  50. {objectstate-1.2.0 → objectstate-2.0.0}/paper.bib +0 -0
  51. {objectstate-1.2.0 → objectstate-2.0.0}/paper.md +0 -0
  52. {objectstate-1.2.0 → objectstate-2.0.0}/plans/n_dimensional_inheritance.md +0 -0
  53. {objectstate-1.2.0 → objectstate-2.0.0}/scripts/check_ruff_added_lines.py +0 -0
  54. {objectstate-1.2.0 → objectstate-2.0.0}/scripts/release.py +0 -0
  55. {objectstate-1.2.0 → objectstate-2.0.0}/scripts/update_and_release.py +0 -0
  56. {objectstate-1.2.0 → objectstate-2.0.0}/scripts/update_version.py +0 -0
  57. {objectstate-1.2.0 → objectstate-2.0.0}/src/objectstate/collection_containers.py +0 -0
  58. {objectstate-1.2.0 → objectstate-2.0.0}/src/objectstate/config.py +0 -0
  59. {objectstate-1.2.0 → objectstate-2.0.0}/src/objectstate/construction_binding.py +0 -0
  60. {objectstate-1.2.0 → objectstate-2.0.0}/src/objectstate/docs/configuration_framework.rst +0 -0
  61. {objectstate-1.2.0 → objectstate-2.0.0}/src/objectstate/docs/context_system.rst +0 -0
  62. {objectstate-1.2.0 → objectstate-2.0.0}/src/objectstate/docs/object_state_lifecycle.rst +0 -0
  63. {objectstate-1.2.0 → objectstate-2.0.0}/src/objectstate/edit_session.py +0 -0
  64. {objectstate-1.2.0 → objectstate-2.0.0}/src/objectstate/field_access.py +0 -0
  65. {objectstate-1.2.0 → objectstate-2.0.0}/src/objectstate/global_config.py +0 -0
  66. {objectstate-1.2.0 → objectstate-2.0.0}/src/objectstate/live_context_resolver.py +0 -0
  67. {objectstate-1.2.0 → objectstate-2.0.0}/src/objectstate/object_state_metadata.py +0 -0
  68. {objectstate-1.2.0 → objectstate-2.0.0}/src/objectstate/parameter_owner.py +0 -0
  69. {objectstate-1.2.0 → objectstate-2.0.0}/src/objectstate/parametric_axes.py +0 -0
  70. {objectstate-1.2.0 → objectstate-2.0.0}/src/objectstate/placeholder.py +0 -0
  71. {objectstate-1.2.0 → objectstate-2.0.0}/src/objectstate/reified_generics.py +0 -0
  72. {objectstate-1.2.0 → objectstate-2.0.0}/src/objectstate/snapshot_model.py +0 -0
  73. {objectstate-1.2.0 → objectstate-2.0.0}/src/objectstate/subfield_semantics.py +0 -0
  74. {objectstate-1.2.0 → objectstate-2.0.0}/src/objectstate/time_travel_profile.py +0 -0
  75. {objectstate-1.2.0 → objectstate-2.0.0}/src/objectstate/token_cache.py +0 -0
  76. {objectstate-1.2.0 → objectstate-2.0.0}/src/objectstate/transaction_checkpoint.py +0 -0
  77. {objectstate-1.2.0 → objectstate-2.0.0}/src/objectstate/ui_visibility.py +0 -0
  78. {objectstate-1.2.0 → objectstate-2.0.0}/src/objectstate/value_semantics.py +0 -0
  79. {objectstate-1.2.0 → objectstate-2.0.0}/tests/__init__.py +0 -0
  80. {objectstate-1.2.0 → objectstate-2.0.0}/tests/conftest.py +0 -0
  81. {objectstate-1.2.0 → objectstate-2.0.0}/tests/test_callable_baseline.py +0 -0
  82. {objectstate-1.2.0 → objectstate-2.0.0}/tests/test_config.py +0 -0
  83. {objectstate-1.2.0 → objectstate-2.0.0}/tests/test_context_manager.py +0 -0
  84. {objectstate-1.2.0 → objectstate-2.0.0}/tests/test_field_access.py +0 -0
  85. {objectstate-1.2.0 → objectstate-2.0.0}/tests/test_global_config.py +0 -0
  86. {objectstate-1.2.0 → objectstate-2.0.0}/tests/test_history_persistence.py +0 -0
  87. {objectstate-1.2.0 → objectstate-2.0.0}/tests/test_integration.py +0 -0
  88. {objectstate-1.2.0 → objectstate-2.0.0}/tests/test_lazy_factory.py +0 -0
  89. {objectstate-1.2.0 → objectstate-2.0.0}/tests/test_object_state_metadata_contracts.py +0 -0
  90. {objectstate-1.2.0 → objectstate-2.0.0}/tests/test_object_state_restore.py +0 -0
  91. {objectstate-1.2.0 → objectstate-2.0.0}/tests/test_object_state_update_instance.py +0 -0
  92. {objectstate-1.2.0 → objectstate-2.0.0}/tests/test_parameter_owner.py +0 -0
  93. {objectstate-1.2.0 → objectstate-2.0.0}/tests/test_parametric_axes.py +0 -0
  94. {objectstate-1.2.0 → objectstate-2.0.0}/tests/test_placeholder.py +0 -0
  95. {objectstate-1.2.0 → objectstate-2.0.0}/tests/test_registry_subscription.py +0 -0
  96. {objectstate-1.2.0 → objectstate-2.0.0}/tests/test_reified_generics.py +0 -0
  97. {objectstate-1.2.0 → objectstate-2.0.0}/tests/test_resolved_change_notifications.py +0 -0
  98. {objectstate-1.2.0 → objectstate-2.0.0}/tests/test_subfield_semantics.py +0 -0
  99. {objectstate-1.2.0 → objectstate-2.0.0}/tests/test_timeline_projection.py +0 -0
  100. {objectstate-1.2.0 → objectstate-2.0.0}/tests/test_transaction_checkpoint.py +0 -0
  101. {objectstate-1.2.0 → objectstate-2.0.0}/tests/test_ui_visible_field_projection.py +0 -0
  102. {objectstate-1.2.0 → objectstate-2.0.0}/tests/test_undo_redo_cascade.py +0 -0
  103. {objectstate-1.2.0 → objectstate-2.0.0}/tests/test_value_semantics.py +0 -0
@@ -4,6 +4,12 @@ on:
4
4
  push:
5
5
  tags:
6
6
  - 'v*'
7
+ workflow_dispatch:
8
+ inputs:
9
+ release_tag:
10
+ description: Existing release tag to publish
11
+ required: true
12
+ type: string
7
13
 
8
14
  permissions:
9
15
  contents: write
@@ -13,6 +19,8 @@ jobs:
13
19
  build-and-publish:
14
20
  name: Build and publish to PyPI
15
21
  runs-on: ubuntu-latest
22
+ env:
23
+ RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }}
16
24
  environment:
17
25
  name: pypi
18
26
  url: https://pypi.org/p/objectstate
@@ -20,6 +28,8 @@ jobs:
20
28
  steps:
21
29
  - name: Checkout code
22
30
  uses: actions/checkout@v4
31
+ with:
32
+ ref: refs/tags/${{ env.RELEASE_TAG }}
23
33
 
24
34
  - name: Set up Python
25
35
  uses: actions/setup-python@v5
@@ -42,6 +52,7 @@ jobs:
42
52
  - name: Create GitHub Release
43
53
  uses: softprops/action-gh-release@v2
44
54
  with:
55
+ tag_name: ${{ env.RELEASE_TAG }}
45
56
  files: dist/*
46
57
  generate_release_notes: true
47
58
  env:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: objectstate
3
- Version: 1.2.0
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
@@ -21,7 +21,7 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
21
  Classifier: Topic :: Utilities
22
22
  Requires-Python: >=3.11
23
23
  Requires-Dist: dill>=0.4.0
24
- Requires-Dist: python-introspect>=0.1.6
24
+ Requires-Dist: python-introspect<0.3,>=0.2.0
25
25
  Provides-Extra: dev
26
26
  Requires-Dist: black==26.5.1; extra == 'dev'
27
27
  Requires-Dist: mypy==2.3.0; extra == 'dev'
@@ -0,0 +1,45 @@
1
+ {
2
+ "status": "PASS_PRIVATE_SAVED_CONSUMER_ONLY",
3
+ "baseline": "Installed ObjectState 1.3.0 from merged 7db151907a52a95743fbeae5a64220b28bc56690, PythonIntrospect 0.2.0",
4
+ "change": "resolve_with_provenance queries the existing canonical type-keyed maps admitted by extract_all_configs instead of scanning every value and reconstructing each owner type.",
5
+ "controls": {
6
+ "passed": 27,
7
+ "command": "PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=src python -m pytest -q -o addopts= -p no:cacheprovider tests/test_context_manager.py tests/test_object_state_restore.py tests/test_callable_baseline.py tests/test_placeholder.py",
8
+ "laws": "Canonical map admission, duplicate/invalid declarations, MRO/None/provenance, real constructors/default factories, callable identity, detached mutable alias graphs"
9
+ },
10
+ "one_pair": [
11
+ {
12
+ "case": "Illumination Example3",
13
+ "first_job": true,
14
+ "steps": 10,
15
+ "baseline_seconds": 0.08565246313810349,
16
+ "candidate_seconds": 0.0676199309527874,
17
+ "saved_seconds": 0.018032532185316086
18
+ },
19
+ {
20
+ "case": "Original 3D monolayer",
21
+ "first_job": false,
22
+ "steps": 31,
23
+ "baseline_seconds": 0.2247260957956314,
24
+ "candidate_seconds": 0.19058690778911114,
25
+ "saved_seconds": 0.03413918800652027
26
+ }
27
+ ],
28
+ "parity": "Exact typed configuration/steps, provenance, canonical source and ancestor scopes PASS",
29
+ "guards": "Before/after source, actual eight installed dependency trees and wheel metadata, four native ABIs, authored input sources and helper hashes PASS; all owned processes terminal",
30
+ "preserved": "Context constructors/merges and extraction epochs, selected live field reads, same-type scope precedence, MRO class-first precedence, None inheritance, AttributeError behavior, fallback provenance and direct-owner default factories",
31
+ "limits": [
32
+ "ONE saved-owner pair, not a whole compiler, ordinary runtime or total speedup measurement.",
33
+ "Normal full Registry READY, document parsing, orchestrator initialization, graph/provider admission and execution were outside the clocks.",
34
+ "3D follows Illumination in each process; it is not an independent cold first-job observation.",
35
+ "This generalized cleanup is not the dominant performance route; the first-case 18 ms saving does not close the remaining total target gap.",
36
+ "Arbitrary corruption of the admitted map/type keys or in-operation instance __class__ rebinding was not established equivalent. Mutable field values remain live.",
37
+ "The public resolver accepting manually supplied maps is unchanged. No new cache, value snapshot or installed dependency mutation."
38
+ ],
39
+ "custody": {
40
+ "private_packet": "20261005/canonical-context-type-resolution-v1",
41
+ "baseline_resolver_sha256": "17fcda62912050bdcdd018bee2fca5f81c2f4940c5f27f2af3dc19b7691e1fb0",
42
+ "candidate_resolver_sha256": "e8e939bfb97ad2d07cb0f4893e43229fc30926274e7bdc7a4491264feeaeae97",
43
+ "version_change": "1.3.1 is a separate patch release; immutable 1.3.0 tags and installed wheel are untouched."
44
+ }
45
+ }
@@ -25,7 +25,7 @@ keywords = ["configuration", "dataclass", "lazy", "inheritance", "contextvars",
25
25
 
26
26
  dependencies = [
27
27
  "dill>=0.4.0",
28
- "python-introspect>=0.1.6",
28
+ "python-introspect>=0.2.0,<0.3",
29
29
  ]
30
30
 
31
31
  [project.optional-dependencies]
@@ -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.2.0"
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
 
@@ -324,24 +324,23 @@ def resolve_with_provenance(container_type: type, field_name: str) -> Tuple[Any,
324
324
  if _debug and field_name == 'well_filter':
325
325
  logger.debug(f"🔍 Phase 1 - Layer scope={scope_id!r}, checking same-type only (inner to outer)")
326
326
 
327
- for config_instance in layer_configs.values():
328
- instance_base = _normalize_to_base(type(config_instance))
329
- if _debug and field_name in ('well_filter', 'enabled') and instance_base == container_base:
330
- logger.debug(f"🔍 FOUND same-type config: {instance_base.__name__} @ scope={scope_id}")
331
- if instance_base == container_base: # Same-type only, no MRO
332
- try:
333
- value = object.__getattribute__(config_instance, field_name)
334
- if _debug and field_name in ('well_filter', 'enabled'):
335
- logger.debug(f"🔍 {container_base.__name__}.{field_name} @ scope={scope_id} = {value!r} (from object.__getattribute__)")
336
- if value is not None:
337
- # Found concrete value in hierarchy - return immediately
338
- if _debug and field_name in ('well_filter', 'enabled'):
339
- logger.debug(f"🔍 FOUND concrete value in hierarchy at scope={scope_id!r}, returning {value!r}")
340
- return value, scope_id, container_base
341
- # Don't set fallback here - let Phase 2 walk MRO to find
342
- # the highest type that defines this field
343
- except AttributeError:
344
- continue
327
+ # extract_all_configs admits one instance per canonical owner type.
328
+ config_instance = layer_configs.get(container_base)
329
+ if config_instance is None:
330
+ continue
331
+ try:
332
+ value = object.__getattribute__(config_instance, field_name)
333
+ if _debug and field_name in ('well_filter', 'enabled'):
334
+ logger.debug(f"🔍 {container_base.__name__}.{field_name} @ scope={scope_id} = {value!r} (from object.__getattribute__)")
335
+ if value is not None:
336
+ # Found concrete value in hierarchy - return immediately
337
+ if _debug and field_name in ('well_filter', 'enabled'):
338
+ logger.debug(f"🔍 FOUND concrete value in hierarchy at scope={scope_id!r}, returning {value!r}")
339
+ return value, scope_id, container_base
340
+ # Don't set fallback here - let Phase 2 walk MRO to find
341
+ # the highest type that defines this field
342
+ except AttributeError:
343
+ continue
345
344
 
346
345
  # PHASE 2: MRO fallback - no concrete value in hierarchy, try MRO inheritance
347
346
  #
@@ -367,25 +366,24 @@ def resolve_with_provenance(container_type: type, field_name: str) -> Tuple[Any,
367
366
 
368
367
  # Walk scopes from inner→outer for this MRO type
369
368
  for scope_id, layer_configs in reversed(all_layer_configs):
370
- for config_instance in layer_configs.values():
371
- instance_base = _normalize_to_base(type(config_instance))
372
- if instance_base != mro_type:
373
- continue
369
+ config_instance = layer_configs.get(mro_type)
370
+ if config_instance is None:
371
+ continue
374
372
 
375
- saw_type_anywhere = True
376
- last_scope_for_type = scope_id # update as we walk inner→outer (last = outermost)
373
+ saw_type_anywhere = True
374
+ last_scope_for_type = scope_id # update as we walk inner→outer (last = outermost)
377
375
 
378
- try:
379
- value = object.__getattribute__(config_instance, field_name)
380
- except AttributeError:
381
- continue
376
+ try:
377
+ value = object.__getattribute__(config_instance, field_name)
378
+ except AttributeError:
379
+ continue
382
380
 
383
- if _debug and field_name == 'well_filter':
384
- logger.debug(f"🔍 MRO: {mro_type.__name__}.{field_name} @ {scope_id!r} = {value!r}")
381
+ if _debug and field_name == 'well_filter':
382
+ logger.debug(f"🔍 MRO: {mro_type.__name__}.{field_name} @ {scope_id!r} = {value!r}")
385
383
 
386
- if value is not None:
387
- # Found MRO-inherited value
388
- return value, scope_id, mro_type
384
+ if value is not None:
385
+ # Found MRO-inherited value
386
+ return value, scope_id, mro_type
389
387
 
390
388
  # No non-None found for this MRO type across any scope.
391
389
  # Record fallback for the FIRST MRO type that exists anywhere.
@@ -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