Skip to content

Repository files navigation

objectstate

Generic lazy dataclass configuration framework with dual-axis inheritance

PyPI versionDocumentation StatusPython 3.11+License: MITCoverage

Features

  • Lazy Dataclass Factory: Dynamically create dataclasses with lazy field resolution
  • Dual-Axis Inheritance:
    • X-Axis: Context hierarchy (Step → Pipeline → Global)
    • Y-Axis: Sibling inheritance within same context
  • Contextvars-Based: Uses Python's contextvars for clean context management
  • UI Integration: Placeholder text generation for configuration forms
  • Thread-Safe: Thread-local global configuration storage
  • 100% Generic: No application-specific dependencies
  • Small dependency surface: Uses python-introspect for callable and dataclass analysis
  • Custom Dataclass Rebuild Hook: For init=False dataclasses during lazy serialization (__objectstate_rebuild__)

Quick Start

Simple Usage (Manual Factory)

fromdataclassesimportdataclassfromobjectstateimportLazyDataclassFactory, config_context, set_base_config_type# Define your base configuration@dataclassclassMyConfig:
output_dir: str="/tmp"num_workers: int=4debug: bool=False# Initialize the framework with your base config typeset_base_config_type(MyConfig)
# Create lazy version manuallyLazyMyConfig=LazyDataclassFactory.make_lazy_simple(MyConfig)
# Use with contextconcrete_config=MyConfig(output_dir="/data", num_workers=8)
withconfig_context(concrete_config):
lazy_cfg=LazyMyConfig()
print(lazy_cfg.output_dir) # "/data" (resolved from context)print(lazy_cfg.num_workers) # 8 (resolved from context)print(lazy_cfg.debug) # False (inherited from defaults)

Project a concrete config into its generated lazy type with from_config. This is the supported boundary when an application has already parsed or loaded concrete configuration:

concrete=MyConfig(output_dir="/data", num_workers=8)
lazy_cfg=LazyMyConfig.from_config(concrete)
# Keep only values that differ from an inherited concrete config.inherited=MyConfig(output_dir="/data", num_workers=4)
overrides=LazyMyConfig.from_config(concrete, inherited=inherited)

from_config accepts concrete dataclass instances. Passing another lazy instance is an error because it would make the projection depend on ambient resolution state.

Setting Up Global Config Context (For Advanced Usage)

When using the decorator pattern with auto_create_decorator, you need to establish the global configuration context for lazy resolution:

fromobjectstateimportensure_global_config_context# After creating your global config instanceglobal_config=GlobalPipelineConfig(
num_workers=8,
# ... other fields
)
# REQUIRED: Establish global config context for lazy resolutionensure_global_config_context(GlobalPipelineConfig, global_config)
# Now lazy configs can resolve from the global context

Key differences:

  • set_base_config_type(MyConfig): Sets the type (class) for the framework
  • ensure_global_config_context(GlobalConfig, instance): Sets the instance (concrete values) for resolution
  • Call ensure_global_config_context() at application startup (GUI) or before pipeline execution

Installation

pip install objectstate

ObjectState Registry

ObjectState separates mutable working state from saved baseline, enabling dirty tracking and undo/redo:

fromobjectstateimportObjectState, ObjectStateRegistry# Register an object (e.g., when added to pipeline)state=ObjectState(my_step_config, scope_id="/pipeline::step_0")
ObjectStateRegistry.register(state)
# Query the registrystate=ObjectStateRegistry.get_by_scope("/pipeline::step_0")
all_states=ObjectStateRegistry.get_all()
# Update a parameter (marks field as dirty)state.update_parameter("output_dir", "/new/path")
# Check dirty stateifstate.dirty_fields:
print(f"Unsaved changes: {state.dirty_fields}")
# Save changes (updates baseline)state.mark_saved()
# Or restore to saved baselinestate.restore_saved()
# Unregister when removedObjectStateRegistry.unregister(state)

Undo/Redo and Time Travel

Git-like DAG history with branching timelines:

