Skip to content

Commit 32823b9

Browse files
yt-msMidnighter
authored andcommitted
feat: support serialisation of deployment nodes within deployment nodes
1 parent 1732651 commit 32823b9

File tree

2 files changed

+181
-12
lines changed

2 files changed

+181
-12
lines changed

src/structurizr/model/deployment_node.py

Lines changed: 98 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,50 +15,136 @@
1515

1616
"""Provide a deployment node model."""
1717

18+
from typing import TYPE_CHECKING, Iterable, List
1819

19-
from typing import Iterable
20+
from pydantic import Field
2021

2122
from .deployment_element import DeploymentElement, DeploymentElementIO
2223

2324

25+
if TYPE_CHECKING:
26+
from .model import Model
27+
2428
__all__ = ("DeploymentNode", "DeploymentNodeIO")
2529

2630

2731
class DeploymentNodeIO(DeploymentElementIO):
28-
"""Represent a deployment node."""
32+
"""
33+
Represent a deployment node.
34+
35+
Attributes:
36+
id: The ID of this deployment node in the model.
37+
name: The name of this node.
38+
description: A short description of this node.
39+
environment (str):
40+
tags: A comma separated list of tags associated with this node.
41+
children: The deployment nodes that are direct children of this node.
42+
properties: A set of arbitrary name-value properties.
43+
relationships: The set of relationships from this node to
44+
other elements.
45+
url (pydantic.HttpUrl):
46+
"""
47+
48+
class Config:
49+
"""Pydantic configuration for DeploymentNodeIO."""
50+
51+
# Prevent infinite recursion for `children` - see
52+
# https://github.com/samuelcolvin/pydantic/issues/524
53+
validate_assignment = "limited"
2954

30-
parent: "DeploymentNodeIO"
3155
technology: str = ""
3256
instances: int = 1
57+
children: List["DeploymentNodeIO"] = Field(default=())
58+
59+
60+
DeploymentNodeIO.update_forward_refs()
3361

3462

3563
class DeploymentNode(DeploymentElement):
36-
"""Represent a deployment node."""
64+
"""
65+
Represent a deployment node.
66+
67+
Attributes:
68+
id: The ID of this deployment node in the model.
69+
name: The name of this node.
70+
description: A short description of this node.
71+
environment (str):
72+
tags: A comma separated list of tags associated with this node.
73+
children: The deployment nodes that are direct children of this node.
74+
properties: A set of arbitrary name-value properties.
75+
relationships: The set of relationships from this node to
76+
other elements.
77+
url (pydantic.HttpUrl):
78+
"""
3779

3880
def __init__(
3981
self,
4082
*,
41-
parent: "DeploymentNode",
83+
parent: "DeploymentNode" = None,
4284
technology: str = "",
4385
instances: int = 1,
4486
children: Iterable["DeploymentNode"] = (),
4587
container_instances: Iterable["DeploymentNode"] = (),
46-
**kwargs
88+
**kwargs,
4789
) -> None:
4890
"""Initialize a deployment node."""
4991
super().__init__(**kwargs)
5092
self.parent = parent
5193
self.technology = technology
5294
self.instances = instances
53-
self.children = set(children)
54-
self.container_instances = set(container_instances)
95+
self._children = set(children)
96+
self._container_instances = set(container_instances)
97+
98+
@property
99+
def children(self) -> Iterable["DeploymentNode"]:
100+
"""Return read-only list of child nodes."""
101+
return list(self._children)
102+
103+
def add_deployment_node(self, **kwargs) -> "DeploymentNode":
104+
"""Add a new child deployment node to this node."""
105+
node = DeploymentNode(**kwargs)
106+
self += node
107+
return node
108+
109+
def __iadd__(self, node: "DeploymentNode") -> "DeploymentNode":
110+
"""Add a newly constructed chile deployment node to this node."""
111+
if node in self._children:
112+
return self
113+
114+
# if self.get_component_with_name(component.name):
115+
# raise ValueError(
116+
# f"Component with name {component.name} already exists in {self}."
117+
# )
118+
119+
if node.parent is None:
120+
node.parent = self
121+
elif node.parent is not self:
122+
raise ValueError(
123+
f"DeploymentNode with name {node.name} already has parent "
124+
f"{node.parent}. Cannot add to {self}."
125+
)
126+
self._children.add(node)
127+
model = self.model
128+
model += node
129+
return self
55130

