Generic lazy dataclass configuration framework with dual-axis inheritance
- 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
contextvarsfor 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-introspectfor callable and dataclass analysis - Custom Dataclass Rebuild Hook: For
init=Falsedataclasses during lazy serialization (__objectstate_rebuild__)
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.
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 contextKey differences:
set_base_config_type(MyConfig): Sets the type (class) for the frameworkensure_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
pip install objectstateObjectState 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)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")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 namespaceKey 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_decoratorgenerates a decorator specific to your global config - Type-safe: All generated classes are proper dataclasses with full IDE support
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-createdHow 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) - 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.
The generated decorator (e.g., @global_pipeline_config) supports optional parameters:
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 defaultsWhy this matters:
- Enables polymorphic access without type-specific attribute names
- Critical for dual-axis inheritance with multiple inheritance
- Uses
InheritAsNoneMetametaclass 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 inheritanceUse 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
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_hiddenflag) - Creates default factories for Optional dataclass fields
- Uses
register_lazy_type_mapping()internally
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# 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 BaseConfigfromobjectstateimportObjectState, 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 pipelineThe 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:
- Context hierarchy is flattened into a single
available_configsdict - For each field resolution, traverse the requesting object's MRO from most to least specific
- For each MRO class, check if there's a config instance in
available_configswith a concrete (non-None) value - Return the first concrete value found
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:
AxesBaseinheritance:class Foo(Base, axes={...})(preferred)- Factory function:
axes_type("Foo", (Base,), {}, scope="...", registry="...") - Decorator:
@with_axes(scope="...", registry="...")(when base can't be modified)
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.
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# 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.
Full documentation available at objectstate.readthedocs.io
- Python 3.11+
python-introspectis the only runtime dependency
MIT License - see LICENSE file for details
Contributions welcome! Please see CONTRIBUTING.md for guidelines.
Developed by Tristan Simas as part of the OpenHCS project.