fromobjectstateimportObjectStateRegistry# Time travel (automatically recorded on parameter changes)ObjectStateRegistry.time_travel_back() # Go one step back in historyObjectStateRegistry.time_travel_forward() # Go one step forward in history# Note: ObjectState uses time-travel semantics (like Git), not traditional undo/redo.# You can navigate to any point in history and make new changes (creating branches).# This is more powerful than undo/redo for complex workflows.# Batch multiple changes into one snapshotwithObjectStateRegistry.atomic("add item"):
ObjectStateRegistry.register(item_state)
parent_state.update_parameter("items", new_items)
# Time travel to specific pointhistory=ObjectStateRegistry.get_branch_history()
ObjectStateRegistry.time_travel_to_snapshot(history[5].id)
ObjectStateRegistry.time_travel_to_head() # Return to latest# Branching timelinesObjectStateRegistry.create_branch("experiment", description="Testing new approach")
ObjectStateRegistry.switch_branch("main")
branches=ObjectStateRegistry.list_branches()
# Persist historyhistory_dict=ObjectStateRegistry.export_history_to_dict()
ObjectStateRegistry.import_history_from_dict(history_dict)
# Or save to fileObjectStateRegistry.save_history_to_file("history.objectstate")
ObjectStateRegistry.load_history_from_file("history.objectstate")

Automatic Lazy Config Generation with Decorators

For more complex applications with multiple config types, use the auto_create_decorator pattern to automatically generate lazy versions and field injection decorators:

fromdataclassesimportdataclassfromobjectstateimportauto_create_decorator, config_context# Step 1: Create a global config class with "Global" prefix and apply auto_create_decorator@auto_create_decorator@dataclassclassGlobalPipelineConfig:
base_output_dir: str="/tmp"verbose: bool=False# This automatically creates:# - A decorator named `global_pipeline_config` (snake_case of class name)# that you can use to decorate other config classes# - A lazy class `PipelineConfig` (removes "Global" prefix) for lazy resolution# Step 2: Use the generated decorator on other config classes@global_pipeline_config# Automatically creates LazyStepConfig@dataclassclassStepConfig:
step_name: str="default_step"iterations: int=100@global_pipeline_config# Automatically creates LazyDatabaseConfig @dataclassclassDatabaseConfig:
host: str="localhost"port: int=5432# The decorator automatically:# - Creates lazy versions: LazyStepConfig, LazyDatabaseConfig# - Registers them for potential field injection into GlobalPipelineConfig# - Makes them available in your module namespace

Key Benefits:

  • Auto-generated lazy classes: Each decorated config automatically gets a lazy version
  • Simplified imports: Lazy classes are automatically added to your module
  • Decorator factory: auto_create_decorator generates a decorator specific to your global config
  • Type-safe: All generated classes are proper dataclasses with full IDE support

Field Injection Behavior

When you use the generated decorator (e.g., @global_pipeline_config), the decorated class is automatically injected as a field into the global config class:

fromdataclassesimportdataclassfromobjectstateimportauto_create_decorator# Create global config with auto_create_decorator@auto_create_decorator@dataclassclassGlobalPipelineConfig:
num_workers: int=1# This creates:# - A decorator named `global_pipeline_config`# - A lazy class named `PipelineConfig`# Use the decorator on a new config class@global_pipeline_config@dataclassclassWellFilterConfig:
well_filter: str=Nonemode: str="include"# After module loading, GlobalPipelineConfig automatically has:# - well_filter_config: WellFilterConfig = WellFilterConfig()# And LazyWellFilterConfig is auto-created

How it works:

  • Decorated classes are injected as fields into GlobalPipelineConfig
  • Field name is snake_case of class name (e.g., WellFilterConfigwell_filter_config)
  • Lazy version is automatically created (e.g., LazyWellFilterConfig)
  • 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.

Decorator Parameters

The generated decorator (e.g., @global_pipeline_config) supports optional parameters:

inherit_as_none (Default: True)

Sets all inherited fields from parent classes to None by default, enabling proper lazy resolution:

@dataclassclassBaseConfig:
timeout: int=30retries: int=3@global_pipeline_config(inherit_as_none=True) # Default behavior@dataclassclassServiceConfig(BaseConfig):
service_name: str="my-service"# timeout and retries automatically set to None for lazy inheritance# This allows ServiceConfig to inherit timeout/retries from context# rather than using the base class defaults

