Skip to content

Commit 89556e9

Browse files
yt-msMidnighter
authored andcommitted
feat: deserialisation of DeploymentNodes
1 parent aa6875b commit 89556e9

File tree

7 files changed

+121
-29
lines changed

7 files changed

+121
-29
lines changed

src/structurizr/model/container.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,10 @@ def hydrate(
114114
software_system: "SoftwareSystem",
115115
model: "Model",
116116
) -> "Container":
117-
"""Hydrate a new Container instance from its IO."""
117+
"""Hydrate a new Container instance from its IO.
118+
119+
This will also automatically register with the model.
120+
"""
118121
container = cls(
119122
**cls.hydrate_arguments(container_io),
120123
parent=software_system,

src/structurizr/model/deployment_element.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818

1919
from abc import ABC
20+
from typing import Optional
2021

2122
from .element import Element, ElementIO
2223

@@ -36,7 +37,7 @@ class DeploymentElementIO(ElementIO, ABC):
3637
3738
"""
3839

39-
environment: str = DEFAULT_DEPLOYMENT_ENVIRONMENT
40+
environment: Optional[str] = DEFAULT_DEPLOYMENT_ENVIRONMENT
4041

4142

4243
class DeploymentElement(Element, ABC):
@@ -54,3 +55,11 @@ def __init__(
5455
"""Initialize a deployment element."""
5556
super().__init__(**kwargs)
5657
self.environment = environment
58+
59+
@classmethod
60+
def hydrate_arguments(cls, deployment_element_io: DeploymentElementIO) -> dict:
61+
"""Build constructor arguments from IO."""
62+
return {
63+
**super().hydrate_arguments(deployment_element_io),
64+
"environment": deployment_element_io.environment,
65+
}

src/structurizr/model/deployment_node.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -111,16 +111,17 @@ def __iadd__(self, node: "DeploymentNode") -> "DeploymentNode":
111111
if node in self._children:
112112
return self
113113

114-
# if self.get_component_with_name(component.name):
115-
# raise ValueError(
116-
# f"Component with name {component.name} already exists in {self}."
117-
# )
114+
if any(node.name == child.name for child in self.children):
115+
raise ValueError(
116+
f"A deployment node with the name '{node.name}' already "
117+
f"exists in node '{self.name}'."
118+
)
118119

119120
if node.parent is None:
120121
node.parent = self
121122
elif node.parent is not self:
122123
raise ValueError(
123-
f"DeploymentNode with name {node.name} already has parent "
124+
f"DeploymentNode with name '{node.name}' already has parent "
124125
f"{node.parent}. Cannot add to {self}."
125126
)
126127
self._children.add(node)
@@ -135,16 +136,18 @@ def hydrate(
135136
model: "Model",
136137
parent: "DeploymentNode" = None,
137138
) -> "DeploymentNode":
138-
"""Hydrate a new DeploymentNode instance from its IO."""
139+
"""Hydrate a new DeploymentNode instance from its IO.
140+
141+
This will also automatically register with the model.
142+
"""
139143
node = cls(
140144
parent=parent,
141-
name=deployment_node_io.name,
142-
description=deployment_node_io.description,
145+
**cls.hydrate_arguments(deployment_node_io),
143146
)
144147
model += node
145148

146149
for child_io in deployment_node_io.children:
147-
child_node = DeploymentNode.hydrate(child_io, model, node)
150+
child_node = DeploymentNode.hydrate(child_io, model=model, parent=node)
148151
node += child_node
149152

150153
return node

src/structurizr/model/model.py

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,11 @@
2121

2222
from pydantic import Field
2323

24-
from structurizr.model.deployment_node import DeploymentNode
25-
2624
from ..abstract_base import AbstractBase
2725
from ..base_model import BaseModel
2826
from .container import Container
2927
from .container_instance import ContainerInstance
28+
from .deployment_node import DeploymentNode, DeploymentNodeIO
3029
from .element import Element
3130
from .enterprise import Enterprise, EnterpriseIO
3231
from .implied_relationship_strategies import (
@@ -69,12 +68,11 @@ class ModelIO(BaseModel):
6968
alias="softwareSystems",
7069
description="The set of software systems belonging to this model.",
7170
)
72-
# TODO:
73-
# deployment_nodes: List[DeploymentNodeIO] = Field(
74-
# default=(),
75-
# alias="deploymentNodes",
76-
# description="The set of deployment nodes belonging to this model.",
77-
# )
71+
deployment_nodes: List[DeploymentNodeIO] = Field(
72+
default=(),
73+
alias="deploymentNodes",
74+
description="The set of top-level deployment nodes belonging to this model.",
75+
)
7876

7977

8078
class Model(AbstractBase):
@@ -156,9 +154,10 @@ def hydrate(cls, model_io: ModelIO) -> "Model":
156154
for software_system_io in model_io.software_systems:
157155
model += SoftwareSystem.hydrate(software_system_io, model=model)
158156

159-
# for deployment_node_io in model_io.deployment_nodes:
160-
# deployment_node = DeploymentNode.hydrate(deployment_node_io)
161-
# model.add_deployment_node(deployment_node=deployment_node)
157+
for deployment_node_io in model_io.deployment_nodes:
158+
DeploymentNode.hydrate(
159+
deployment_node_io, model=model
160+
) # Auto-registers with the model
162161

163162
for element in model.get_elements():
164163
for relationship in element.relationships:
@@ -215,6 +214,8 @@ def add_software_system(self, **kwargs) -> SoftwareSystem:
215214

216215
def __iadd__(self, element: Element) -> "Model":
217216
"""Add a newly constructed element to the model."""
217+
if element in self.get_elements():
218+
return self
218219
if isinstance(element, Person):
219220
if any(element.name == p.name for p in self.people):
220221
raise ValueError(
@@ -229,12 +230,12 @@ def __iadd__(self, element: Element) -> "Model":
229230
)
230231
elif isinstance(element, DeploymentNode):
231232
if any(
232-
element.name == d.name and isinstance(d, DeploymentNode)
233-
for d in self.get_elements()
233+
element.name == d.name and element.environment == d.environment
234+
for d in self.deployment_nodes
234235
):
235236
raise ValueError(
236237
f"A deployment node with the name '{element.name}' already "
237-
f"exists in the model."
238+
f"exists in environment '{element.environment}' of the model."
238239
)
239240
elif element.parent is None:
240241
raise ValueError(
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# https://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
13+
"""
14+
Ensure that relationships in Elements and in the Model are consistent.
15+
16+
See https://github.com/Midnighter/structurizr-python/issues/31.
17+
"""
18+
19+
from pathlib import Path
20+
21+
import pytest
22+
23+
from structurizr import Workspace
24+
25+
26+
DEFINITIONS = Path(__file__).parent / "data" / "workspace_definition"
27+
28+
29+
@pytest.mark.parametrize(
30+
"filename",
31+
["BigBank.json"],
32+
)
33+
def test_model_deserialises_deployment_nodes(filename: str):
34+
"""Ensure deserialisaton of deployment nodes works."""
35+
path = DEFINITIONS / filename
36+
workspace = Workspace.load(path)
37+
model = workspace.model
38+
39+
db_server = model.get_element("59")
40+
assert db_server.name == "Docker Container - Database Server"
41+
assert db_server is not None
42+
assert db_server.model is model
43+
assert db_server.parent.name == "Developer Laptop"
44+
assert db_server.parent.parent is None

tests/unit/model/test_deployment_node.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,33 @@ def test_deployment_node_adds_to_children(model_with_node):
6666
assert child in top_node.children
6767

6868

69+
def test_deployment_node_adding_same_child_twice_is_ok(model_with_node):
70+
"""Test adding the same child twice is just ignored."""
71+
top_node = model_with_node.empty_node
72+
child = top_node.add_deployment_node(name="child")
73+
top_node += child
74+
assert len(top_node.children) == 1
75+
76+
77+
def test_deployment_node_add_child_with_existing_parent(model_with_node: MockModel):
78+
"""Check that adding a node with an existing parent fails."""
79+
top_node = model_with_node.empty_node
80+
other_parent = DeploymentNode(name="OtherParent")
81+
with pytest.raises(ValueError, match="DeploymentNode .* already has parent."):
82+
top_node += DeploymentNode(name="child", parent=other_parent)
83+
84+
85+
def test_deployment_node_cant_have_two_children_with_the_same_name(model_with_node):
86+
"""Make sure you can't have two children with the same name in the same node."""
87+
top_node = model_with_node.empty_node
88+
top_node.add_deployment_node(name="child")
89+
with pytest.raises(
90+
ValueError,
91+
match="A deployment node with the name 'child' already exists in node 'Empty'.",
92+
):
93+
top_node.add_deployment_node(name="child")
94+
95+
6996
def test_deployment_node_serialization_of_recursive_nodes(model_with_node):
7097
"""Check that nodes within nodes are handled with (de)serialisation."""
7198
top_node = model_with_node.empty_node

tests/unit/model/test_model.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,18 @@ def test_model_add_top_level_deployment_node(empty_model: Model):
8888

8989

9090
def test_model_cant_add_two_deployment_nodes_with_same_name(empty_model: Model):
91-
"""Make sure that deployment nodes (at any level) can't share a name."""
92-
node = empty_model.add_deployment_node(name="node1")
91+
"""Make sure that deployment nodes at the top level can't have the same name.
92+
93+
Unless they're in different environments.
94+
"""
95+
empty_model.add_deployment_node(name="node1", environment="Live")
96+
empty_model.add_deployment_node(name="node1", environment="Dev") # Different env
9397
with pytest.raises(
9498
ValueError,
95-
match="A deployment node with the name 'node1' already exists in the model.",
99+
match="A deployment node with the name 'node1' already "
100+
"exists in environment 'Live' of the model.",
96101
):
97-
node.add_deployment_node(name="node1")
102+
empty_model.add_deployment_node(name="node1", environment="Live")
98103

99104

100105
def test_model_add_lower_level_deployment_node(empty_model: Model):

0 commit comments

Comments
 (0)