Hierarchical System#
The system module provides hierarchical namespace organization for domain objects. Systems can be nested arbitrarily deep with automatic parent-child relationship management, tag-based lookup, and dynamic reorganization.
Overview#
The System class is the culmination of all Jangada’s mixins, providing:
Hierarchical organization - Tree-structured subsystem relationships
Namespace access - Dict-style and attribute-style subsystem lookup
Dynamic reorganization - Automatic updates when tags change
Circular dependency prevention - Validates hierarchy constraints
Full persistence - Save/load entire hierarchies
Rich display - Beautiful terminal visualization
|
Hierarchical namespace container for organizing subsystems. |
Quick Start#
Basic hierarchy:
from jangada.system import System
# Create systems
root = System(tag='root', name='Root System')
child = System(tag='child', name='Child System')
# Build hierarchy
root.add(child)
# Access subsystems
root['child'] # Dict-style
root.child # Attribute-style
Dict-style registration:
system = System(tag='system')
system['sensors'] = System(name='Sensors')
system['controllers'] = System(name='Controllers')
# Tags are automatically set from keys
assert system['sensors'].tag == 'sensors'
Complex hierarchy:
system = System(tag='system')
# Build via dict-style
system['sensors'] = System(name='Sensors')
system['sensors']['temperature'] = System(name='Temp Sensor')
system['sensors']['pressure'] = System(name='Pressure Sensor')
# Navigate
temp = system.sensors.temperature
print(temp.name) # 'Temp Sensor'
System Reference#
- class jangada.system.System(*args, **kwargs)#
Bases:
Persistable,Displayable,Identifiable,Taggable,Nameable,DescribableHierarchical namespace container for organizing subsystems.
System provides a framework for building tree-structured hierarchies with automatic bidirectional parent-child relationship management. Subsystems can be accessed via tag using either dict-style (
system['tag']) or attribute-style (system.tag) syntax.Key features: - Automatic parent-child relationship management - Tag-based namespace access (dict and attribute style) - Dynamic tag reorganization via observers - Circular dependency prevention - Persistence of entire hierarchies - Rich terminal display integration
- Attributes:
- subsystemsdict[str, System]
Dictionary mapping tags to subsystem objects. Managed automatically.
supersystemSystem or NoneGet parent system.
supersystem_chainlist[System]Get list of all ancestors.
See also
PersistableSerialization base class
DisplayableTerminal display base class
IdentifiableUnique identification mixin
TaggableTag-based identification mixin
Notes
- Hierarchy Structure
Systems form a tree structure (not DAG). Each system has at most one parent. Circular dependencies are prevented automatically.
- Tag Requirements
Subsystems must have tags to be registered. Tags must be valid Python identifiers and cannot be Python keywords.
- Tag Observers
When a subsystem’s tag changes, the parent’s subsystems dict is automatically updated. This is achieved through the observer pattern.
- Identity vs Content Equality
sys1 == sys2uses Identifiable (ID-based equality)sys1.equal(sys2)uses Persistable (content-based equality)
- Persistence
Saving a system saves its entire subtree. Parent references are stored as IDs, allowing proper reconstruction on load.
- Reserved Attributes
Avoid using subsystem tags that conflict with System’s methods and properties (e.g., ‘add’, ‘remove’, ‘subsystems’, ‘tag’, ‘id’).
Examples
Create simple hierarchy:
root = System(tag='root', name='Root System') child = System(tag='child', name='Child System') root.add(child) # Access via dict or attribute assert root['child'] is child assert root.child is child
Dict-style registration:
root = System(tag='root') root['sensor'] = System(name='Temperature Sensor') # Tag is automatically set from key assert root['sensor'].tag == 'sensor'
Build complex hierarchy:
system = System(tag='system') # Using dict-style system['sensors'] = System(name='Sensors') system['sensors']['temperature'] = System(name='Temp Sensor') system['sensors']['pressure'] = System(name='Pressure Sensor') # Navigate temp = system.sensors.temperature assert temp.name == 'Temp Sensor'
Dynamic reorganization:
root = System(tag='root') child = System(tag='old_tag') root.add(child) # Change tag - dict keys update automatically child.tag = 'new_tag' assert 'new_tag' in root assert 'old_tag' not in root
Save and load hierarchies:
root = System(tag='root') root['child'] = System(tag='child', name='Child') root.save('hierarchy.sys') loaded = System.load('hierarchy.sys') assert loaded.child.name == 'Child'
Content comparison:
sys1 = System(tag='test', name='Name') sys2 = System(tag='test', name='Name') # Different IDs, so ID-based equality is False assert sys1 != sys2 # But same content, so content equality is True assert sys1.equal(sys2)
- class ProxyDataset(dataset: Dataset)#
Bases:
objectLazy-loading wrapper for HDF5 datasets.
ProxyDataset provides array-like access to HDF5 datasets without loading the entire dataset into memory. It supports slicing, indexing, and modification operations, delegating to the underlying HDF5 dataset.
This class is automatically used when opening Persistable objects in context manager mode. It enables efficient access to large arrays that would be impractical to load fully.
- Attributes:
- _dataseth5py.Dataset
The underlying HDF5 dataset.
- _attrsdict
Cached dataset attributes (metadata).
- _dataset_type_namestr
Fully qualified name of the dataset type.
See also
PersistableParent class that creates ProxyDatasets
Notes
ProxyDataset does not implement the full NumPy array API. It provides:
- Supported:
Slicing and indexing (
__getitem__,__setitem__)Shape, dtype, ndim, size, nbytes properties
Append operation
Automatic resizing on out-of-bounds assignment
- Not supported:
Arithmetic operations (
+,-,*, etc.)Iteration (
__iter__)Length (
__len__)Universal functions (ufuncs)
For full array operations, load the data explicitly:
data_array = exp.data[:] # Load entire dataset result = data_array * 2 # Now can use numpy operations
Examples
Accessing data lazily:
with Experiment('data.hdf5', mode='r') as exp: # exp.data is a ProxyDataset print(exp.data.shape) # (1000000,) - not loaded yet # Load only what you need chunk = exp.data[100:200] # Loads only 100 elements
Modifying data:
with Experiment('data.hdf5', mode='r+') as exp: exp.data[50] = 99 # Modify single element exp.data[10:20] = new_values # Modify slice
Appending data:
with Experiment('data.hdf5', mode='r+') as exp: exp.data.append(np.array([new, data]))
- append(value: Any) None#
Append data to the end of the dataset.
Efficiently adds new data by resizing the dataset and writing only the new values. Much faster than loading, concatenating, and saving.
- Parameters:
- valueAny
Data to append. Must be compatible with the dataset type. For 1D datasets, can be a single value or array. For nD datasets, first dimension is extended.
- Raises:
- AssertionError
If the value’s metadata doesn’t match the dataset’s metadata.
Notes
The append operation: 1. Disassembles the value to array + metadata 2. Validates metadata matches existing dataset 3. Resizes dataset to accommodate new data 4. Writes only the new data (efficient)
For scalar datasets (ndim=0), append is not supported as they cannot be resized.
Examples
Append to time series:
with TimeSeries('data.hdf5', mode='r+') as ts: ts.data.append(np.array([new, values]))
Incremental data collection:
with Experiment('exp.hdf5', mode='r+') as exp: for measurement in new_measurements: exp.readings.append(measurement)
- property attrs: dict#
Get dataset metadata attributes.
- Returns:
- dict
Copy of the dataset’s metadata attributes.
Notes
Returns a copy to prevent accidental modification. The __dataset_type__ attribute is excluded as it’s used internally.
Examples
>>> proxy.attrs {'timezone': 'UTC', 'units': 'kelvin'}
- property dtype: dtype#
Get the data type of the dataset.
- Returns:
- numpy.dtype
The dtype of the dataset elements.
Examples
>>> proxy.dtype dtype('float64')
- property nbytes: int#
Get the total bytes consumed by the dataset elements.
- Returns:
- int
Total bytes (size * dtype.itemsize).
Examples
>>> proxy.nbytes 80000 # 10000 elements * 8 bytes per float64
- property ndim: int#
Get the number of dimensions.
- Returns:
- int
Number of dimensions.
Examples
>>> proxy.ndim 3
- add(*subsystems: System) None#
Add one or more subsystems.
- Parameters:
- *subsystemsSystem
Subsystems to add.
- Raises:
- ValueError
If subsystem has no tag, tag conflicts exist, or circular dependency would be created.
- TypeError
If argument is not a System.
Notes
Equivalent to setting
subsystem.supersystem = selffor each subsystem.Examples
>>> root = System(tag='root') >>> child1 = System(tag='child1') >>> child2 = System(tag='child2') >>> root.add(child1, child2) >>> len(root) 2
- copy() Serializable#
Create an independent copy of this object.
Uses serialization with is_copy=True, so only copiable properties are included in the copy.
- Returns:
- Serializable
A new instance with the same copiable property values.
See also
__copy__Implements copy.copy() support
Notes
The copy is created via serialization, so: - Only copiable properties are copied - Non-copiable properties use their defaults - Nested objects are also copied (deep copy) - All property initialization (parsers, observers, initializers) runs
Examples
>>> class MyClass(Serializable): ... value = SerializableProperty(default=0, copiable=True) ... cache = SerializableProperty(default=0, copiable=False) ... >>> obj = MyClass(value=42, cache=100) >>> copied = obj.copy() >>> copied.value 42 >>> copied.cache 0 >>> copied is obj False
- description: str#
Free-form descriptive text.
Extended context, documentation, or explanatory information. Can be multiline and arbitrarily long. Intended for detailed information that doesn’t fit in a short name.
See also
Nameable.nameShort display name
Notes
Descriptions are for extended information - use name for short labels. No formatting is applied - if you need formatted text (markdown, HTML), store it as a string and format at display time.
Common use cases: detailed documentation, usage instructions, configuration notes, debugging context, help text in UIs.
Examples
Basic usage:
obj.description = "This sensor monitors ambient temperature."
Multiline text:
obj.description = ''' This is a detailed description. It can span multiple lines. Useful for documentation. '''
Very long text:
obj.description = "Long documentation..." * 1000 # No limit
Normalization (same as name):
obj.description = " Text " assert obj.description == "Text" obj.description = "" assert obj.description is None
- static deserialize(data: Any) Any#
Recursively deserialize data to reconstruct objects.
Converts serialized dictionary structures back into Python objects. Handles nested Serializable objects, collections, and primitives.
- Parameters:
- dataAny
The serialized data to deserialize.
- Returns:
- Any
The reconstructed object: - dict with ‘__class__’ → Serializable instance - list → list (recursively deserialized) - dict without ‘__class__’ → dict (recursively deserialized) - Primitives → unchanged - None → None
- Raises:
- TypeError
If the data type is not supported for deserialization.
See also
serializeConvert objects to serializable data
Notes
If a class referenced in ‘__class__’ is not registered (not imported), deserialize creates a generic Serializable subclass on-the-fly with the necessary properties. This allows reading data even when the original class definition is unavailable.
Deserialization triggers all property parsers, observers, and post-initializers as if the object were being constructed normally.
Examples
Deserialize a simple object:
>>> class MyClass(Serializable): ... value = SerializableProperty(default=0) ... >>> qualname = get_full_qualified_name(MyClass) >>> data = {'__class__': qualname, 'value': 42} >>> obj = Serializable.deserialize(data) >>> obj.value 42
Roundtrip serialization:
>>> original = MyClass(value=99) >>> data = Serializable.serialize(original) >>> restored = Serializable.deserialize(data) >>> restored.value 99 >>> original == restored True
Collections are handled recursively:
>>> data = [1, 2, {'a': 3}] >>> result = Serializable.deserialize(data) >>> result [1, 2, {'a': 3}]
Unknown classes create generic Serializable objects:
>>> data = { ... '__class__': 'unknown.Module.UnknownClass', ... 'prop1': 42, ... 'prop2': "test" ... } >>> obj = Serializable.deserialize(data) >>> obj.prop1 42
- display_settings: DisplaySettings#
Configuration for display formatting and styling.
Provides access to all visual customization options for this object’s terminal display. Each instance receives its own DisplaySettings object by default (via factory), allowing per-object customization without affecting other instances.
See also
DisplaySettingsThe settings class with all options
format_as_formUses property_style setting
format_as_tableUses table_* settings
Notes
- Factory Behavior
The display_settings property uses a factory function to create a new DisplaySettings instance for each Displayable object. This ensures: - Each instance has independent settings by default - No unexpected sharing between objects - Settings can be customized per-object without side effects
- Sharing Settings
To share settings across multiple objects, explicitly assign the same DisplaySettings instance:
shared_settings = DisplaySettings() obj1.display_settings = shared_settings obj2.display_settings = shared_settings
Now changes to shared_settings affect both objects.
- Persistence
DisplaySettings is Persistable, so settings can be: - Saved to .disp files (themes) - Loaded from saved themes - Versioned and shared across projects - Used to maintain consistent styling
- Immediate Effect
Changes to display_settings take effect immediately on the next render: - print(obj) uses current settings - obj.__rich__() uses current settings - obj.to_html() uses current settings
No need to “apply” or “refresh” - just change and use.
- Available Settings
See DisplaySettings documentation for all available options: - Console: console_width - Panel: panel_border_style, panel_box, panel_title_align - Property labels: property_style - Tables: table_index_style, table_header_style, table_round_floats, table_spacing
Examples
Customize styling:
obj.display_settings.panel_border_style = 'green' obj.display_settings.table_header_style = 'bold magenta' obj.display_settings.console_width = 120
Access specific settings:
width = obj.display_settings.console_width border = obj.display_settings.panel_border_style spacing = obj.display_settings.table_spacing
Share settings across objects:
shared = DisplaySettings() shared.panel_border_style = 'yellow' shared.table_spacing = 8 obj1.display_settings = shared obj2.display_settings = shared # Both objects now share the same styling
Load saved theme:
theme = DisplaySettings.load('dark_theme.disp') obj.display_settings = theme
Save current settings:
obj.display_settings.save('my_custom_theme.disp')
Temporary style changes:
# Modify for one display obj.display_settings.console_width = 100 print(obj) # Reset to defaults obj.display_settings = DisplaySettings()
- equal(system: System) bool#
Compare content equality with another system.
Uses Persistable’s equality which compares serialized content recursively, ignoring non-copiable properties like IDs.
- Parameters:
- systemSystem
System to compare with.
- Returns:
- bool
True if systems have identical content, False otherwise.
See also
Persistable.__eq__Content comparison implementation
Identifiable.__eq__ID comparison implementation
Notes
Distinction from
==: -sys1 == sys2uses Identifiable (compares IDs) -sys1.equal(sys2)uses Persistable (compares content)This is useful for comparing copies or checking if two systems have the same structure and data, regardless of their identities.
Examples
>>> sys1 = System(tag='test', name='Name') >>> sys2 = System(tag='test', name='Name') >>> sys1 == sys2 # ID-based equality False >>> sys1.equal(sys2) # Content-based equality True
- format_as_form(data: dict[str, str] | Series) Table#
Format data as key-value form.
Creates two-column table with keys (left) and values (right).
- Parameters:
- datadict[str, str] or pandas.Series
Key-value pairs to display.
- Returns:
- Table
Rich Table in form layout.
Notes
Keys automatically get ‘:’ appended and use property_style. Values are left-aligned with no special styling.
Examples
>>> form = self.format_as_form({ ... 'Name': 'Alice', ... 'Age': '25', ... 'City': 'NYC' ... })
- format_as_table(frame: DataFrame, show_index: bool = True, format_index_as_property: bool = False, format_header_as_property: bool = False, align_header: str | dict[str, str] = 'center', align_column: str | dict[str, str] | None = None, max_rows: int = 31, **kwargs) Table#
Format DataFrame as Rich table.
Creates formatted table with automatic alignment, optional truncation, and extensive customization options.
- Parameters:
- framepandas.DataFrame
Data to display.
- show_indexbool, optional
Include index column. Default True.
- format_index_as_propertybool, optional
Use property_style for index. Default False.
- format_header_as_propertybool, optional
Use property_style for headers. Default False.
- align_headerstr or dict[str, str], optional
Header alignment. Default ‘center’.
- align_columnstr, dict[str, str], or None, optional
Column alignment. None = auto-detect. Default None.
- max_rowsint, optional
Max rows before truncation. Default 31.
- **kwargs
Additional options: - header_style : str or None - index_style : str or None - round_floats : int, dict, or None
- Returns:
- Table
Formatted Rich Table.
- Raises:
- TypeError
If alignment or rounding parameters have invalid types.
Notes
Auto-alignment (align_column=None): numeric right, datetime/text left.
Truncation: Shows first n/2 rows, ‘…’, last n/2 rows when > max_rows.
Creates DataFrame copy for processing.
Examples
Basic usage:
>>> table = self.format_as_table(df)
Custom alignment:
>>> table = self.format_as_table(df, align_column='right')
Float rounding:
>>> table = self.format_as_table(df, round_floats=2)
Large dataset:
>>> table = self.format_as_table(df, max_rows=20)
- classmethod get_instance(id_: str) Identifiable | None#
Retrieve an Identifiable instance by its ID.
Looks up an object in the global instance registry using its unique identifier. Returns the object if it exists and has not been garbage collected, otherwise returns None.
- Parameters:
- id_str
The 32-character hexadecimal UUID string to look up.
- Returns:
- Identifiable or None
The instance with the given ID, or None if no such instance exists or has been garbage collected.
See also
idThe unique identifier property
Notes
This method searches the global registry maintained by the Identifiable class. The registry uses weak references, so objects can be garbage collected even if they’re in the registry.
If an object has been garbage collected, its ID will no longer be in the registry and this method will return None.
The lookup is O(1) as the registry is implemented as a dictionary.
Examples
Basic lookup:
>>> obj = Identifiable() >>> obj_id = obj.id >>> retrieved = Identifiable.get_instance(obj_id) >>> retrieved is obj True
Lookup of non-existent ID:
>>> Identifiable.get_instance('00000000000000000000000000000000') None
Object garbage collected:
>>> obj = Identifiable() >>> obj_id = obj.id >>> del obj # Object can be garbage collected >>> Identifiable.get_instance(obj_id) None
- id: str#
Globally unique identifier (UUID v4).
A 32-character hexadecimal UUID v4 string that uniquely identifies this instance. Automatically generated on first access if not explicitly provided. Write-once (cannot be changed after initialization) and non-copiable (each copy gets a new ID).
See also
Identifiable.get_instanceRetrieve instance by ID
Notes
The ID serves as the basis for
__hash__()and__eq__(), enabling instances to be used in sets and as dictionary keys. Objects with the same ID are considered equal.Examples
Automatic generation:
obj = Identifiable() print(obj.id) # '3a5f8e2c1b9d4f7a0b1c2d3e4f5a6b7c'
Explicit setting (during deserialization):
# ID is validated and normalized obj.id = 'A1B2C3D4-E5F6-4789-ABCD-EF0123456789'
Immutability:
obj = Identifiable() obj.id = 'different-id' # Raises AttributeError
Lookup by ID:
obj_id = obj.id retrieved = Identifiable.get_instance(obj_id) assert retrieved is obj
- classmethod load(path: Path | str) Persistable#
Load an object from an HDF5 file.
- Parameters:
- pathPath | str
File path to load from.
- Returns:
- Persistable
Reconstructed object.
- Raises:
- FileNotFoundError
If file does not exist.
See also
saveSave counterpart
Notes
The entire file is loaded into memory. For large files, consider using context manager mode for lazy loading:
with Experiment('large_file.hdf5', mode='r') as exp: # Access data on-demand chunk = exp.data[100:200]
Examples
>>> exp = Experiment.load('experiment.hdf5') >>> print(exp.name) 'Test'
- classmethod load_serialized_data(path: Path | str) Any#
Load serialized data dictionary from HDF5 file.
This is a low-level method that reads an HDF5 file and returns the data dictionary (which can be passed to Serializable.deserialize).
- Parameters:
- pathPath | str
File path to load from.
- Returns:
- Any
Deserialized data dictionary.
- Raises:
- FileNotFoundError
If the file does not exist.
See also
loadHigh-level load method
save_serialized_dataSave counterpart
Notes
This method is called internally by load(). Users typically use load() instead of calling this directly.
- name: str#
Human-readable display name.
A string for labeling and display purposes. Less restrictive than tags - can contain spaces, special characters, and Unicode. Intended for user-facing contexts like UI labels, reports, and logs.
See also
Taggable.tagSymbolic identifier for namespace access
Describable.descriptionExtended description text
Notes
Use names for display and human consumption. For programmatic identifiers that work in attribute access, use tags from Taggable.
Names are mutable and have no uniqueness constraints. Very long names may need truncation for display purposes.
Examples
Basic usage:
obj.name = "Temperature Sensor" obj.name = "Sensor #1 (Main)" obj.name = "Test-Case-A"
Unicode support:
obj.name = "Tëst Nämé" obj.name = "测试名称" obj.name = "📊 Data Dashboard"
Normalization:
obj.name = " Name " assert obj.name == "Name" obj.name = " " assert obj.name is None obj.name = "" assert obj.name is None
Type conversion:
obj.name = 123 assert obj.name == "123"
- remove(subsystem: System | str) None#
Remove a subsystem.
- Parameters:
- subsystemSystem or str
Subsystem object or tag to remove.
- Raises:
- ValueError
If subsystem is not registered in this system.
Notes
Equivalent to setting
subsystem.supersystem = None.Examples
>>> root = System(tag='root') >>> child = System(tag='child') >>> root.add(child) >>> root.remove(child) >>> child in root False
>>> root.add(child) >>> root.remove('child') >>> child in root False
- save(path: Path | str, overwrite: bool = True, use_default_extension: bool = True) None#
Save this object to an HDF5 file.
- Parameters:
- pathPath | str
File path to save to.
- overwritebool, optional
If True, overwrite existing file. If False, raise FileExistsError if file exists. Default is True.
- use_default_extensionbool, optional
If True, add the class’s default extension if not present. Default is True.
- Raises:
- FileExistsError
If file exists and overwrite=False.
See also
loadLoad counterpart
Notes
The save process: 1. Resolves path (adds extension if needed) 2. Checks if file exists (if overwrite=False) 3. Serializes object to dictionary 4. Writes dictionary to HDF5 file
The entire object is serialized, so this may be slow for very large objects. For incremental updates, use context manager mode.
Examples
Basic save:
exp = Experiment(name="Test", temperature=300.0) exp.save('experiment.hdf5')
Save without overwriting:
exp.save('experiment.hdf5', overwrite=False)
Save with custom extension:
exp.save('data.h5', use_default_extension=False)
- save_serialized_data(path: Path | str, data: Any) None#
Save serialized data dictionary to HDF5 file.
This is a low-level method that writes a data dictionary (from Serializable.serialize) to an HDF5 file.
- Parameters:
- pathPath | str
File path to save to.
- dataAny
Serialized data (typically from Serializable.serialize).
See also
saveHigh-level save method
load_serialized_dataLoad counterpart
Notes
This method is called internally by save(). Users typically use save() instead of calling this directly.
Creates an HDF5 file with a ‘root’ group containing all the data.
- static serialize(obj: Any, is_copy: bool = False) Any#
Recursively serialize an object to a dictionary structure.
Converts Python objects to a nested dictionary structure suitable for JSON, HDF5 attributes, or other storage formats. Handles Serializable objects, collections, primitives, and dataset types.
- Parameters:
- objAny
The object to serialize.
- is_copybool, optional
If True, only serialize copiable properties. If False, serialize all properties. Default is False.
- Returns:
- Any
The serialized representation: - Serializable → dict with ‘__class__’ key - list/tuple/set → list (recursively serialized) - dict → dict (recursively serialized values) - Primitives → unchanged - None → None
- Raises:
- TypeError
If the object’s type is not registered as primitive, dataset, or Serializable.
See also
deserializeReconstruct objects from serialized data
copyCreate a copy using serialize with is_copy=True
Notes
The serialization format for Serializable objects is: ```python {
‘__class__’: ‘module.ClassName’, ‘property1’: value1, ‘property2’: value2, …
}#
Tuples and sets are converted to lists (Python’s JSON doesn’t distinguish these).
Examples
Serialize a simple object:
>>> class MyClass(Serializable): ... value = SerializableProperty(default=0) ... >>> obj = MyClass(value=42) >>> data = Serializable.serialize(obj) >>> '__class__' in data True >>> data['value'] 42
Serialize with copy mode (only copiable properties):
>>> class MyClass2(Serializable): ... important = SerializableProperty(default=0, copiable=True) ... cache = SerializableProperty(default=0, copiable=False) ... >>> obj = MyClass2(important=10, cache=20) >>> data = Serializable.serialize(obj, is_copy=True) >>> 'important' in data True >>> 'cache' in data False
Collections are handled recursively:
>>> data = Serializable.serialize([1, "two", 3.0]) >>> data [1, 'two', 3.0]
- subsystems: dict[str, System]#
Dictionary mapping tags to subsystem objects.
Automatically managed - subsystems are added/removed via add(), remove(), dict-style assignment, or by setting supersystem property. Manual modification is discouraged as it bypasses validation.
Notes
- Automatic Management
This dict is managed automatically. When you add a subsystem via
system.add(child)orsystem['tag'] = child, it is added to this dict. When tags change, dict keys update automatically via observers.- Direct Modification
Avoid directly modifying this dict (e.g.,
system.subsystems['tag'] = child) as it bypasses validation and observer setup. Useadd(),remove(), or dict-style assignment (system['tag'] = child) instead.- Serialization
When a System is saved, the subsystems dict is serialized, preserving the entire hierarchy. Parent references are stored as IDs to avoid circular references.
Examples
Access subsystems:
for tag, subsystem in system.subsystems.items(): print(f"{tag}: {subsystem.name}")
Check contents:
if 'sensor' in system.subsystems: sensor = system.subsystems['sensor']
Iterate:
for subsystem in system.subsystems.values(): process(subsystem)
- property supersystem: System | None#
Get parent system.
- Returns:
- System or None
Parent system, or None if this is a root system.
Examples
>>> root = System(tag='root') >>> child = System(tag='child') >>> root.add(child) >>> child.supersystem is root True >>> root.supersystem is None True
- property supersystem_chain: list[System]#
Get list of all ancestors.
Returns list of all parent systems from immediate parent to root, in order.
- Returns:
- list[System]
List of ancestors, nearest first.
Notes
Walks the hierarchy upwards until reaching a root (supersystem is None). Used for circular dependency detection and hierarchy navigation.
Examples
>>> root = System(tag='root') >>> level1 = System(tag='level1') >>> level2 = System(tag='level2') >>> root.add(level1) >>> level1.add(level2) >>> level2.supersystem_chain [<System level1>, <System root>]
- tag: str | None#
Symbolic identifier for namespace-style access.
A validated string that must be a valid Python identifier (but not a keyword). Intended for programmatic, attribute-style access in containers and namespaces, similar to how pandas DataFrames expose columns.
See also
Nameable.nameHuman-readable display name
Notes
Tags are mutable (can be changed) to support dynamic reorganization of namespaces. Uniqueness is not enforced globally - containers should manage tag uniqueness within their scope.
Use tags for programmatic access, not display. For human-readable labels, use the
nameproperty from Nameable.Examples
Valid tags:
obj.tag = "sensor_a" obj.tag = "temp_sensor_01" obj.tag = "_private"
Invalid tags:
obj.tag = "123invalid" # Starts with digit obj.tag = "invalid-tag" # Contains hyphen obj.tag = "invalid tag" # Contains space obj.tag = "if" # Python keyword obj.tag = "" # Empty string
Namespace access pattern:
# Container provides attribute-style access by tag system.sensor_a # Returns object with tag='sensor_a'
Whitespace normalization:
obj.tag = " my_tag " assert obj.tag == "my_tag"
Core Concepts#
Hierarchy Structure#
Systems form a tree structure (not DAG). Each system has:
Zero or one parent (supersystem)
Zero or more children (subsystems)
No circular dependencies (automatically prevented)
Example hierarchy:
root
├── sensors
│ ├── temperature
│ └── pressure
└── controllers
└── main
Tag-Based Access#
Subsystems are accessed by their tags using either dict-style or attribute-style syntax:
Dict-Style:
system['sensor_name']
system['sensor_name'] = System()
if 'sensor_name' in system:
sensor = system['sensor_name']
Attribute-Style:
system.sensor_name
# Note: __setattr__ not yet implemented
if hasattr(system, 'sensor_name'):
sensor = system.sensor_name
Which to use?
Dict-style: More explicit, works with any valid identifier
Attribute-style: Cleaner syntax for static structures
Bidirectional Relationships#
Parent-child relationships are bidirectional and automatically managed:
Setting parent automatically updates child list:
parent = System(tag='parent')
child = System(tag='child')
child.supersystem = parent
# Automatically: parent.subsystems['child'] = child
Setting child automatically updates parent:
parent = System(tag='parent')
child = System(tag='child')
parent.add(child)
# Automatically: child.supersystem = parent
Reparenting:
parent1 = System(tag='parent1')
parent2 = System(tag='parent2')
child = System(tag='child')
parent1.add(child)
parent2.add(child) # Removes from parent1, adds to parent2
Dynamic Tag Reorganization#
When a subsystem’s tag changes, parent dicts update automatically via observers:
Example:
root = System(tag='root')
child = System(tag='old_tag')
root.add(child)
assert 'old_tag' in root
# Change tag
child.tag = 'new_tag'
# Dict keys updated automatically
assert 'new_tag' in root
assert 'old_tag' not in root
Usage Patterns#
Building Hierarchies#
Incremental construction:
system = System(tag='system')
sensors = System(tag='sensors')
system.add(sensors)
temp = System(tag='temperature')
sensors.add(temp)
Nested construction:
system = System(tag='system')
system['sensors'] = System()
system['sensors']['temperature'] = System()
system['sensors']['pressure'] = System()
Bulk addition:
system = System(tag='system')
components = [
System(tag='comp1'),
System(tag='comp2'),
System(tag='comp3'),
]
system.add(*components)
Persistence#
Save hierarchy:
root = System(tag='root')
root['child'] = System(tag='child', name='Child')
root.save('hierarchy.sys')
Load hierarchy:
loaded = System.load('hierarchy.sys')
# Hierarchy reconstructed
assert loaded.child.name == 'Child'
assert loaded.child.supersystem is loaded
Content comparison:
sys1 = System(tag='test', name='Name')
sys2 = System(tag='test', name='Name')
# Different IDs
assert sys1 != sys2 # ID-based equality
# Same content
assert sys1.equal(sys2) # Content-based equality
Display#
Terminal output:
system = System(tag='system', name='My System')
system['sensor'] = System(tag='sensor', name='Sensor')
print(system) # Rich formatted panel
Customize display:
system.display_settings.panel_border_style = 'green'
system.display_settings.console_width = 120
print(system)
Export to HTML/SVG:
html = system.to_html()
svg = system.to_svg()
Advanced Topics#
Validation and Constraints#
Systems automatically validate:
Tag requirements:
child = System(tag=None)
parent.add(child) # ValueError: must have tag
Circular dependencies:
sys_a = System(tag='a')
sys_b = System(tag='b')
sys_a.add(sys_b)
sys_b.add(sys_a) # ValueError: circular dependency
Tag conflicts:
parent = System(tag='parent')
parent['sensor'] = System()
parent['sensor'] = System() # OK: replaces
child1 = System(tag='sensor')
child2 = System(tag='sensor')
parent.add(child1)
parent.add(child2) # ValueError: tag conflict
Observer Pattern#
System uses observers to maintain consistency when tags change:
How it works:
1. Subsystem added → Observer attached to tag property
2. Tag changes → Observer notified with old and new values
3. Observer updates parent's subsystems dict
4. Validates no conflicts, updates keys
Observer lifecycle:
parent = System(tag='parent')
child = System(tag='child')
parent.add(child)
# → Observer created and attached to child.tag
child.tag = 'renamed'
# → Observer fires, updates parent.subsystems
parent.remove(child)
# → Observer detached and cleaned up
Manual observer access (advanced):
# Check observers
observers = parent._tag_observers
# observers[child] contains the observer function
Identity vs Content Equality#
System supports two types of equality:
ID-Based (``==`` operator):
Uses Identifiable.__eq__
sys1 = System(tag='test')
sys2 = System(tag='test')
sys1 == sys2 # False (different IDs)
Content-Based (``equal()`` method):
Uses Persistable.__eq__
sys1 = System(tag='test', name='Name')
sys2 = System(tag='test', name='Name')
sys1.equal(sys2) # True (same content)
Use cases:
==: Check if same object (by ID)equal(): Check if same structure/data (for copies)
Inheritance Hierarchy#
System inherits from five base classes:
System
├── Persistable → Save/load to HDF5
├── Displayable → Rich terminal display
├── Identifiable → Unique ID, hash, equality
├── Taggable → Symbolic tag for namespace
├── Nameable → Human-readable name
└── Describable → Free-form description
Each mixin provides orthogonal capabilities that System combines.
API Design Decisions#
Why Store Parent as ID?#
The _supersystem_id property stores the parent’s ID rather than a
direct reference. This design:
Advantages:
Avoids circular references in serialization
Allows garbage collection (weak reference behavior)
Enables proper save/load of hierarchies
Works with Identifiable’s global registry
Trade-offs:
Requires ID lookup on every
supersystemaccessParent must exist in Identifiable registry
Slightly more complex implementation
Tree vs DAG Structure#
System enforces a tree structure (single parent per system) rather than a DAG (multiple parents).
Why trees?:
Simpler mental model
Easier serialization
Clear ownership semantics
Prevents complex circular dependency scenarios
If you need DAG:
Use references instead of hierarchy
Store IDs in custom properties
Implement custom relationship management
Examples Gallery#
Scientific Instrument System#
Model a scientific instrument with subsystems:
instrument = System(tag='instrument', name='UV-Vis Spectrometer')
# Optical components
instrument['optics'] = System(name='Optical System')
instrument['optics']['source'] = System(name='Light Source')
instrument['optics']['monochromator'] = System(name='Monochromator')
instrument['optics']['detector'] = System(name='Detector')
# Control systems
instrument['control'] = System(name='Control System')
instrument['control']['temperature'] = System(name='Temp Controller')
instrument['control']['shutter'] = System(name='Shutter Controller')
# Access
detector = instrument.optics.detector
print(f"Path: {detector.supersystem.supersystem.tag}")
Data Processing Pipeline#
Build a data processing pipeline:
pipeline = System(tag='pipeline', name='Data Pipeline')
# Stages
pipeline['ingest'] = System(name='Data Ingest')
pipeline['clean'] = System(name='Data Cleaning')
pipeline['transform'] = System(name='Transformation')
pipeline['export'] = System(name='Export')
# Configure each stage
for stage in pipeline:
stage.display_settings.panel_border_style = 'green'
print(f"Stage: {stage.name}")
Organization Hierarchy#
Model an organization structure:
company = System(tag='company', name='Acme Corp')
# Departments
company['engineering'] = System(name='Engineering')
company['engineering']['backend'] = System(name='Backend Team')
company['engineering']['frontend'] = System(name='Frontend Team')
company['sales'] = System(name='Sales')
company['sales']['enterprise'] = System(name='Enterprise Sales')
# Reorganize
company['engineering']['devops'] = System(name='DevOps Team')
# Move team
company['operations'] = System(name='Operations')
company['operations']['devops'] = company['engineering']['devops']
# DevOps moved from Engineering to Operations
Configuration Management#
Hierarchical configuration:
config = System(tag='config', name='Application Config')
# Database settings
config['database'] = System(name='Database')
config['database'].description = 'PostgreSQL connection params'
config['database']['host'] = System(name='localhost')
config['database']['port'] = System(name='5432')
# API settings
config['api'] = System(name='API')
config['api']['base_url'] = System(name='https://api.example.com')
config['api']['timeout'] = System(name='30')
# Access configuration
db_host = config.database.host.name
Best Practices#
Naming Conventions#
Tags (machine identifiers):
Use lowercase with underscores:
sensor_tempBe descriptive but concise
Include type/category:
ctrl_main,sensor_pressureAvoid abbreviations unless standard
Names (human labels):
Use Title Case:
"Temperature Sensor"Include units where relevant:
"Temp (°C)"Be descriptive:
"Primary Temperature Sensor"
Organization#
Logical grouping:
system = System(tag='system')
system['sensors'] = System() # Group by type
system['controllers'] = System()
system['processors'] = System()
Hierarchical decomposition:
root = System(tag='root')
root['subsystem_a'] = System()
root['subsystem_a']['component_1'] = System()
root['subsystem_a']['component_2'] = System()
Keep hierarchies shallow: 3-5 levels is typically sufficient.
Error Handling#
Check before accessing:
if 'sensor' in system:
sensor = system['sensor']
else:
print("Sensor not found")
Handle KeyError/AttributeError:
try:
sensor = system['nonexistent']
except KeyError:
print("Subsystem not found")
try:
sensor = system.nonexistent
except AttributeError:
print("Subsystem not found")
Validate before adding:
if child.tag is None:
child.tag = 'default_tag'
if child.tag not in parent:
parent.add(child)
Memory Management#
Weak references: Identifiable uses weak references, so systems can be garbage collected when no longer referenced.
Break cycles: When deleting systems, explicitly remove from parent:
parent.remove(child)
del child
Large hierarchies: Be mindful of memory usage with deep/wide trees.
Troubleshooting#
Common Errors#
ValueError: Subsystems must have a tag:
# Problem
child = System(tag=None)
parent.add(child)
# Solution
child.tag = 'child_tag'
parent.add(child)
ValueError: circular dependency:
# Problem
sys_a.add(sys_b)
sys_b.add(sys_a)
# Solution: Don't create cycles
# Use references instead if bidirectional link needed
ValueError: tag already used:
# Problem
parent['sensor'] = System()
child = System(tag='sensor')
parent.add(child)
# Solution: Use different tag or replace
parent['sensor'] = child # Replaces existing
KeyError: No subsystem registered:
# Problem
system['nonexistent']
# Solution: Check first
if 'nonexistent' in system:
subsys = system['nonexistent']
Tag Observer Issues#
Tag conflicts during reorganization:
# If tag observer raises error
try:
child.tag = 'conflicting_tag'
except ValueError as e:
print(f"Tag change failed: {e}")
# Tag remains unchanged
Observer not firing:
# Verify subsystem is registered
assert child in parent
# Change tag
child.tag = 'new_tag'
# Verify update
assert 'new_tag' in parent
Persistence Issues#
Hierarchy not reconstructed:
# Ensure entire tree is saved
root.save('hierarchy.sys') # Saves root and all children
# Not this:
child.save('child.sys') # Only saves child, loses parent
IDs change after load:
# IDs are non-copiable, so each load gets new IDs
original_id = system.id
system.save('system.sys')
loaded = System.load('system.sys')
assert loaded.id != original_id # Expected behavior
Performance Considerations#
Hierarchy Depth#
O(1): Dict-style access (
system['tag'])O(n): Supersystem chain (
system.supersystem_chain)O(d): Navigation by path (d = depth)
For deep hierarchies, cache supersystem chain if accessed frequently.
Tag Changes#
Tag changes trigger observer which:
Validates new tag (O(1) dict lookup)
Updates parent dict (O(1) operations)
No recursive updates
Impact is minimal for most use cases.
Serialization#
Saving a system serializes:
The system itself
All subsystems recursively
All SerializableProperty data
For large hierarchies:
Save can be slow (all subsystems written)
Load reconstructs entire tree
Consider splitting very large hierarchies
See Also#
External Resources#
Composite Pattern - Design pattern for tree structures
Observer Pattern - Design pattern for event notification