Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,22 @@ ensure_global_config_context(GlobalPipelineConfig, global_config)
pip install objectstate
```

## Saving Configs as Python Source

`objectstate.codegen` registers a [pycodify](https://github.com/OpenHCSDev/pycodify)
formatter for lazy dataclasses. Source is written from raw field values, so an
inherited `None` stays inherited when the file is loaded back:

```python
import objectstate.codegen # registers the formatter
from pycodify import Assignment, generate_python_source

source = generate_python_source(Assignment("config", lazy_config), clean_mode=True)
```

Clean mode writes only constructor-set fields and concrete values; full mode
writes every field.

## ObjectState Registry

ObjectState separates mutable working state from saved baseline, enabling dirty tracking and undo/redo:
Expand Down Expand Up @@ -254,19 +270,19 @@ class GlobalPipelineConfig:
# Use the decorator on a new config class
@global_pipeline_config
@dataclass
class WellFilterConfig:
well_filter: str = None
class PartitionFilterConfig:
partition_filter: str = None
mode: str = "include"

# After module loading, GlobalPipelineConfig automatically has:
# - well_filter_config: WellFilterConfig = WellFilterConfig()
# And LazyWellFilterConfig is auto-created
# - partition_filter_config: PartitionFilterConfig = PartitionFilterConfig()
# And LazyPartitionFilterConfig is auto-created
```

**How it works:**
- Decorated classes are injected as fields into `GlobalPipelineConfig`
- Field name is snake_case of class name (e.g., `WellFilterConfig` → `well_filter_config`)
- Lazy version is automatically created (e.g., `LazyWellFilterConfig`)
- Field name is snake_case of class name (e.g., `PartitionFilterConfig` → `partition_filter_config`)
- Lazy version is automatically created (e.g., `LazyPartitionFilterConfig`)
- Injection happens at end of module loading via `_inject_all_pending_fields()`

This enables a clean, modular configuration structure where each component's config is automatically part of the global configuration.
Expand Down
6 changes: 3 additions & 3 deletions docs/architectural_fixes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ Fix 2: Enable Real-Time MRO Inheritance

**Problem:** During ``_recompute_invalid_fields()``, the code was using
``self.object_instance`` (original saved state) instead of reconstructing from current
parameters. This broke real-time MRO inheritance—editing ``WellFilterConfig.well_filter``
wouldn't update placeholders for inheriting fields like ``PathPlanningConfig.well_filter``.
parameters. This broke real-time MRO inheritance—editing ``PartitionFilterConfig.partition_filter``
wouldn't update placeholders for inheriting fields like ``PathPlanningConfig.partition_filter``.

**Solution:** Reconstruct current object from parameters without delegate sync:

Expand Down Expand Up @@ -233,7 +233,7 @@ Testing & Validation
These fixes were validated through:

1. **Manual testing**: Save GlobalPipelineConfig → no crash, descendants clear dirty markers
2. **Real-time inheritance**: Edit WellFilterConfig → inheriting fields update immediately
2. **Real-time inheritance**: Edit PartitionFilterConfig → inheriting fields update immediately
3. **Legacy compatibility**: Old snapshots with ``saved_parameters=None`` load correctly
4. **Multi-level inheritance**: Global → Pipeline → Step inheritance works correctly

Expand Down
20 changes: 10 additions & 10 deletions docs/architecture.rst
Original file line number Diff line number Diff line change
Expand Up @@ -348,20 +348,20 @@ When you use the generated decorator, the decorated class is automatically injec

@global_pipeline_config
@dataclass
class WellFilterConfig:
well_filter: str = None
class PartitionFilterConfig:
partition_filter: str = None
mode: str = "include"

# After module loading, GlobalPipelineConfig has:
# - well_filter_config: WellFilterConfig = WellFilterConfig()
# And LazyWellFilterConfig is auto-created
# - partition_filter_config: PartitionFilterConfig = PartitionFilterConfig()
# And LazyPartitionFilterConfig is auto-created

**Injection process:**

1. Decorated classes are registered for injection
2. At module load completion, ``_inject_all_pending_fields()`` is called
3. Field name is snake_case of class name (``WellFilterConfig`` → ``well_filter_config``)
4. Lazy version is created (``LazyWellFilterConfig``)
3. Field name is snake_case of class name (``PartitionFilterConfig`` → ``partition_filter_config``)
4. Lazy version is created (``LazyPartitionFilterConfig``)
5. Both the field and lazy class are added to the global config

