diff --git a/docs/explanation/serialization.md b/docs/explanation/serialization.md index 982c76f..a40699d 100644 --- a/docs/explanation/serialization.md +++ b/docs/explanation/serialization.md @@ -14,7 +14,6 @@ those types. { "uuid": "1e5f90ae-a386-4c8a-89ae-0ed123da3e26", "name": null, - "system_uuid": "9c33cd43-38ac-4ecf-97b8-0eccd1aa1fb5", "x": 0.0, "y": 0.0, "crs": null, @@ -44,7 +43,6 @@ de-serialized. { "uuid": "e503984a-3285-43b6-84c2-805eb3889210", "name": "bus1", - "system_uuid": "9c33cd43-38ac-4ecf-97b8-0eccd1aa1fb5", "voltage": 1.1, "coordinates": { "__metadata__": { @@ -76,12 +74,10 @@ Here is an example of a bus serialized that way (`bus.model_dump_json(indent=2)` { "uuid": "e503984a-3285-43b6-84c2-805eb3889210", "name": "bus1", - "system_uuid": "9c33cd43-38ac-4ecf-97b8-0eccd1aa1fb5", "voltage": 1.1, "coordinates": { "uuid": "1e5f90ae-a386-4c8a-89ae-0ed123da3e26", "name": null, - "system_uuid": "9c33cd43-38ac-4ecf-97b8-0eccd1aa1fb5", "x": 0.0, "y": 0.0, "crs": null @@ -97,7 +93,6 @@ instance. Here is an example of such a component: { "uuid": "711d2724-5814-4e0e-be5f-4b0b825b7f07", "name": "test", - "system_uuid": "264a2e80-64f4-42ea-b2c4-87aa708287b4", "distance": { "value": 2, "units": "meter", diff --git a/docs/tutorials/custom_system.md b/docs/tutorials/custom_system.md index 026459c..2b6760d 100644 --- a/docs/tutorials/custom_system.md +++ b/docs/tutorials/custom_system.md @@ -46,13 +46,6 @@ class Bus(Component): voltage: float coordinates: Location | None = None - def check_component_addition(self, system_uuid: UUID): - if self.coordinates is not None and not self.coordinates.is_attached( - system_uuid=system_uuid - ): - msg = f"{self.label} has coordinates that are not attached to the system" - raise ISOperationNotAllowed(msg) - @classmethod def example(cls) -> "Bus": return Bus( diff --git a/src/infrasys/component.py b/src/infrasys/component.py index 159ac8e..5d502d4 100644 --- a/src/infrasys/component.py +++ b/src/infrasys/component.py @@ -1,17 +1,12 @@ """Defines base models for components.""" -from typing import Any, Optional -from uuid import UUID +from typing import Any -from pydantic import Field, field_serializer +from pydantic import Field from rich import print as _pprint from typing_extensions import Annotated from infrasys.base_quantity import BaseQuantity -from infrasys.exceptions import ( - ISNotStored, - ISAlreadyAttached, -) from infrasys.models import ( InfraSysBaseModelWithIdentifers, ) @@ -28,27 +23,10 @@ class Component(InfraSysBaseModelWithIdentifers): """Base class for all models representing entities that get attached to a System.""" name: Annotated[str, Field(frozen=True)] - system_uuid: Annotated[Optional[UUID], Field(repr=False, exclude=True)] = None - - @field_serializer("system_uuid") - def _serialize_system_uuid(self, _) -> str: - return str(self.system_uuid) - def check_component_addition(self, system_uuid: UUID) -> None: + def check_component_addition(self) -> None: """Perform checks on the component before adding it to a system.""" - def is_attached(self, system_uuid: Optional[UUID] = None) -> bool: - """Return True if the component is attached to a system. - - Parameters - ---------- - system_uuid : UUID - Only return True if the component is attached to the system with this UUID. - """ - if self.system_uuid is None: - return False - return self.system_uuid == system_uuid - def model_dump_custom(self, *args, **kwargs) -> dict[str, Any]: """Custom serialization for this package""" refs = {x: self._model_dump_field(x) for x in self.model_fields} @@ -82,26 +60,6 @@ def pprint(self): return _pprint(self) -def raise_if_attached(component: Component): - """Raise an exception if this component is attached to a system.""" - if component.system_uuid is not None: - msg = f"{component.label} is attached to system {component.system_uuid}" - raise ISAlreadyAttached(msg) - - -def raise_if_not_attached(component: Component, system_uuid: UUID): - """Raise an exception if this component is not attached to a system. - - Parameters - ---------- - system_uuid : UUID - The component must be attached to the system with this UUID. - """ - if component.system_uuid is None or component.system_uuid != system_uuid: - msg = f"{component.label} is not attached to the system" - raise ISNotStored(msg) - - def serialize_component_reference(component: Component) -> dict[str, Any]: """Make a JSON serializable reference to a component.""" return SerializedTypeMetadata( diff --git a/src/infrasys/component_manager.py b/src/infrasys/component_manager.py index 60d187b..df9a9d0 100644 --- a/src/infrasys/component_manager.py +++ b/src/infrasys/component_manager.py @@ -6,7 +6,7 @@ from uuid import UUID from loguru import logger -from infrasys.component import Component, raise_if_attached +from infrasys.component import Component from infrasys.exceptions import ISAlreadyAttached, ISNotStored, ISOperationNotAllowed from infrasys.models import make_label, get_class_and_name_from_label @@ -211,7 +211,6 @@ def remove(self, component: Component) -> Any: for i, comp in enumerate(container): if comp.uuid == component.uuid: container.pop(i) - component.system_uuid = None if not self._components[component_type][component.name]: self._components[component_type].pop(component.name) self._components_by_uuid.pop(component.uuid) @@ -229,14 +228,14 @@ def copy( name: str | None = None, attach=False, ) -> Component: - """Create a copy of the component. Time series data is excluded.""" + """Create a shallow copy of the component.""" values = {} for field in type(component).model_fields: cur_val = getattr(component, field) if field == "name" and name: # Name is special-cased because it is a frozen field. val = name - elif field in ("system_uuid", "uuid"): + elif field in ("uuid",): continue else: val = cur_val @@ -250,6 +249,11 @@ def copy( return new_component + def deepcopy(self, component: Component) -> Component: + """Create a deep copy of the component.""" + values = component.model_dump() + return type(component)(**values) + def change_uuid(self, component: Component) -> None: """Change the component UUID.""" raise NotImplementedError("change_component_uuid") @@ -267,12 +271,12 @@ def update( return def _add(self, component: Component, deserialization_in_progress: bool) -> None: - raise_if_attached(component) + self.raise_if_attached(component) if not deserialization_in_progress: # TODO: Do we want any checks during deserialization? User could change the JSON. # We could prevent the user from changing the JSON with a checksum. self._check_component_addition(component) - component.check_component_addition(self._uuid) + component.check_component_addition() if component.uuid in self._components_by_uuid: msg = f"{component.label} with UUID={component.uuid} is already stored" raise ISAlreadyAttached(msg) @@ -287,7 +291,6 @@ def _add(self, component: Component, deserialization_in_progress: bool) -> None: self._components[cls][name].append(component) self._components_by_uuid[component.uuid] = component - component.system_uuid = self._uuid logger.debug("Added {} to the system", component.label) def _check_component_addition(self, component: Component) -> None: @@ -308,7 +311,7 @@ def _check_component_addition(self, component: Component) -> None: def _handle_composed_component(self, component: Component) -> None: """Do what's needed for a composed component depending on system settings: nothing, add, or raise an exception.""" - if component.system_uuid is not None: + if component.uuid in self._components_by_uuid: return if self._auto_add_composed_components: @@ -320,3 +323,21 @@ def _handle_composed_component(self, component: Component) -> None: f"its composed component {component.label} is not already attached." ) raise ISOperationNotAllowed(msg) + + def raise_if_attached(self, component: Component): + """Raise an exception if this component is attached to a system.""" + if component.uuid in self._components_by_uuid: + msg = f"{component.label} is already attached to the system" + raise ISAlreadyAttached(msg) + + def raise_if_not_attached(self, component: Component): + """Raise an exception if this component is not attached to a system. + + Parameters + ---------- + system_uuid : UUID + The component must be attached to the system with this UUID. + """ + if component.uuid not in self._components_by_uuid: + msg = f"{component.label} is not attached to the system" + raise ISNotStored(msg) diff --git a/src/infrasys/system.py b/src/infrasys/system.py index 61f2ded..146e954 100644 --- a/src/infrasys/system.py +++ b/src/infrasys/system.py @@ -15,12 +15,10 @@ from infrasys.exceptions import ( ISFileExists, ISConflictingArguments, - ISConflictingSystem, ) from infrasys.models import make_label from infrasys.component import ( Component, - raise_if_not_attached, ) from infrasys.component_manager import ComponentManager from infrasys.serialization import ( @@ -349,8 +347,14 @@ def copy_component( name: str | None = None, attach: bool = False, ) -> Any: - """Create a copy of the component. Time series data is excluded. The new component will - have a different UUID from the original. + """Create a copy of the component. Time series data is excluded. + + - The new component will have a different UUID than the original. + - The copied component will have shared references to any composed components. + + The intention of this method is to provide a way to create variants of a component that + will be added to the same system. Please refer to :`deepcopy_component`: to create + copies that are suitable for addition to a different system. Parameters ---------- @@ -366,9 +370,38 @@ def copy_component( >>> gen1 = system.get_component(Generator, "gen1") >>> gen2 = system.copy_component(gen, name="gen2") >>> gen3 = system.copy_component(gen, name="gen3", attach=True) + + See Also + -------- + deepcopy_component """ return self._component_mgr.copy(component, name=name, attach=attach) + def deepcopy_component(self, component: Component) -> Any: + """Create a deep copy of the component and all composed components. All attributes, + including names and UUIDs, will be identical to the original. Unlike + :meth:`copy_component`, there will be no shared references to composed components. + + The intention of this method is to provide a way to create variants of a component that + will be added to a different system. Please refer to :`copy_component`: to create + copies that are suitable for addition to the same system. + + Parameters + ---------- + component : Component + Source component + + Examples + -------- + >>> gen1 = system.get_component(Generator, "gen1") + >>> gen2 = system.deepcopy_component(gen) + + See Also + -------- + copy_component + """ + return self._component_mgr.deepcopy(component) + def get_component(self, component_type: Type[Component], name: str) -> Any: """Return the component with the passed type and name. @@ -523,7 +556,7 @@ def remove_component(self, component: Component) -> Any: >>> gen = system.get_component(Generator, "gen1") >>> system.remove_component(gen) """ - raise_if_not_attached(component, self.uuid) + self._component_mgr.raise_if_not_attached(component) if self.has_time_series(component): for metadata in self._time_series_mgr.list_time_series_metadata(component): self.remove_time_series( @@ -1012,13 +1045,6 @@ def _try_deserialize_component( metadata = SerializedTypeMetadata(**component[TYPE_METADATA]) component_type = cached_types.get_type(metadata.fields) - system_uuid = values.pop("system_uuid") - if str(self.uuid) != system_uuid: - msg = ( - "component has a system_uuid that conflicts with the system: " - f"{values} component's system_uuid={system_uuid} system={self.uuid}" - ) - raise ISConflictingSystem(msg) actual_component = component_type(**values) self._components.add(actual_component, deserialization_in_progress=True) return actual_component diff --git a/tests/models/simple_system.py b/tests/models/simple_system.py index adfa94c..dab59c8 100644 --- a/tests/models/simple_system.py +++ b/tests/models/simple_system.py @@ -1,9 +1,7 @@ """Defines models that can be used for testing the package.""" from typing import Any -from uuid import UUID -from infrasys.exceptions import ISOperationNotAllowed from infrasys import Component, Location, System @@ -13,14 +11,6 @@ class SimpleBus(Component): voltage: float coordinates: Location | None = None - def check_component_addition(self, system_uuid: UUID) -> None: - if self.coordinates is not None and not self.coordinates.is_attached( - system_uuid=system_uuid - ): - # Other packages might want to auto-add in the System class. - msg = f"{self.label} has coordinates that are not attached to the system" - raise ISOperationNotAllowed(msg) - @classmethod def example(cls) -> "SimpleBus": return SimpleBus( diff --git a/tests/test_system.py b/tests/test_system.py index 8db77c1..9429a38 100644 --- a/tests/test_system.py +++ b/tests/test_system.py @@ -428,15 +428,25 @@ def test_copy_component(simple_system_with_time_series: SimpleSystem): gen2 = system.copy_component(gen1) assert gen2.uuid != gen1.uuid assert gen2.name == gen1.name - assert gen2.system_uuid is None + assert gen2.bus is gen1.bus gen3 = system.copy_component(gen1, name="gen3") assert gen3.name == "gen3" - assert gen2.system_uuid is None gen4 = system.copy_component(gen1, name="gen4", attach=True) assert gen4.name == "gen4" - assert gen4.system_uuid == gen1.system_uuid + + +def test_deepcopy_component(simple_system_with_time_series: SimpleSystem): + system = simple_system_with_time_series + gen1 = system.get_component(SimpleGenerator, "test-gen") + subsystem = SimpleSubsystem(name="subsystem1", generators=[gen1]) + system.add_component(subsystem) + gen2 = system.deepcopy_component(gen1) + assert gen2.name == gen1.name + assert gen2.uuid == gen1.uuid + assert gen2.bus.uuid == gen1.bus.uuid + assert gen2.bus is not gen1.bus @pytest.mark.parametrize("in_memory", [True, False]) @@ -463,7 +473,6 @@ def test_remove_component(in_memory): system.remove_component_by_uuid(gen2.uuid) assert not system.has_time_series(gen2) - assert gen2.system_uuid is None with pytest.raises(ISNotStored): system.remove_component(gen2) @@ -501,7 +510,6 @@ def test_system_to_dict(): assert len(component_dict) == 3 # 3 generators assert component_dict[0]["bus"] == gen1.bus.label - exclude = {"system_uuid"} variable_name = "active_power" length = 8784 data = range(length) @@ -509,9 +517,8 @@ def test_system_to_dict(): resolution = timedelta(hours=1) ts = SingleTimeSeries.from_array(data, variable_name, start, resolution) system.add_time_series(ts, gen1) - component_dicts = list(system.to_records(SimpleGenerator, exclude=exclude)) + component_dicts = list(system.to_records(SimpleGenerator)) assert len(component_dicts) == 3 # 3 generators - assert "system_uuid" not in component_dicts[0] def test_time_series_metadata_sql():