diff --git a/README.md b/README.md index ebaca33..5f09d36 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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. diff --git a/docs/architectural_fixes.rst b/docs/architectural_fixes.rst index f74ac00..6817ab4 100644 --- a/docs/architectural_fixes.rst +++ b/docs/architectural_fixes.rst @@ -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: @@ -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 diff --git a/docs/architecture.rst b/docs/architecture.rst index 7db7244..a70ef0d 100644 --- a/docs/architecture.rst +++ b/docs/architecture.rst @@ -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:** @@ -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: @@ -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" diff --git a/docs/examples/dual_axis.rst b/docs/examples/dual_axis.rst index 5f631fa..e9a0797 100644 --- a/docs/examples/dual_axis.rst +++ b/docs/examples/dual_axis.rst @@ -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 @@ -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" @@ -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) @@ -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 ) @@ -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 @@ -275,7 +275,7 @@ 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. @@ -283,8 +283,8 @@ You can disable this behavior if you want inherited fields to keep their default 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) diff --git a/docs/provenance.rst b/docs/provenance.rst index 4fe916e..193011a 100644 --- a/docs/provenance.rst +++ b/docs/provenance.rst @@ -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 @@ -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}") @@ -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:** @@ -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__})") @@ -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 @@ -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 @@ -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 @@ -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 ------------------------- diff --git a/pyproject.toml b/pyproject.toml index 7943496..88df81c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/src/objectstate/__init__.py b/src/objectstate/__init__.py index cdb5b35..09e0838 100644 --- a/src/objectstate/__init__.py +++ b/src/objectstate/__init__.py @@ -212,7 +212,7 @@ 'ObjectStateEditSession', ] -__version__ = "2.0.0" +__version__ = "2.1.0" __author__ = 'OpenHCS Team' __description__ = 'Generic configuration framework for lazy dataclass resolution' diff --git a/src/objectstate/codegen.py b/src/objectstate/codegen.py new file mode 100644 index 0000000..453ee8d --- /dev/null +++ b/src/objectstate/codegen.py @@ -0,0 +1,137 @@ +"""Python source for lazily resolved dataclasses, through pycodify. + +Importing this module registers two formatters. Both write raw field values, +never resolved ones: a ``None`` field inherits from context and stays ``None`` +in the source. + +- :class:`LazyResolutionFormatter` (any :class:`LazyResolutionDataclass`): + clean mode writes the fields whose raw value differs from the field default. +- :class:`LazyDataclassFormatter` (factory-made :class:`LazyDataclass`): clean + mode writes a field when it was passed to the constructor or holds a concrete + value; an inherited ``None`` is omitted, and so is a nested lazy dataclass + that carries nothing. + +Full mode writes every field. +""" + +from __future__ import annotations + +from dataclasses import MISSING, Field, fields, is_dataclass + +from pycodify import ( + FormatContext, + SourceFormatter, + SourceFragment, + imported_name, + to_source, +) + +from objectstate.field_access import DataclassFieldAccess +from objectstate.lazy_factory import LazyDataclass, LazyResolutionDataclass + + +def _set_field_names(value: LazyDataclass) -> frozenset[str]: + return frozenset(object.__getattribute__(value, "_explicitly_set_fields")) + + +def lazy_dataclass_carries_values(value: LazyDataclass) -> bool: + """Whether a lazy dataclass has a constructor-set field or a concrete value.""" + + return bool(_set_field_names(value)) or any( + DataclassFieldAccess.raw_value(value, field.name) is not None + for field in fields(value) + ) + + +def _is_instance_of_dataclass(value: object) -> bool: + return is_dataclass(value) and not isinstance(value, type) + + +def raw_values_equal(left: object, right: object) -> bool: + """Whether two values are equal, comparing dataclasses by raw field values.""" + + if _is_instance_of_dataclass(left) and _is_instance_of_dataclass(right): + if type(left) is not type(right): + return False + return all( + raw_values_equal( + DataclassFieldAccess.raw_value(left, field.name), + DataclassFieldAccess.raw_value(right, field.name), + ) + for field in fields(left) + if field.init + ) + return left == right + + +def field_needs_source(current: object, default: object, context: FormatContext) -> bool: + """Whether an owner's field holding ``current`` must be written. + + For owners (such as a step) that format their own constructor fields and + hold lazy dataclasses among them. Full mode writes every field; clean mode + writes a lazy dataclass that carries values different from the default, + and any other value that differs from the default. + """ + + if not context.clean_mode: + return True + if isinstance(current, LazyDataclass): + if not lazy_dataclass_carries_values(current): + return False + return not raw_values_equal(current, default) + return current != default + + +def _field_default(field: Field) -> object: + if field.default is not MISSING: + return field.default + if field.default_factory is not MISSING: + return field.default_factory() + return MISSING + + +class LazyResolutionFormatter(SourceFormatter): + priority = 36 + formats: type = LazyResolutionDataclass + + def can_format(self, value: object) -> bool: + return isinstance(value, self.formats) + + def clean_field_needs_source(self, value: object, field: Field, current: object) -> bool: + return not raw_values_equal(current, _field_default(field)) + + def format(self, value: object, context: FormatContext) -> SourceFragment: + lazy_type = type(value) + class_ref = imported_name(lazy_type.__module__, lazy_type.__qualname__, context) + imports = set(class_ref.imports) + field_ctx = context.indented() + lines = [] + for field in fields(value): + if not field.init: + continue + current = DataclassFieldAccess.raw_value(value, field.name) + if context.clean_mode and not self.clean_field_needs_source(value, field, current): + continue + fragment = to_source(current, field_ctx) + imports |= fragment.imports + lines.append(f"{field.name}={fragment.code}") + + if not lines: + return SourceFragment(f"{class_ref.code}()", frozenset(imports)) + inner = f",\n{field_ctx.indent_str}".join(lines) + code = f"{class_ref.code}(\n{field_ctx.indent_str}{inner}\n{context.indent_str})" + return SourceFragment(code, frozenset(imports)) + + +class LazyDataclassFormatter(LazyResolutionFormatter): + priority = 37 + formats = LazyDataclass + + def clean_field_needs_source(self, value: object, field: Field, current: object) -> bool: + if current is None: + return False + if field.name in _set_field_names(value): + return True + if isinstance(current, LazyDataclass): + return lazy_dataclass_carries_values(current) + return True diff --git a/src/objectstate/context_manager.py b/src/objectstate/context_manager.py index 575c686..cf023f8 100644 --- a/src/objectstate/context_manager.py +++ b/src/objectstate/context_manager.py @@ -187,8 +187,9 @@ def config_context(obj, mask_with_none: bool = False, use_live_global: bool = Tr # to_base_config() passes None values to base class constructor, # but frozen dataclasses with non-Optional defaults substitute the # default value for None. This breaks cross-hierarchy inheritance. - # Example: LazyWellFilterConfig(well_filter_mode=None) becomes - # WellFilterConfig(well_filter_mode=INCLUDE) instead of keeping None. + # Example: LazyPartitionFilterConfig(partition_filter_mode=None) + # becomes PartitionFilterConfig(partition_filter_mode=INCLUDE) + # instead of keeping None. # # Instead, pass lazy dataclass directly to _merge_nested_dataclass, # which uses object.__getattribute__ to get raw values and correctly @@ -730,12 +731,12 @@ def _find_live_values_for_type(target_type: type, live_context: dict) -> dict | Find live values for a target type in live_context. Handles type normalization (lazy vs base types) AND inheritance. - For sibling inheritance, a StepWellFilterConfig's values should be - usable when resolving WellFilterConfig's placeholders. + For sibling inheritance, a StepPartitionFilterConfig's values should be + usable when resolving PartitionFilterConfig's placeholders. IMPORTANT: Prefers subclass matches over exact matches. - This ensures StepWellFilterConfig values (with concrete value) are used - for WellFilterConfig resolution, not WellFilterConfig values (with None). + This ensures StepPartitionFilterConfig values (with concrete value) are used + for PartitionFilterConfig resolution, not PartitionFilterConfig values (with None). Args: target_type: The type to find values for @@ -755,7 +756,7 @@ def _find_live_values_for_type(target_type: type, live_context: dict) -> dict | return config_values # First pass: look for subclass match (more specific wins) after normalization - # e.g., StepWellFilterConfig values for WellFilterConfig resolution + # e.g., StepPartitionFilterConfig values for PartitionFilterConfig resolution for config_type, config_values in live_context.items(): config_base = _normalize_type(config_type) try: @@ -899,12 +900,6 @@ def get_base_global_config(use_live: bool = True): # Get the appropriate global config (live or saved) current_global = get_current_global_config(base_config_type, use_live=use_live) if current_global is not None: - # DEBUG: Log well_filter value - try: - wf_value = object.__getattribute__(current_global.well_filter_config, 'well_filter') - logger.debug(f"🔍 get_base_global_config: use_live={use_live}, well_filter={wf_value}") - except: - pass return current_global # Fallback to default if none was set diff --git a/src/objectstate/docs/configuration_framework.rst b/src/objectstate/docs/configuration_framework.rst index db4a248..8c8cae8 100644 --- a/src/objectstate/docs/configuration_framework.rst +++ b/src/objectstate/docs/configuration_framework.rst @@ -63,7 +63,7 @@ Nested configurations use the ``@global_config`` decorator to inject fields into @global_config @dataclass(frozen=True) - class PathPlanningConfig(WellFilterConfig): + class PathPlanningConfig(PartitionFilterConfig): output_dir_suffix: str = "" sub_dir: str = "" diff --git a/src/objectstate/dual_axis_resolver.py b/src/objectstate/dual_axis_resolver.py index ff0d0c6..69cec7d 100644 --- a/src/objectstate/dual_axis_resolver.py +++ b/src/objectstate/dual_axis_resolver.py @@ -63,8 +63,8 @@ def invalidate_mro_cache_for_field(changed_type: type, field_name: str) -> None: def _normalize_to_base(t: type) -> type: """Normalize lazy type to its base type for comparison. - LazyWellFilterConfig -> WellFilterConfig - WellFilterConfig -> WellFilterConfig + LazyPartitionFilterConfig -> PartitionFilterConfig + PartitionFilterConfig -> PartitionFilterConfig PERFORMANCE: Results are cached with lru_cache since type->base_type mappings are immutable after class creation. Import is hoisted to @@ -164,21 +164,14 @@ def resolve_field_inheritance( if _cached is not _CACHE_SENTINEL: return _cached - _debug = logger.isEnabledFor(logging.DEBUG) - if needs_resolution: for config_type, config_instance in available_configs.items(): - # Normalize both sides: LazyWellFilterConfig matches WellFilterConfig + # Normalize both sides: LazyPartitionFilterConfig matches PartitionFilterConfig instance_base = _normalize_to_base(type(config_instance)) if instance_base == obj_base: try: field_value = object.__getattribute__(config_instance, field_name) if field_value is not None: - if _debug: - if field_name == 'well_filter': - logger.debug(f"🔍 CONCRETE VALUE: {obj_type.__name__}.{field_name} = {field_value}") - if field_name == 'num_workers': - logger.debug(f"🔍 SAME-TYPE MATCH: {obj_type.__name__}.{field_name} = {field_value!r} (type={type(field_value).__name__}) FROM config_type={config_type.__name__}") _mro_resolution_cache[_cache_key] = field_value return field_value except AttributeError: @@ -187,33 +180,21 @@ def resolve_field_inheritance( # Step 2: MRO-based inheritance - traverse MRO from most to least specific # Skip the first entry (self type) since we already checked it above (for lazy) or want to skip it (for concrete) # This finds PARENT class configs with concrete values (sibling inheritance) - if _debug and field_name in ('output_dir_suffix', 'sub_dir', 'well_filter', 'well_filter_mode'): - logger.debug(f"🔍 MRO-INHERITANCE: Resolving {obj_type.__name__}.{field_name}") - logger.debug(f"🔍 MRO-INHERITANCE: MRO = {[cls.__name__ for cls in obj_type.__mro__]}") for mro_class in obj_type.__mro__[1:]: # Skip first (self type) if not is_dataclass(mro_class): continue # Look for a config instance of this MRO class type in the available configs - # Normalize both sides: LazyWellFilterConfig instance matches WellFilterConfig in MRO + # Normalize both sides: a LazyPartitionFilterConfig instance matches + # PartitionFilterConfig in the MRO mro_base = _normalize_to_base(mro_class) for config_instance in available_configs.values(): instance_base = _normalize_to_base(type(config_instance)) if instance_base == mro_base: try: value = object.__getattribute__(config_instance, field_name) - if _debug: - if field_name in ('output_dir_suffix', 'sub_dir', 'well_filter', 'well_filter_mode'): - logger.debug(f"🔍 MRO-INHERITANCE: {mro_class.__name__}.{field_name} = {value}") - if field_name == 'num_workers': - logger.debug(f"🔍 MRO-INHERITANCE: {mro_class.__name__}.{field_name} = {value!r} (type={type(value).__name__})") if value is not None: - if _debug: - if field_name in ('output_dir_suffix', 'sub_dir', 'well_filter', 'well_filter_mode'): - logger.debug(f"🔍 MRO-INHERITANCE: FOUND {mro_class.__name__}.{field_name}: {value} (returning)") - if field_name == 'num_workers': - logger.debug(f"🔍 MRO-INHERITANCE: RETURNING {mro_class.__name__}.{field_name} = {value!r}") _mro_resolution_cache[_cache_key] = value return value except AttributeError: @@ -227,8 +208,6 @@ def resolve_field_inheritance( # If MRO walk finds nothing and the field is not directly owned by this # config class, return None so inherited fields continue to resolve through # their owning MRO/default config. - if _debug and field_name in ('output_dir_suffix', 'sub_dir', 'well_filter'): - logger.debug(f"🔍 NO-RESOLUTION: {obj_type.__name__}.{field_name} = None") _mro_resolution_cache[_cache_key] = None return None @@ -249,7 +228,7 @@ def resolve_with_provenance(container_type: type, field_name: str) -> Tuple[Any, This allows sibling inheritance when no concrete value exists in the hierarchy. This ensures: - - GlobalPipelineConfig.well_filter_config.well_filter overrides the same field + - GlobalPipelineConfig.partition_filter_config.partition_filter overrides the same field at PipelineConfig level (hierarchy precedence for same-type) - MRO inheritance only applies when NO concrete value exists in the hierarchy for the specific config type being resolved @@ -261,12 +240,12 @@ def resolve_with_provenance(container_type: type, field_name: str) -> Tuple[Any, Args: 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: (resolved_value, source_scope_id, source_type): The resolved value, scope that provided it, and the TYPE that has the concrete value (may differ from container_type - due to MRO inheritance, e.g., PathPlanningConfig instead of WellFilterConfig). + due to MRO inheritance, e.g., PathPlanningConfig instead of PartitionFilterConfig). If no concrete value found, returns (None, None, None). """ from objectstate.context_manager import get_context_layer_stack, extract_all_configs @@ -291,17 +270,11 @@ def resolve_with_provenance(container_type: type, field_name: str) -> Tuple[Any, # Phase 2: MRO fallback (only if no concrete value in hierarchy) # # This ensures: - # - A concrete value at GlobalPipelineConfig.well_filter_config.well_filter overrides + # - A concrete value at GlobalPipelineConfig.partition_filter_config.partition_filter overrides # the same field at PipelineConfig level (hierarchy precedence) # - MRO inheritance only applies when NO concrete value exists in the hierarchy # for the specific config type being resolved - _debug = logger.isEnabledFor(logging.DEBUG) - - if _debug and field_name == 'well_filter': - logger.debug(f"🔍 resolve_with_provenance: container={container_base.__name__}, field={field_name}, layers={len(layers)}") - logger.debug(f"🔍 resolve_with_provenance: mro_types={[t.__name__ for t in mro_types]}") - # Track fallback provenance - where we find the field (even if None) fallback_scope: Optional[str] = None fallback_type: Optional[type] = None @@ -321,8 +294,6 @@ def resolve_with_provenance(container_type: type, field_name: str) -> Tuple[Any, # This gives hierarchy precedence: inner/child scopes override outer/parent scopes # REVERSED ORDER: Walk from inner to outer so more specific scopes override general scopes for scope_id, layer_configs in reversed(all_layer_configs): - if _debug and field_name == 'well_filter': - logger.debug(f"🔍 Phase 1 - Layer scope={scope_id!r}, checking same-type only (inner to outer)") # extract_all_configs admits one instance per canonical owner type. config_instance = layer_configs.get(container_base) @@ -330,12 +301,8 @@ def resolve_with_provenance(container_type: type, field_name: str) -> Tuple[Any, continue try: value = object.__getattribute__(config_instance, field_name) - if _debug and field_name in ('well_filter', 'enabled'): - logger.debug(f"🔍 {container_base.__name__}.{field_name} @ scope={scope_id} = {value!r} (from object.__getattribute__)") if value is not None: # Found concrete value in hierarchy - return immediately - if _debug and field_name in ('well_filter', 'enabled'): - logger.debug(f"🔍 FOUND concrete value in hierarchy at scope={scope_id!r}, returning {value!r}") return value, scope_id, container_base # Don't set fallback here - let Phase 2 walk MRO to find # the highest type that defines this field @@ -353,8 +320,6 @@ def resolve_with_provenance(container_type: type, field_name: str) -> Tuple[Any, # # Therefore: for each MRO type (most→least specific), scan scopes (inner→outer) # looking for the first non-None value. - if _debug and field_name == 'well_filter': - logger.debug("🔍 Phase 2 - MRO fallback, walking layers inner to outer") # Track where the "highest-precedence" field exists even if None, so callers # can still navigate to a sensible provenance target when everything is None. @@ -378,8 +343,6 @@ def resolve_with_provenance(container_type: type, field_name: str) -> Tuple[Any, except AttributeError: continue - if _debug and field_name == 'well_filter': - logger.debug(f"🔍 MRO: {mro_type.__name__}.{field_name} @ {scope_id!r} = {value!r}") if value is not None: # Found MRO-inherited value @@ -409,7 +372,7 @@ def get_field_provenance(container_type: type, field_name: str) -> Tuple[Optiona Args: 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: (source_scope_id, source_type): The scope_id and type that provided the value. diff --git a/src/objectstate/lazy_factory.py b/src/objectstate/lazy_factory.py index a1ed3ec..8923b35 100644 --- a/src/objectstate/lazy_factory.py +++ b/src/objectstate/lazy_factory.py @@ -423,7 +423,7 @@ class LazyDataclass(LazyResolutionDataclass): All lazy dataclasses inherit from this, regardless of naming convention: - PipelineConfig (lazy version of GlobalPipelineConfig) - LazyPathPlanningConfig - - LazyWellFilterConfig + - LazyPartitionFilterConfig - etc. ANTI-DUCK-TYPING: Use isinstance(obj, LazyDataclass) instead of hasattr() checks. @@ -585,7 +585,7 @@ def bind_lazy_resolution_to_class(cls: Type) -> None: """ Add lazy __getattribute__ to an existing class. - This enables concrete classes (like WellFilterConfig stored in + This enables concrete classes (like PartitionFilterConfig stored in GlobalPipelineConfig) to resolve None values via MRO without changing their static defaults. @@ -1382,15 +1382,17 @@ class abbreviation: Can be used as: 1. Class decorator: @abbreviation('wfc') to set class abbreviation - 2. In Annotated types: Annotated[Type, abbreviation('wf')] for field abbreviation + 2. In Annotated types: Annotated[Type, abbreviation('pf')] for field abbreviation Examples: @abbreviation('wfc') @global_pipeline_config @dataclass - class WellFilterConfig: - well_filter: Annotated[Optional[int], abbreviation('wf')] = None - well_filter_mode: Annotated[WellFilterMode, abbreviation('wfm')] = WellFilterMode.INCLUDE + class PartitionFilterConfig: + partition_filter: Annotated[Optional[int], abbreviation('pf')] = None + partition_filter_mode: Annotated[ + type[PartitionFilterMode], abbreviation('pfm') + ] = IncludePartitions Args: name: The abbreviation string @@ -1539,7 +1541,8 @@ def global_default_decorator(cls=None, *, optional: bool = False, inherit_as_non abbreviation: Short abbreviation for config class name used in grouped previews (e.g., "pp" for PathPlanningConfig). Registered in GROUP_ABBREVIATIONS_REGISTRY. field_abbreviations: Dict mapping field names to abbreviations for compact display. - E.g., {'well_filter': 'wf', 'num_workers': 'W'}. Registered in FIELD_ABBREVIATIONS_REGISTRY. + E.g., {'partition_filter': 'pf', 'num_workers': 'W'}. Registered in + FIELD_ABBREVIATIONS_REGISTRY. always_viewable_fields: List of field names that should always be shown in list item previews for this config type. E.g., ['enabled', 'persistent']. Registered in ALWAYS_VIEWABLE_FIELDS_REGISTRY. """ diff --git a/src/objectstate/live_context_resolver.py b/src/objectstate/live_context_resolver.py index 0ae9986..b6e0e61 100644 --- a/src/objectstate/live_context_resolver.py +++ b/src/objectstate/live_context_resolver.py @@ -132,8 +132,9 @@ def _merge_live_values(self, base_obj: object, live_values: Optional[Dict[str, A base object. This allows the lazy resolution system to walk up the MRO to find the inherited value from parent context. - Example: PipelineConfig form resets well_filter_config.enabled to None - → dataclasses.replace(saved_pipeline_config, well_filter_config=LazyWellFilterConfig(enabled=None)) + Example: PipelineConfig form resets partition_filter_config.enabled to None + → dataclasses.replace(saved_pipeline_config, + partition_filter_config=LazyPartitionFilterConfig(enabled=None)) → When resolving enabled, the None triggers MRO walk to GlobalPipelineConfig """ if live_values is None or not is_dataclass(base_obj): diff --git a/src/objectstate/object_state.py b/src/objectstate/object_state.py index b462a4a..0a0402e 100644 --- a/src/objectstate/object_state.py +++ b/src/objectstate/object_state.py @@ -50,8 +50,8 @@ class FieldProxy: """Type-safe proxy for accessing ObjectState fields via dotted attribute syntax. Provides IDE autocomplete while using flat internal storage: - - External API: state.fields.well_filter_config.well_filter (type-safe) - - Internal: state.parameters['well_filter_config.well_filter'] (flat dict) + - External API: state.fields.partition_filter_config.partition_filter (type-safe) + - Internal: state.parameters['partition_filter_config.partition_filter'] (flat dict) """ def __init__(self, state: 'ObjectState', path: str, field_type: type): @@ -460,7 +460,7 @@ def fields(self) -> FieldProxy: Returns: FieldProxy for accessing fields with IDE autocomplete: - state.fields.well_filter_config.well_filter → resolved value + state.fields.partition_filter_config.partition_filter → resolved value """ return FieldProxy(self, '', type(self.object_instance)) @@ -528,7 +528,7 @@ def parameter_descriptions(self) -> dict[str, str | None]: Returns: Dictionary mapping dotted parameter paths to their descriptions (value may be None). - E.g., {'well_filter_config.well_filter': 'Filter wells by...'} + E.g., {'partition_filter_config.partition_filter': 'Filter partitions by...'} """ return self._parameter_structure.descriptions @@ -576,7 +576,8 @@ def on_resolved_changed(self, callback: Callable[[set[str]], None]) -> None: Args: callback: Function that takes a Set[str] of changed dotted paths. - E.g., {'processing_config.group_by', 'well_filter_config.well_filter'} + E.g., {'processing_config.group_by', + 'partition_filter_config.partition_filter'} """ if callback not in self._on_resolved_changed_callbacks: self._on_resolved_changed_callbacks.append(callback) @@ -1020,7 +1021,7 @@ def update_parameter(self, param_name: str, value: Any) -> set[str]: if current_live is not None: # Do a quick partial update to set the new value in LIVE thread-local if '.' in param_name: - # Nested field like 'well_filter_config.well_filter' + # Nested field like 'partition_filter_config.partition_filter' parts = param_name.split('.') nested_config_name = parts[0] nested_field_name = '.'.join(parts[1:]) @@ -1045,14 +1046,6 @@ def update_parameter(self, param_name: str, value: Any) -> set[str]: # Clear cached context so resolution uses updated LIVE thread-local clear_current_temp_global() - # DEBUG: Log well_filter value - if 'well_filter' in param_name: - verify_live = get_live_global_config(obj_type) - try: - wf_value = object.__getattribute__(verify_live.well_filter_config, 'well_filter') - logger.debug(f"🔍 LIVE thread-local updated BEFORE invalidation: {obj_type.__name__}.{param_name} = {value}, well_filter={wf_value}") - except: - pass except Exception as e: logger.warning(f"Failed to update LIVE thread-local: {e}") @@ -1061,17 +1054,16 @@ def update_parameter(self, param_name: str, value: Any) -> set[str]: changed_paths = self._ensure_live_resolved(notify_flash=False) for changed_param in changed_semantic_params: # SCOPE + TYPE + FIELD AWARE INVALIDATION: - # Get the CONTAINER type for this field (e.g., WellFilterConfig for 'well_filter_config.well_filter') - # This is critical for sibling inheritance: when WellFilterConfig.well_filter changes, - # we need to invalidate PathPlanningConfig.well_filter (which inherits from WellFilterConfig) + # Get the CONTAINER type for this field (e.g., PartitionFilterConfig for + # 'partition_filter_config.partition_filter') + # This is critical for sibling inheritance: when + # PartitionFilterConfig.partition_filter changes, we need to invalidate + # PathPlanningConfig.partition_filter (which inherits from PartitionFilterConfig) container_type = self._path_to_type.get(changed_param, type(self.object_instance)) # Extract leaf field name for invalidation matching leaf_field_name = changed_param.split('.')[-1] if '.' in changed_param else changed_param - # DEBUG: Log invalidation for well_filter - if 'well_filter' in changed_param: - logger.debug(f"🔍 Invalidating descendants: scope={self.scope_id}, type={container_type.__name__}, field={leaf_field_name}") ObjectStateRegistry.invalidate_by_type_and_scope( scope_id=self.scope_id, @@ -1114,7 +1106,8 @@ def get_resolved_value(self, param_name: str) -> Any: """Get resolved value for a field from the bulk snapshot. Args: - param_name: Field name to resolve (can be dotted path like 'path_planning_config.well_filter') + param_name: Field name to resolve (can be dotted path like + 'path_planning_config.partition_filter') Returns: Resolved value from _live_resolved snapshot. @@ -1140,9 +1133,6 @@ def get_resolved_value(self, param_name: str) -> Any: result = self._live_resolved.get(param_name) - # DEBUG: Log well_filter resolution - if 'well_filter' in param_name: - logger.debug(f"🔍 get_resolved_value: scope={self.scope_id!r}, obj_type={type(self.object_instance).__name__}, param={param_name}, value={result}") return result @@ -1157,7 +1147,8 @@ def get_saved_resolved_value(self, param_name: str) -> Any: dataclass with all sub-fields populated from saved resolved values. Args: - param_name: Field name to resolve (can be dotted path like 'path_planning_config.well_filter') + param_name: Field name to resolve (can be dotted path like + 'path_planning_config.partition_filter') Returns: Saved resolved value from _saved_resolved snapshot. @@ -1296,13 +1287,13 @@ def get_provenance(self, param_name: str) -> tuple[str, type] | None: Used for click-to-source navigation in the UI. The source_type may differ from the local container type due to MRO inheritance. - For example, WellFilterConfig.well_filter might inherit from PathPlanningConfig. + For example, PartitionFilterConfig.partition_filter might inherit from PathPlanningConfig. NOTE: Returns provenance even when the resolved value is None (signature default). A "concrete None" just means the class default is None and nothing overrode it. Args: - param_name: Field name (can be dotted path like 'path_planning_config.well_filter') + param_name: Field name (can be dotted path like 'path_planning_config.partition_filter') Returns: (source_scope_id, source_type): The scope and type that provided the value, @@ -1412,7 +1403,7 @@ def resolve_for_type(self, container_type: type, field_name: str) -> Any: Args: container_type: Type of the containing config (e.g., PathPlanningConfig) - field_name: Field name within that config (e.g., 'well_filter') + field_name: Field name within that config (e.g., 'partition_filter') Returns: Resolved value, or None if not found @@ -2507,7 +2498,7 @@ def _extract_all_parameters_flat( Args: obj: Object to extract from (dataclass instance, callable, or regular object) - prefix: Current path prefix (e.g., 'well_filter_config') + prefix: Current path prefix (e.g., 'partition_filter_config') exclude_params: List of top-level parameter names to exclude """ exclude_params = exclude_params or [] @@ -2669,8 +2660,8 @@ def to_object(self, *, update_delegate: bool = False, sync_delegate: bool = True if '.' not in field_name: # Check if this field's TYPE is a dataclass (not the instance value) # We need to check the TYPE because the instance value might be stale - # (e.g., self.parameters['well_filter_config'] might have well_filter=2 - # even though self.parameters['well_filter_config.well_filter'] = None) + # (e.g., self.parameters['partition_filter_config'] might have partition_filter=2 + # even though self.parameters['partition_filter_config.partition_filter'] = None) field_type = self._path_to_type.get(field_name) # CRITICAL FIX: _path_to_type stores CONTAINER type for leaf fields, # but FIELD type for nested dataclass fields. We must distinguish: @@ -2683,7 +2674,8 @@ def to_object(self, *, update_delegate: bool = False, sync_delegate: bool = True ) if is_nested_dataclass: # Nested dataclass: ALWAYS recursively reconstruct from flat storage - # This ensures we pick up changes to nested fields like 'well_filter_config.well_filter' + # This ensures we pick up changes to nested fields like + # 'partition_filter_config.partition_filter' logger.debug(f"🔧 to_object: Reconstructing nested dataclass '{field_name}' from flat storage") reconstructed = self._reconstruct_from_prefix(field_name) logger.debug(f"🔧 to_object: Reconstructed '{field_name}' type={type(reconstructed).__name__}") @@ -2752,7 +2744,7 @@ def _reconstruct_from_prefix(self, prefix: str) -> Any: """Recursively reconstruct dataclass from flat parameters. Args: - prefix: Current path prefix (e.g., 'well_filter_config') + prefix: Current path prefix (e.g., 'partition_filter_config') Returns: Reconstructed dataclass instance @@ -2794,9 +2786,6 @@ def _reconstruct_from_parameter_snapshot( else: # Direct field of this object direct_fields[remainder] = value - # DEBUG - if prefix == 'well_filter_config' and remainder == 'well_filter': - logger.debug(f"🔍 _reconstruct: Found direct field {prefix}.{remainder} = {value}") # Reconstruct nested dataclasses first for nested_name in nested_prefixes: @@ -2818,17 +2807,10 @@ def _reconstruct_from_parameter_snapshot( if not prefix: direct_fields.update(self._excluded_params) - # DEBUG: Log what we're reconstructing - if prefix == 'well_filter_config': - logger.debug(f"🔍 _reconstruct_from_prefix: prefix={prefix}, direct_fields={direct_fields}") # Instantiate the dataclass with ALL fields including None values result = obj_type(**direct_fields) - # DEBUG: Log the result - if prefix == 'well_filter_config': - raw_well_filter = object.__getattribute__(result, 'well_filter') - logger.debug(f"🔍 _reconstruct_from_prefix: Reconstructed {prefix} with well_filter={raw_well_filter}") return result diff --git a/src/objectstate/object_state_registry.py b/src/objectstate/object_state_registry.py index 6171311..3c765e0 100644 --- a/src/objectstate/object_state_registry.py +++ b/src/objectstate/object_state_registry.py @@ -780,9 +780,6 @@ def _invalidate_by_type_and_scope_now( else: base_changed_type = changed_type - # DEBUG: Log invalidation for well_filter - if field_name == 'well_filter': - logger.debug(f"🔍 invalidate_by_type_and_scope: scope={changed_scope!r}, type={base_changed_type.__name__}, field={field_name}, total_states={len(cls._states)}") for state in cls._states.values(): state_scope = cls._normalize_scope_id(state.scope_id) @@ -814,9 +811,10 @@ def _invalidate_field_in_matching_states( 1. Its container type matches target_base_type exactly, OR 2. Its container type inherits from target_base_type (has target_base_type in MRO) - This handles sibling inheritance: when WellFilterConfig.well_filter changes, - both 'well_filter_config.well_filter' and 'step_well_filter_config.well_filter' - are invalidated (since StepWellFilterConfig inherits from WellFilterConfig). + This handles sibling inheritance: when PartitionFilterConfig.partition_filter changes, + both 'partition_filter_config.partition_filter' and + 'step_partition_filter_config.partition_filter' + are invalidated (since StepPartitionFilterConfig inherits from PartitionFilterConfig). Args: state: ObjectState to check diff --git a/tests/test_codegen.py b/tests/test_codegen.py new file mode 100644 index 0000000..0c9bcff --- /dev/null +++ b/tests/test_codegen.py @@ -0,0 +1,113 @@ +"""Lazy dataclasses written as Python source keep their raw values.""" + +from dataclasses import dataclass, field +from enum import Enum + +import pytest +from pycodify import Assignment, FormatContext, generate_python_source + +from objectstate import LazyDataclassFactory +from objectstate.codegen import field_needs_source +from objectstate.field_access import DataclassFieldAccess +from objectstate.lazy_factory import LazyResolutionDataclass + + +class Speed: + class Mode(Enum): + FAST = "fast" + SLOW = "slow" + + +@dataclass +class Inner: + depth: int = 1 + mode: Speed.Mode = Speed.Mode.FAST + + +@dataclass +class Outer: + name: str = "a" + inner: Inner = field(default_factory=Inner) + + +LazyInner = LazyDataclassFactory.make_lazy_simple(Inner) +LazyOuter = LazyDataclassFactory.make_lazy_simple(Outer) + + +def _load(value, clean_mode): + source = generate_python_source(Assignment("value", value), clean_mode=clean_mode) + namespace = {} + exec(source, namespace) + return source, namespace["value"] + + +def _raw(value): + return { + name: _raw(raw) if isinstance(raw, LazyInner) else raw + for name in ("name", "inner", "depth", "mode") + if DataclassFieldAccess.has_field(value, name) + for raw in (DataclassFieldAccess.raw_value(value, name),) + } + + +VALUES = ( + LazyOuter(), + LazyOuter(name="b"), + LazyOuter(inner=LazyInner()), + LazyOuter(inner=LazyInner(depth=3, mode=Speed.Mode.SLOW)), + LazyOuter(name=None, inner=LazyInner(depth=None)), +) + + +@pytest.mark.parametrize("clean_mode", (True, False)) +@pytest.mark.parametrize("value", VALUES, ids=repr) +def test_source_loads_back_with_equal_raw_values(value, clean_mode): + _, loaded = _load(value, clean_mode) + assert type(loaded) is type(value) + assert _raw(loaded) == _raw(value) + + +def test_clean_source_omits_inherited_fields_and_empty_nested_configs(): + source, _ = _load(LazyOuter(name="b", inner=LazyInner(depth=None)), clean_mode=True) + assert "name='b'" in source + assert "inner=LazyInner()" in source + assert "depth" not in source + + source, _ = _load(LazyOuter(name="b"), clean_mode=True) + assert "inner" not in source + + +def test_full_source_writes_every_raw_field(): + source, _ = _load(LazyInner(mode=Speed.Mode.SLOW), clean_mode=False) + assert "depth=None" in source + assert "mode=Speed.Mode.SLOW" in source + + +def test_field_needs_source_compares_lazy_raw_values(): + clean = FormatContext(clean_mode=True) + assert not field_needs_source(LazyInner(), LazyInner(), clean) + assert not field_needs_source(LazyInner(depth=2), LazyInner(depth=2), clean) + assert field_needs_source(LazyInner(depth=2), LazyInner(), clean) + assert field_needs_source(3, 1, clean) + assert not field_needs_source(1, 1, clean) + assert field_needs_source(LazyInner(), LazyInner(), FormatContext()) + + +@dataclass +class Inheriting(LazyResolutionDataclass): + """A base config whose None fields resolve elsewhere.""" + + mode: str | None = None + size: int = 4 + + def __getattribute__(self, name): + value = object.__getattribute__(self, name) + return "resolved" if name == "mode" and value is None else value + + +@pytest.mark.parametrize("clean_mode", (True, False)) +def test_inheriting_base_config_keeps_its_raw_none(clean_mode): + source, loaded = _load(Inheriting(size=5), clean_mode) + assert "resolved" not in source + assert DataclassFieldAccess.raw_value(loaded, "mode") is None + assert DataclassFieldAccess.raw_value(loaded, "size") == 5