**Benefits:**
Expand All @@ -384,9 +384,9 @@ Sets all inherited fields from parent classes to ``None`` by default:
.. code-block:: python

@dataclass
class StepWellFilterConfig:
class StepPartitionFilterConfig:
persistent: bool = True
well_filter: str = None
partition_filter: str = None

@dataclass
class StreamingDefaults:
Expand All @@ -395,10 +395,10 @@ Sets all inherited fields from parent classes to ``None`` by default:

@global_pipeline_config(inherit_as_none=True) # Default
@dataclass
class StreamingConfig(StepWellFilterConfig, StreamingDefaults):
class StreamingConfig(StepPartitionFilterConfig, StreamingDefaults):
"""Uses multiple inheritance.

All inherited fields (persistent, well_filter, host, port)
All inherited fields (persistent, partition_filter, host, port)
are automatically set to None for proper lazy resolution.
"""
stream_type: str = "napari"
Expand Down
22 changes: 11 additions & 11 deletions docs/examples/dual_axis.rst
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,9 @@ The ``inherit_as_none`` parameter enables proper dual-axis inheritance when usin

# Base class with some fields
@dataclass
class StepWellFilterConfig:
class StepPartitionFilterConfig:
persistent: bool = True
well_filter: str = None
partition_filter: str = None
timeout: int = 30

# Another base class with different fields
Expand All @@ -212,11 +212,11 @@ The ``inherit_as_none`` parameter enables proper dual-axis inheritance when usin
# Use inherit_as_none for multiple inheritance
@global_pipeline_config(inherit_as_none=True) # Default behavior
@dataclass
class StreamingConfig(StepWellFilterConfig, StreamingDefaults):
class StreamingConfig(StepPartitionFilterConfig, StreamingDefaults):
"""Config using multiple inheritance.

With inherit_as_none=True, all inherited fields
(persistent, well_filter, timeout, host, port, transport_mode)
(persistent, partition_filter, timeout, host, port, transport_mode)
are automatically set to None for proper lazy resolution.
"""
stream_type: str = "napari"
Expand All @@ -227,7 +227,7 @@ The ``inherit_as_none`` parameter enables proper dual-axis inheritance when usin
print(f"stream_type: {config.stream_type}") # "napari" (explicit)
print(f"buffer_size: {config.buffer_size}") # 1024 (explicit)
print(f"persistent: {config.persistent}") # None (inherited, set to None)
print(f"well_filter: {config.well_filter}") # None (was already None)
print(f"partition_filter: {config.partition_filter}") # None (was already None)
print(f"timeout: {config.timeout}") # None (inherited, set to None)
print(f"host: {config.host}") # None (inherited, set to None)
print(f"port: {config.port}") # None (inherited, set to None)
Expand All @@ -242,9 +242,9 @@ This enables polymorphic access to inherited fields without type-specific attrib
from objectstate import config_context