56131
@classmethod
57-
def hydrate(cls, deployment_node_io: DeploymentNodeIO) -> "DeploymentNode":
132+
def hydrate(
133+
cls,
134+
deployment_node_io: DeploymentNodeIO,
135+
model: "Model",
136+
parent: "DeploymentNode" = None,
137+
) -> "DeploymentNode":
58138
"""Hydrate a new DeploymentNode instance from its IO."""
59-
# TODO (midnighter): Initialization requires `parent`.
60-
return cls(
61-
# parent=deployment_node_io.parent,
139+
node = cls(
140+
parent=parent,
62141
name=deployment_node_io.name,
63142
description=deployment_node_io.description,
64143
)
144+
model += node
145+
146+
for child_io in deployment_node_io.children:
147+
child_node = DeploymentNode.hydrate(child_io, model, node)
148+
node += child_node
149+
150+
return node
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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 the expected behaviour of the container element."""
15+
16+
17+
import pytest
18+
19+
from structurizr.model.deployment_node import DeploymentNode, DeploymentNodeIO
20+
21+
22+
class MockModel:
23+
"""Implement a mock model for testing."""
24+
25+
def __init__(self):
26+
"""Initialize the mock, creating an empty node for tests."""
27+
self.empty_node = DeploymentNode(name="Empty")
28+
self.empty_node.set_model(self)
29+
30+
def __iadd__(self, node):
31+
"""Simulate the model assigning IDs to new elements."""
32+
if not node.id:
33+
node.id = "id"
34+
node.set_model(self)
35+
return self
36+
37+
38+
@pytest.fixture(scope="function")
39+
def model_with_node() -> MockModel:
40+
"""Provide an new empty model on demand for test cases to use."""
41+
return MockModel()
42+
43+
44+
@pytest.mark.parametrize(
45+
"attributes",
46+
[
47+
pytest.param({}, marks=pytest.mark.raises(exception=TypeError)),
48+
{"name": "Node1", "technology": "tech1"},
49+
],
50+
)
51+
def test_deployment_node_init(attributes):
52+
"""Expect proper initialization from arguments."""
53+
node = DeploymentNode(**attributes)
54+
for attr, expected in attributes.items():
55+
assert getattr(node, attr) == expected
56+
57+
58+
def test_deployment_node_adds_to_children(model_with_node):
59+
"""Test adding a child node to an existing one."""
60+
top_node = model_with_node.empty_node
61+
child = top_node.add_deployment_node(name="child")
62+
63+
assert child is not None
64+
assert child.model == model_with_node
65+
assert child.parent == top_node
66+
assert child in top_node.children
67+
68+
69+
def test_deployment_node_serialization_of_recursive_nodes(model_with_node):
70+
"""Check that nodes within nodes are handled with (de)serialisation."""
71+
top_node = model_with_node.empty_node
72+
top_node.add_deployment_node(name="child")
73+
74+
io = DeploymentNodeIO.from_orm(top_node)
75+
assert len(io.children) == 1
76+
assert io.children[0].name == "child"
77+
78+
new_top_node = DeploymentNode.hydrate(io, model_with_node)
79+
assert len(new_top_node.children) == 1
80+
new_child = new_top_node.children[0]
81+
assert new_child.name == "child"
82+
assert new_child.parent is new_top_node
83+
assert new_child.model is model_with_node

0 commit comments

Comments
 (0)