Why this matters:

  • Enables polymorphic access without type-specific attribute names
  • Critical for dual-axis inheritance with multiple inheritance
  • Uses InheritAsNoneMeta metaclass internally

ui_hidden (Default: False)

Hides configs from UI while still applying decorator behavior and keeping them in the resolution context:

@global_pipeline_config(ui_hidden=True)@dataclassclassInternalConfig:
internal_setting: str="hidden"# This config won't appear in UI but is still available for inheritance

Use cases:

  • Intermediate configs that are only inherited by other configs
  • Internal implementation details not meant for user configuration
  • Base classes that should never be directly instantiated in UI

Nested Dataclass Lazification

When creating a lazy dataclass, nested dataclass fields are automatically converted to their lazy versions:

fromdataclassesimportdataclassfromobjectstateimportLazyDataclassFactory@dataclassclassDatabaseConfig:
host: str="localhost"port: int=5432@dataclassclassAppConfig:
db_config: DatabaseConfig=DatabaseConfig()
app_name: str="MyApp"# Create lazy version - nested configs are automatically lazifiedLazyAppConfig=LazyDataclassFactory.make_lazy_simple(AppConfig)
# The db_config field is automatically converted to LazyDatabaseConfig# You don't need to manually create LazyDatabaseConfig first!

Benefits:

  • No need to manually create lazy versions of nested configs
  • Preserves field metadata (e.g., ui_hidden flag)
  • Creates default factories for Optional dataclass fields
  • Uses register_lazy_type_mapping() internally

Why objectstate?

Before (Manual parameter passing):

defprocess_step(data, output_dir, num_workers, debug, *more_options):
# Pass 20+ parameters through every functionresult=sub_process(data, output_dir, num_workers, debug, *more_options)
returnresultdefsub_process(data, output_dir, num_workers, debug, *more_options):
# Repeat parameter declarations everywhere
...

After (objectstate):

@dataclassclassStepConfig:
output_dir: str=Nonenum_workers: int=Nonedebug: bool=Nonedefprocess_step(data, config: LazyStepConfig):
# Config fields resolve automatically from contextprint(config.output_dir) # Resolved from context hierarchyresult=sub_process(data, config)
returnresult

Advanced Features

Dual-Axis Inheritance

# X-Axis: Context hierarchywithconfig_context(global_config):
withconfig_context(pipeline_config):
withconfig_context(step_config):
# Resolves: step → pipeline → global → defaultsvalue=objectstate.some_field# Y-Axis: Sibling inheritance (MRO-based)@dataclassclassBaseConfig:
field_a: str="base"@dataclassclassSpecializedConfig(BaseConfig):
field_b: str="specialized"# SpecializedConfig inherits field_a from BaseConfig

Accessing Resolved Values

fromobjectstateimportObjectState, ObjectStateRegistry# ObjectState stores both saved baseline and live (edited) valuesstate=ObjectStateRegistry.get_by_scope("/pipeline::step_0")
# Access resolved value (from _live_resolved cache)output_dir=state.get_resolved_value("output_dir")
# Check if field is dirty (live != saved)is_dirty="output_dir"instate.dirty_fields# Get provenance (where did this value come from?)source_scope, source_type=state.get_resolved_provenance("output_dir")
# Returns: ("/pipeline", GlobalPipelineConfig) if inherited from pipeline

Architecture

Dual-Axis Resolution

The framework uses pure MRO-based dual-axis resolution:

X-Axis (Context Hierarchy):

Step Context → Pipeline Context → Global Context → Static Defaults

Y-Axis (MRO Traversal):