# Create a base config with values
base_config = StepWellFilterConfig(
base_config = StepPartitionFilterConfig(
persistent=False,
well_filter="A01",
partition_filter="A01",
timeout=60
)

Expand All @@ -261,7 +261,7 @@ This enables polymorphic access to inherited fields without type-specific attrib
# - stream_type from streaming_config ("custom")
# - buffer_size from streaming_config (2048)
# - persistent from base_config (False)
# - well_filter from base_config ("A01")
# - partition_filter from base_config ("A01")
# - timeout from base_config (60)
# - host, port, transport_mode from StreamingDefaults
pass
Expand All @@ -275,16 +275,16 @@ You can disable this behavior if you want inherited fields to keep their default

@global_pipeline_config(inherit_as_none=False)
@dataclass
class ConcreteStreamingConfig(StepWellFilterConfig, StreamingDefaults):
class ConcreteStreamingConfig(StepPartitionFilterConfig, StreamingDefaults):
"""Config with concrete inherited defaults.

Inherited fields keep their default values from parent classes.
"""
stream_type: str = "napari"

config = ConcreteStreamingConfig()
print(f"persistent: {config.persistent}") # True (from StepWellFilterConfig)
print(f"timeout: {config.timeout}") # 30 (from StepWellFilterConfig)
print(f"persistent: {config.persistent}") # True (from StepPartitionFilterConfig)
print(f"timeout: {config.timeout}") # 30 (from StepPartitionFilterConfig)
print(f"host: {config.host}") # "localhost" (from StreamingDefaults)
print(f"port: {config.port}") # 5000 (from StreamingDefaults)

Expand Down
24 changes: 12 additions & 12 deletions docs/provenance.rst
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,13 @@ Get the source scope_id and type for an inherited field value:
state = ObjectStateRegistry.get_by_scope("plate_123::step_0")

# Get provenance for a field
provenance = state.get_provenance("well_filter")
provenance = state.get_provenance("partition_filter")
if provenance:
source_scope, source_type = provenance
print(f"well_filter comes from scope: {source_scope}")
print(f"well_filter comes from type: {source_type.__name__}")
print(f"partition_filter comes from scope: {source_scope}")
print(f"partition_filter comes from type: {source_type.__name__}")
else:
print("well_filter is explicitly set on this object")
print("partition_filter is explicitly set on this object")

**Returns:**
* ``Tuple[str, type]`` if the value is inherited
Expand All @@ -95,7 +95,7 @@ Resolve a field value AND find its provenance source in ONE walk:
# Resolve value and get provenance simultaneously
value, source_scope, source_type = resolve_with_provenance(
container_type=PathPlanningConfig,
field_name="well_filter"
field_name="partition_filter"
)

print(f"Value: {value}")
Expand All @@ -105,7 +105,7 @@ Resolve a field value AND find its provenance source in ONE walk:
**Parameters:**

* ``container_type``: The type containing the field (e.g., ``LazyPathPlanningConfig``)
* ``field_name``: Name of the field to find provenance for (e.g., ``"well_filter"``)
* ``field_name``: Name of the field to find provenance for (e.g., ``"partition_filter"``)

**Returns:**

Expand All @@ -126,7 +126,7 @@ Convenience wrapper that returns only the scope and type (not the value):

source_scope, source_type = get_field_provenance(
container_type=PathPlanningConfig,
field_name="well_filter"
field_name="partition_filter"
)

print(f"Field comes from: {source_scope} ({source_type.__name__})")
Expand Down Expand Up @@ -165,7 +165,7 @@ Use provenance to understand where a value comes from:
state = ObjectStateRegistry.get_by_scope("plate_123::step_0")

# Check where various fields come from
for field_name in ["well_filter", "output_dir", "num_workers"]:
for field_name in ["partition_filter", "output_dir", "num_workers"]:
provenance = state.get_provenance(field_name)
if provenance:
source_scope, source_type = provenance
Expand All @@ -177,7 +177,7 @@ Example output:

.. code-block:: text

well_filter: inherited from (GlobalPipelineConfig)
partition_filter: inherited from (GlobalPipelineConfig)
output_dir: inherited from plate_123 (PlateConfig)
num_workers: explicitly set on this object

Expand Down Expand Up @@ -209,7 +209,7 @@ Trace the full inheritance chain for a field:
print(" → Value is a class default (no override)")

# Use it
trace_field_origin(PathPlanningConfig, "well_filter")
trace_field_origin(PathPlanningConfig, "partition_filter")
trace_field_origin(PathPlanningConfig, "output_dir_suffix")

UI Integration
Expand Down Expand Up @@ -242,8 +242,8 @@ Provenance information can be displayed in UI elements to help users understand
}

# Display in UI
info = get_field_display_info("well_filter")
print(f"well_filter: {info['value']} [{info['origin']}]")
info = get_field_display_info("partition_filter")
print(f"partition_filter: {info['value']} [{info['origin']}]")

Provenance in Snapshots
-------------------------
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ keywords = ["configuration", "dataclass", "lazy", "inheritance", "contextvars",

dependencies = [
"dill>=0.4.0",
"pycodify>=0.2.0,<0.3",
"python-introspect>=0.2.0,<0.3",
]

Expand Down
2 changes: 1 addition & 1 deletion src/objectstate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@
'ObjectStateEditSession',
]

__version__ = "2.0.0"
__version__ = "2.1.0"
__author__ = 'OpenHCS Team'
__description__ = 'Generic configuration framework for lazy dataclass resolution'

Expand Down
Loading
Loading