Most specific class → Least specific class (following Python's MRO)

How it works:

  1. Context hierarchy is flattened into a single available_configs dict
  2. For each field resolution, traverse the requesting object's MRO from most to least specific
  3. For each MRO class, check if there's a config instance in available_configs with a concrete (non-None) value
  4. Return the first concrete value found

Parametric Axes Prototype (PEP Draft)

The parametric_axes module demonstrates extending Python's type system with arbitrary semantic axes beyond (B, S):

fromobjectstate.parametric_axesimportAxesBaseclassStep(AxesBase):
passclassMyStep(Step, axes={"scope": "/pipeline/step_0", "registry": "handlers"}):
passMyStep.__axes__# {'scope': '/pipeline/step_0', 'registry': 'handlers'}MyStep.__scope__# '/pipeline/step_0' (convenience attribute)

This works TODAY via __init_subclass__ (PEP 487) - no grammar changes required.

Three usage patterns:

  • AxesBase inheritance: class Foo(Base, axes={...}) (preferred)
  • Factory function: axes_type("Foo", (Base,), {}, scope="...", registry="...")
  • Decorator: @with_axes(scope="...", registry="...") (when base can't be modified)

Try It Out

Run the tests to see the prototype in action:

# Run all parametric axes tests
python -m pytest tests/test_parametric_axes.py -v
# Interactive exploration
python -c "from objectstate.parametric_axes import AxesBase, axes_type, with_axes, get_axes# Pattern 1: Class statement syntax (preferred)class Step(AxesBase): passclass ProcessingStep(Step, axes={'scope': '/pipeline/step_0', 'registry': 'processing'}): def process(self): return 'processed'print(f'ProcessingStep.__axes__ = {dict(ProcessingStep.__axes__)}')print(f'ProcessingStep.__scope__ = {ProcessingStep.__scope__}')# Pattern 2: Factory function (mimics extended type())Handler = axes_type('Handler', (), {}, format='imagexpress', version=2)print(f'Handler.__axes__ = {Handler.__axes__}')# Pattern 3: Decorator (when base can't be modified)@with_axes(scope='/decorated', priority=10)class DecoratedStep: passprint(f'DecoratedStep.__axes__ = {DecoratedStep.__axes__}')# MRO-based axis resolution with multiple inheritanceclass A(AxesBase, axes={'x': 1, 'from_a': True}): passclass B(AxesBase, axes={'x': 2, 'from_b': True}): passclass C(A, B): # x=1 from A (leftmost in MRO) passprint(f'C.__axes__ = {dict(C.__axes__)}') # x=1, from_a=True, from_b=True"

See src/objectstate/parametric_axes.py for full implementation and docstrings.

Reified Generics Prototype

The reified_generics module provides runtime-accessible type parameters for generics:

fromobjectstate.reified_genericsimportList, Dict# Type parameters are preserved at runtimeIntList=List[int]
StrDict=Dict[str, float]
# Introspection worksIntList.__args__# (int,)IntList.__origin__# listStrDict.__args__# (str, float)# isinstance checks work with reified typesmy_list=IntList([1, 2, 3])
isinstance(my_list, IntList) # Trueisinstance(my_list, List[str]) # False - different type parameter!# Type caching ensures identityList[int] isList[int] # True - same object

Try It Out

# Run reified generics tests
python -m pytest tests/test_reified_generics.py -v
# Interactive exploration
python -c "from objectstate.reified_generics import List, Dict, Set, Optional# Create reified typesIntList = List[int]StrIntDict = Dict[str, int]print(f'IntList.__args__ = {IntList.__args__}')print(f'IntList.__origin__ = {IntList.__origin__}')print(f'StrIntDict.__args__ = {StrIntDict.__args__}')# Type identity (caching)print(f'List[int] is List[int]: {List[int] is List[int]}')print(f'List[int] is List[str]: {List[int] is List[str]}')# Create instancesmy_list = IntList([1, 2, 3])print(f'isinstance(my_list, IntList): {isinstance(my_list, IntList)}')print(f'isinstance(my_list, List[str]): {isinstance(my_list, List[str])}')"

See src/objectstate/reified_generics.py for full implementation.

Documentation

Full documentation available at objectstate.readthedocs.io

Requirements

  • Python 3.11+
  • python-introspect is the only runtime dependency

License

MIT License - see LICENSE file for details

Contributing

Contributions welcome! Please see CONTRIBUTING.md for guidelines.

Credits

Developed by Tristan Simas as part of the OpenHCS project.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages