julee-c4 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. julee_c4/__init__.py +26 -0
  2. julee_c4/domain/__init__.py +0 -0
  3. julee_c4/domain/models/__init__.py +11 -0
  4. julee_c4/domain/models/component.py +101 -0
  5. julee_c4/domain/models/container.py +121 -0
  6. julee_c4/domain/models/deployment_node.py +175 -0
  7. julee_c4/domain/models/diagrams.py +106 -0
  8. julee_c4/domain/models/dynamic_step.py +116 -0
  9. julee_c4/domain/models/relationship.py +127 -0
  10. julee_c4/domain/models/software_system.py +93 -0
  11. julee_c4/domain/repositories/__init__.py +4 -0
  12. julee_c4/domain/repositories/component.py +79 -0
  13. julee_c4/domain/repositories/container.py +93 -0
  14. julee_c4/domain/repositories/deployment_node.py +97 -0
  15. julee_c4/domain/repositories/dynamic_step.py +88 -0
  16. julee_c4/domain/repositories/relationship.py +124 -0
  17. julee_c4/domain/repositories/software_system.py +89 -0
  18. julee_c4/infrastructure/__init__.py +4 -0
  19. julee_c4/infrastructure/repositories/__init__.py +4 -0
  20. julee_c4/infrastructure/repositories/file/__init__.py +5 -0
  21. julee_c4/infrastructure/repositories/file/component.py +80 -0
  22. julee_c4/infrastructure/repositories/file/container.py +90 -0
  23. julee_c4/infrastructure/repositories/file/deployment_node.py +93 -0
  24. julee_c4/infrastructure/repositories/file/dynamic_step.py +97 -0
  25. julee_c4/infrastructure/repositories/file/relationship.py +128 -0
  26. julee_c4/infrastructure/repositories/file/software_system.py +92 -0
  27. julee_c4/infrastructure/repositories/memory/__init__.py +5 -0
  28. julee_c4/infrastructure/repositories/memory/component.py +87 -0
  29. julee_c4/infrastructure/repositories/memory/container.py +97 -0
  30. julee_c4/infrastructure/repositories/memory/deployment_node.py +100 -0
  31. julee_c4/infrastructure/repositories/memory/dynamic_step.py +104 -0
  32. julee_c4/infrastructure/repositories/memory/relationship.py +142 -0
  33. julee_c4/infrastructure/repositories/memory/software_system.py +97 -0
  34. julee_c4/parsers/__init__.py +4 -0
  35. julee_c4/parsers/rst.py +816 -0
  36. julee_c4/py.typed +0 -0
  37. julee_c4/serializers/__init__.py +4 -0
  38. julee_c4/serializers/plantuml.py +445 -0
  39. julee_c4/serializers/rst.py +272 -0
  40. julee_c4/tests/__init__.py +1 -0
  41. julee_c4/tests/conftest.py +6 -0
  42. julee_c4/tests/entities/__init__.py +1 -0
  43. julee_c4/tests/entities/test_component.py +180 -0
  44. julee_c4/tests/entities/test_container.py +192 -0
  45. julee_c4/tests/entities/test_deployment_node.py +239 -0
  46. julee_c4/tests/entities/test_dynamic_step.py +249 -0
  47. julee_c4/tests/entities/test_relationship.py +246 -0
  48. julee_c4/tests/entities/test_software_system.py +176 -0
  49. julee_c4/tests/parsers/__init__.py +1 -0
  50. julee_c4/tests/parsers/test_rst.py +465 -0
  51. julee_c4/tests/repositories/__init__.py +1 -0
  52. julee_c4/tests/repositories/test_component.py +202 -0
  53. julee_c4/tests/repositories/test_container.py +230 -0
  54. julee_c4/tests/repositories/test_deployment_node.py +221 -0
  55. julee_c4/tests/repositories/test_dynamic_step.py +246 -0
  56. julee_c4/tests/repositories/test_relationship.py +244 -0
  57. julee_c4/tests/repositories/test_software_system.py +233 -0
  58. julee_c4/tests/serializers/__init__.py +1 -0
  59. julee_c4/tests/serializers/test_plantuml.py +93 -0
  60. julee_c4/tests/use_cases/__init__.py +1 -0
  61. julee_c4/tests/use_cases/test_component_crud.py +346 -0
  62. julee_c4/tests/use_cases/test_container_crud.py +326 -0
  63. julee_c4/tests/use_cases/test_deployment_node_crud.py +366 -0
  64. julee_c4/tests/use_cases/test_diagram_use_cases.py +758 -0
  65. julee_c4/tests/use_cases/test_dynamic_step_crud.py +409 -0
  66. julee_c4/tests/use_cases/test_relationship_crud.py +378 -0
  67. julee_c4/tests/use_cases/test_software_system_crud.py +323 -0
  68. julee_c4/usecases/__init__.py +4 -0
  69. julee_c4/usecases/crud.py +208 -0
  70. julee_c4/usecases/crud_component.py +156 -0
  71. julee_c4/usecases/crud_container.py +153 -0
  72. julee_c4/usecases/crud_deployment_node.py +170 -0
  73. julee_c4/usecases/crud_dynamic_step.py +173 -0
  74. julee_c4/usecases/crud_relationship.py +164 -0
  75. julee_c4/usecases/crud_software_system.py +167 -0
  76. julee_c4/usecases/delete.py +168 -0
  77. julee_c4/usecases/diagrams/__init__.py +4 -0
  78. julee_c4/usecases/diagrams/component_diagram.py +141 -0
  79. julee_c4/usecases/diagrams/container_diagram.py +118 -0
  80. julee_c4/usecases/diagrams/deployment_diagram.py +100 -0
  81. julee_c4/usecases/diagrams/dynamic_diagram.py +129 -0
  82. julee_c4/usecases/diagrams/system_context.py +106 -0
  83. julee_c4/usecases/diagrams/system_landscape.py +95 -0
  84. julee_c4-0.1.0.dist-info/METADATA +49 -0
  85. julee_c4-0.1.0.dist-info/RECORD +89 -0
  86. julee_c4-0.1.0.dist-info/WHEEL +5 -0
  87. julee_c4-0.1.0.dist-info/entry_points.txt +2 -0
  88. julee_c4-0.1.0.dist-info/licenses/LICENSE +674 -0
  89. julee_c4-0.1.0.dist-info/top_level.txt +1 -0
julee_c4/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """C4 architecture models.
2
+
3
+ A julee kit. It holds the C4 model — software systems, containers,
4
+ components, the relationships between them, deployment nodes and dynamic
5
+ steps — and the queries that assemble those into the six C4 diagrams.
6
+
7
+ It describes how a system is built rather than what business it is in, so
8
+ it carries no domain of its own. julee-viewpoints renders what a solution
9
+ declares here into its documentation.
10
+
11
+ Install it, then adopt it::
12
+
13
+ [tool.julee]
14
+ kits = ["c4"]
15
+ """
16
+
17
+ from julee.core.entities.kit import Kit
18
+
19
+ kit = Kit(
20
+ slug="c4",
21
+ name="C4 architecture models",
22
+ package="julee_c4",
23
+ viewpoint=True,
24
+ )
25
+
26
+ __all__ = ["kit"]
File without changes
@@ -0,0 +1,11 @@
1
+ """C4 domain entities.
2
+
3
+ Import directly from submodules:
4
+ from julee_c4.domain.models.software_system import SoftwareSystem, SystemType
5
+ from julee_c4.domain.models.container import Container, ContainerType
6
+ from julee_c4.domain.models.component import Component
7
+ from julee_c4.domain.models.relationship import Relationship, ElementType
8
+ from julee_c4.domain.models.deployment_node import DeploymentNode, NodeType
9
+ from julee_c4.domain.models.dynamic_step import DynamicStep
10
+ from julee_c4.domain.models.diagrams import SystemContextDiagram, ContainerDiagram, ...
11
+ """
@@ -0,0 +1,101 @@
1
+ """Component domain model.
2
+
3
+ A grouping of related functionality within a container.
4
+ """
5
+
6
+ from julee.core.entities.entity import Entity
7
+ from julee.core.utils import normalize_name, slugify
8
+ from pydantic import Field, computed_field, field_validator
9
+
10
+
11
+ class Component(Entity):
12
+ """Component entity.
13
+
14
+ A component is a grouping of related functionality encapsulated
15
+ behind a well-defined interface. Components exist within containers
16
+ and are NOT separately deployable units.
17
+ """
18
+
19
+ slug: str
20
+ name: str
21
+ container_slug: str
22
+ system_slug: str
23
+ description: str = ""
24
+ technology: str = ""
25
+ interface: str = ""
26
+ code_path: str = ""
27
+ tags: tuple[str, ...] = Field(default_factory=tuple)
28
+ docname: str = ""
29
+
30
+ @field_validator("slug", mode="before")
31
+ @classmethod
32
+ def validate_slug(cls, v: str) -> str:
33
+ """Validate and normalize slug."""
34
+ if not v or not v.strip():
35
+ raise ValueError("slug cannot be empty")
36
+ return slugify(v.strip())
37
+
38
+ @field_validator("name", mode="before")
39
+ @classmethod
40
+ def validate_name(cls, v: str) -> str:
41
+ """Validate name is not empty."""
42
+ if not v or not v.strip():
43
+ raise ValueError("name cannot be empty")
44
+ return v.strip()
45
+
46
+ @field_validator("container_slug", mode="before")
47
+ @classmethod
48
+ def validate_container_slug(cls, v: str) -> str:
49
+ """Validate container_slug is not empty."""
50
+ if not v or not v.strip():
51
+ raise ValueError("container_slug cannot be empty")
52
+ return v.strip()
53
+
54
+ @field_validator("system_slug", mode="before")
55
+ @classmethod
56
+ def validate_system_slug(cls, v: str) -> str:
57
+ """Validate system_slug is not empty."""
58
+ if not v or not v.strip():
59
+ raise ValueError("system_slug cannot be empty")
60
+ return v.strip()
61
+
62
+ @computed_field # type: ignore[prop-decorator]
63
+ @property
64
+ def name_normalized(self) -> str:
65
+ """Normalized name for case-insensitive matching."""
66
+ return normalize_name(self.name)
67
+
68
+ @property
69
+ def qualified_slug(self) -> str:
70
+ """Fully qualified slug including container and system."""
71
+ return f"{self.system_slug}/{self.container_slug}/{self.slug}"
72
+
73
+ @property
74
+ def has_code(self) -> bool:
75
+ """Check if component has linked code."""
76
+ return bool(self.code_path)
77
+
78
+ @property
79
+ def has_interface(self) -> bool:
80
+ """Check if component has interface description."""
81
+ return bool(self.interface)
82
+
83
+ def has_tag(self, tag: str) -> bool:
84
+ """Check if component has a specific tag (case-insensitive)."""
85
+ return tag.lower() in [t.lower() for t in self.tags]
86
+
87
+ def with_tag(self, tag: str) -> "Component":
88
+ """The entity with a tag added.
89
+
90
+ An entity is immutable, so this returns a new one rather than
91
+ changing this one. A tag already present is not added twice.
92
+
93
+ Args:
94
+ tag: Tag to add
95
+
96
+ Returns:
97
+ A new Component, or this one if it already had the tag
98
+ """
99
+ if self.has_tag(tag):
100
+ return self
101
+ return self.model_copy(update={"tags": (*self.tags, tag)})
@@ -0,0 +1,121 @@
1
+ """Container domain model.
2
+
3
+ A runtime boundary - application or data store within a software system.
4
+ """
5
+
6
+ from enum import StrEnum
7
+
8
+ from julee.core.entities.entity import Entity
9
+ from julee.core.utils import normalize_name, slugify
10
+ from pydantic import Field, computed_field, field_validator
11
+
12
+
13
+ class ContainerType(StrEnum):
14
+ """Classification of containers."""
15
+
16
+ WEB_APPLICATION = "web_application"
17
+ MOBILE_APP = "mobile_app"
18
+ DESKTOP_APP = "desktop_app"
19
+ CONSOLE_APP = "console_app"
20
+ SERVERLESS_FUNCTION = "serverless_function"
21
+ DATABASE = "database"
22
+ FILE_STORAGE = "file_storage"
23
+ MESSAGE_QUEUE = "message_queue"
24
+ API = "api"
25
+ OTHER = "other"
26
+
27
+
28
+ class Container(Entity):
29
+ """Container entity.
30
+
31
+ A container is an application or data store - a runtime boundary.
32
+ Something that needs to be running for the overall system to work.
33
+
34
+ Note: This has nothing to do with Docker. The term "container" in C4
35
+ predates containerization technology.
36
+ """
37
+
38
+ slug: str
39
+ name: str
40
+ system_slug: str
41
+ description: str = ""
42
+ container_type: ContainerType = ContainerType.OTHER
43
+ technology: str = ""
44
+ url: str = ""
45
+ tags: tuple[str, ...] = Field(default_factory=tuple)
46
+ docname: str = ""
47
+
48
+ @field_validator("slug", mode="before")
49
+ @classmethod
50
+ def validate_slug(cls, v: str) -> str:
51
+ """Validate and normalize slug."""
52
+ if not v or not v.strip():
53
+ raise ValueError("slug cannot be empty")
54
+ return slugify(v.strip())
55
+
56
+ @field_validator("name", mode="before")
57
+ @classmethod
58
+ def validate_name(cls, v: str) -> str:
59
+ """Validate name is not empty."""
60
+ if not v or not v.strip():
61
+ raise ValueError("name cannot be empty")
62
+ return v.strip()
63
+
64
+ @field_validator("system_slug", mode="before")
65
+ @classmethod
66
+ def validate_system_slug(cls, v: str) -> str:
67
+ """Validate system_slug is not empty."""
68
+ if not v or not v.strip():
69
+ raise ValueError("system_slug cannot be empty")
70
+ return v.strip()
71
+
72
+ @computed_field # type: ignore[prop-decorator]
73
+ @property
74
+ def name_normalized(self) -> str:
75
+ """Normalized name for case-insensitive matching."""
76
+ return normalize_name(self.name)
77
+
78
+ @property
79
+ def qualified_slug(self) -> str:
80
+ """Fully qualified slug including system."""
81
+ return f"{self.system_slug}/{self.slug}"
82
+
83
+ @property
84
+ def is_data_store(self) -> bool:
85
+ """Check if this container stores data."""
86
+ return self.container_type in [
87
+ ContainerType.DATABASE,
88
+ ContainerType.FILE_STORAGE,
89
+ ]
90
+
91
+ @property
92
+ def is_application(self) -> bool:
93
+ """Check if this container is an application."""
94
+ return self.container_type in [
95
+ ContainerType.WEB_APPLICATION,
96
+ ContainerType.MOBILE_APP,
97
+ ContainerType.DESKTOP_APP,
98
+ ContainerType.CONSOLE_APP,
99
+ ContainerType.SERVERLESS_FUNCTION,
100
+ ContainerType.API,
101
+ ]
102
+
103
+ def has_tag(self, tag: str) -> bool:
104
+ """Check if container has a specific tag (case-insensitive)."""
105
+ return tag.lower() in [t.lower() for t in self.tags]
106
+
107
+ def with_tag(self, tag: str) -> "Container":
108
+ """The entity with a tag added.
109
+
110
+ An entity is immutable, so this returns a new one rather than
111
+ changing this one. A tag already present is not added twice.
112
+
113
+ Args:
114
+ tag: Tag to add
115
+
116
+ Returns:
117
+ A new Container, or this one if it already had the tag
118
+ """
119
+ if self.has_tag(tag):
120
+ return self
121
+ return self.model_copy(update={"tags": (*self.tags, tag)})
@@ -0,0 +1,175 @@
1
+ """DeploymentNode domain model.
2
+
3
+ Infrastructure where containers are deployed.
4
+ """
5
+
6
+ from collections.abc import Mapping
7
+ from enum import StrEnum
8
+
9
+ from julee.core.entities.entity import Entity
10
+ from julee.core.utils import slugify
11
+ from pydantic import Field, field_validator
12
+
13
+
14
+ class NodeType(StrEnum):
15
+ """Classification of deployment nodes."""
16
+
17
+ PHYSICAL_SERVER = "physical_server"
18
+ VIRTUAL_MACHINE = "virtual_machine"
19
+ CONTAINER_RUNTIME = "container_runtime" # Docker, containerd, etc.
20
+ KUBERNETES_CLUSTER = "kubernetes_cluster"
21
+ KUBERNETES_POD = "kubernetes_pod"
22
+ CLOUD_REGION = "cloud_region"
23
+ AVAILABILITY_ZONE = "availability_zone"
24
+ BROWSER = "browser"
25
+ MOBILE_DEVICE = "mobile_device"
26
+ DNS = "dns"
27
+ LOAD_BALANCER = "load_balancer"
28
+ FIREWALL = "firewall"
29
+ CDN = "cdn"
30
+ OTHER = "other"
31
+
32
+
33
+ class ContainerInstance(Entity):
34
+ """A deployed instance of a container.
35
+
36
+ Represents a container running within a deployment node.
37
+ """
38
+
39
+ container_slug: str
40
+ instance_count: int = 1
41
+ properties: Mapping[str, str] = Field(default_factory=dict)
42
+
43
+ @field_validator("container_slug", mode="before")
44
+ @classmethod
45
+ def validate_container_slug(cls, v: str) -> str:
46
+ """Validate container_slug is not empty."""
47
+ if not v or not v.strip():
48
+ raise ValueError("container_slug cannot be empty")
49
+ return v.strip()
50
+
51
+
52
+ class DeploymentNode(Entity):
53
+ """DeploymentNode entity.
54
+
55
+ Represents infrastructure where containers run - physical servers,
56
+ VMs, Docker hosts, Kubernetes clusters, execution environments, etc.
57
+
58
+ Deployment nodes can be nested to represent infrastructure hierarchy
59
+ (e.g., Cloud Region > Availability Zone > Kubernetes Cluster > Pod).
60
+ """
61
+
62
+ slug: str
63
+ name: str
64
+ environment: str = "production"
65
+ node_type: NodeType = NodeType.OTHER
66
+ description: str = ""
67
+ technology: str = ""
68
+ instances: int = 1
69
+ parent_slug: str | None = None
70
+ container_instances: tuple[ContainerInstance, ...] = Field(default_factory=tuple)
71
+ properties: Mapping[str, str] = Field(default_factory=dict)
72
+ tags: tuple[str, ...] = Field(default_factory=tuple)
73
+ docname: str = ""
74
+
75
+ @field_validator("slug", mode="before")
76
+ @classmethod
77
+ def validate_slug(cls, v: str) -> str:
78
+ """Validate and normalize slug."""
79
+ if not v or not v.strip():
80
+ raise ValueError("slug cannot be empty")
81
+ return slugify(v.strip())
82
+
83
+ @field_validator("name", mode="before")
84
+ @classmethod
85
+ def validate_name(cls, v: str) -> str:
86
+ """Validate name is not empty."""
87
+ if not v or not v.strip():
88
+ raise ValueError("name cannot be empty")
89
+ return v.strip()
90
+
91
+ @property
92
+ def has_parent(self) -> bool:
93
+ """Check if this node has a parent node."""
94
+ return self.parent_slug is not None
95
+
96
+ @property
97
+ def has_containers(self) -> bool:
98
+ """Check if this node has deployed containers."""
99
+ return len(self.container_instances) > 0
100
+
101
+ @property
102
+ def total_container_instances(self) -> int:
103
+ """Get total count of container instances."""
104
+ return sum(ci.instance_count for ci in self.container_instances)
105
+
106
+ def deploys_container(self, container_slug: str) -> bool:
107
+ """Check if a specific container is deployed here."""
108
+ return any(
109
+ ci.container_slug == container_slug for ci in self.container_instances
110
+ )
111
+
112
+ def with_container_instance(
113
+ self,
114
+ container_slug: str,
115
+ instance_count: int = 1,
116
+ properties: Mapping[str, str] | None = None,
117
+ ) -> "DeploymentNode":
118
+ """The node with a container deployed on it.
119
+
120
+ Deploying a container that is already here adds to its count and
121
+ merges its properties, rather than listing it twice. An entity is
122
+ immutable, so this returns a new node.
123
+
124
+ Args:
125
+ container_slug: Container being deployed
126
+ instance_count: How many of it
127
+ properties: Deployment properties, merged with any already set
128
+
129
+ Returns:
130
+ A new DeploymentNode
131
+ """
132
+ instances = []
133
+ found = False
134
+ for instance in self.container_instances:
135
+ if instance.container_slug == container_slug:
136
+ found = True
137
+ instances.append(
138
+ instance.model_copy(
139
+ update={
140
+ "instance_count": instance.instance_count + instance_count,
141
+ "properties": {**instance.properties, **(properties or {})},
142
+ }
143
+ )
144
+ )
145
+ else:
146
+ instances.append(instance)
147
+ if not found:
148
+ instances.append(
149
+ ContainerInstance(
150
+ container_slug=container_slug,
151
+ instance_count=instance_count,
152
+ properties=properties or {},
153
+ )
154
+ )
155
+ return self.model_copy(update={"container_instances": tuple(instances)})
156
+
157
+ def has_tag(self, tag: str) -> bool:
158
+ """Check if node has a specific tag (case-insensitive)."""
159
+ return tag.lower() in [t.lower() for t in self.tags]
160
+
161
+ def with_tag(self, tag: str) -> "DeploymentNode":
162
+ """The entity with a tag added.
163
+
164
+ An entity is immutable, so this returns a new one rather than
165
+ changing this one. A tag already present is not added twice.
166
+
167
+ Args:
168
+ tag: Tag to add
169
+
170
+ Returns:
171
+ A new DeploymentNode, or this one if it already had the tag
172
+ """
173
+ if self.has_tag(tag):
174
+ return self
175
+ return self.model_copy(update={"tags": (*self.tags, tag)})
@@ -0,0 +1,106 @@
1
+ """C4 Diagram domain models.
2
+
3
+ These models represent the computed data for various C4 diagram types.
4
+ They are domain objects that can be serialized to different output formats
5
+ (PlantUML, Structurizr DSL, etc.) by serializers.
6
+ """
7
+
8
+ from julee.core.entities.entity import Entity
9
+ from pydantic import Field
10
+
11
+ from .component import Component
12
+ from .container import Container
13
+ from .deployment_node import DeploymentNode
14
+ from .dynamic_step import DynamicStep
15
+ from .relationship import Relationship
16
+ from .software_system import SoftwareSystem
17
+
18
+
19
+ class PersonInfo(Entity):
20
+ """Minimal person info for diagrams.
21
+
22
+ Represents a user/actor in C4 diagrams. This is a lightweight
23
+ representation used when full Person entities aren't needed.
24
+ """
25
+
26
+ slug: str
27
+ name: str
28
+ description: str = ""
29
+
30
+
31
+ class SystemLandscapeDiagram(Entity):
32
+ """Domain model for a C4 System Landscape diagram.
33
+
34
+ Shows all software systems and their relationships at the highest level.
35
+ """
36
+
37
+ systems: tuple[SoftwareSystem, ...] = Field(default_factory=tuple)
38
+ person_slugs: tuple[str, ...] = Field(default_factory=tuple)
39
+ relationships: tuple[Relationship, ...] = Field(default_factory=tuple)
40
+
41
+
42
+ class SystemContextDiagram(Entity):
43
+ """Domain model for a C4 System Context diagram.
44
+
45
+ Shows a single system in context with its users and external systems.
46
+ """
47
+
48
+ system: SoftwareSystem
49
+ external_systems: tuple[SoftwareSystem, ...] = Field(default_factory=tuple)
50
+ person_slugs: tuple[str, ...] = Field(default_factory=tuple)
51
+ persons: tuple[PersonInfo, ...] = Field(default_factory=tuple)
52
+ relationships: tuple[Relationship, ...] = Field(default_factory=tuple)
53
+
54
+
55
+ class ContainerDiagram(Entity):
56
+ """Domain model for a C4 Container diagram.
57
+
58
+ Shows the containers within a software system.
59
+ """
60
+
61
+ system: SoftwareSystem
62
+ containers: tuple[Container, ...] = Field(default_factory=tuple)
63
+ external_systems: tuple[SoftwareSystem, ...] = Field(default_factory=tuple)
64
+ person_slugs: tuple[str, ...] = Field(default_factory=tuple)
65
+ relationships: tuple[Relationship, ...] = Field(default_factory=tuple)
66
+
67
+
68
+ class ComponentDiagram(Entity):
69
+ """Domain model for a C4 Component diagram.
70
+
71
+ Shows the components within a container.
72
+ """
73
+
74
+ system: SoftwareSystem
75
+ container: Container
76
+ components: tuple[Component, ...] = Field(default_factory=tuple)
77
+ external_containers: tuple[Container, ...] = Field(default_factory=tuple)
78
+ external_systems: tuple[SoftwareSystem, ...] = Field(default_factory=tuple)
79
+ person_slugs: tuple[str, ...] = Field(default_factory=tuple)
80
+ relationships: tuple[Relationship, ...] = Field(default_factory=tuple)
81
+
82
+
83
+ class DeploymentDiagram(Entity):
84
+ """Domain model for a C4 Deployment diagram.
85
+
86
+ Shows the deployment infrastructure for an environment.
87
+ """
88
+
89
+ environment: str
90
+ nodes: tuple[DeploymentNode, ...] = Field(default_factory=tuple)
91
+ containers: tuple[Container, ...] = Field(default_factory=tuple)
92
+ relationships: tuple[Relationship, ...] = Field(default_factory=tuple)
93
+
94
+
95
+ class DynamicDiagram(Entity):
96
+ """Domain model for a C4 Dynamic diagram.
97
+
98
+ Shows a sequence of interactions for a specific scenario.
99
+ """
100
+
101
+ sequence_name: str
102
+ steps: tuple[DynamicStep, ...] = Field(default_factory=tuple)
103
+ systems: tuple[SoftwareSystem, ...] = Field(default_factory=tuple)
104
+ containers: tuple[Container, ...] = Field(default_factory=tuple)
105
+ components: tuple[Component, ...] = Field(default_factory=tuple)
106
+ person_slugs: tuple[str, ...] = Field(default_factory=tuple)
@@ -0,0 +1,116 @@
1
+ """DynamicStep domain model.
2
+
3
+ A numbered step in a dynamic (sequence) diagram.
4
+ """
5
+
6
+ from julee.core.entities.entity import Entity
7
+ from julee.core.utils import slugify
8
+ from pydantic import field_validator
9
+
10
+ from .relationship import ElementType
11
+
12
+
13
+ class DynamicStep(Entity):
14
+ """DynamicStep entity.
15
+
16
+ Represents a numbered interaction in a dynamic diagram.
17
+ Dynamic diagrams show runtime behavior for specific scenarios
18
+ (user stories, use cases, features).
19
+ """
20
+
21
+ slug: str = ""
22
+ sequence_name: str
23
+ step_number: int
24
+ source_type: ElementType
25
+ source_slug: str
26
+ destination_type: ElementType
27
+ destination_slug: str
28
+ description: str = ""
29
+ technology: str = ""
30
+ return_value: str = ""
31
+ is_async: bool = False
32
+ docname: str = ""
33
+
34
+ def model_post_init(self, __context: object) -> None:
35
+ """Derive the slug when none was given.
36
+
37
+ A step is identified by where it sits in its sequence, which the
38
+ step already knows, so the slug is derivable rather than required.
39
+ """
40
+ if not self.slug:
41
+ object.__setattr__(
42
+ self, "slug", self.generate_slug(self.sequence_name, self.step_number)
43
+ )
44
+
45
+ @field_validator("slug", mode="before")
46
+ @classmethod
47
+ def validate_slug(cls, v: str) -> str:
48
+ """Normalise the slug; an empty one is derived after validation."""
49
+ return v.strip() if v else v
50
+
51
+ @field_validator("sequence_name", mode="before")
52
+ @classmethod
53
+ def validate_sequence_name(cls, v: str) -> str:
54
+ """Validate sequence_name is not empty."""
55
+ if not v or not v.strip():
56
+ raise ValueError("sequence_name cannot be empty")
57
+ return v.strip()
58
+
59
+ @field_validator("step_number")
60
+ @classmethod
61
+ def validate_step_number(cls, v: int) -> int:
62
+ """Validate step_number is positive."""
63
+ if v < 1:
64
+ raise ValueError("step_number must be >= 1")
65
+ return v
66
+
67
+ @field_validator("source_slug", mode="before")
68
+ @classmethod
69
+ def validate_source_slug(cls, v: str) -> str:
70
+ """Validate source_slug is not empty."""
71
+ if not v or not v.strip():
72
+ raise ValueError("source_slug cannot be empty")
73
+ return v.strip()
74
+
75
+ @field_validator("destination_slug", mode="before")
76
+ @classmethod
77
+ def validate_destination_slug(cls, v: str) -> str:
78
+ """Validate destination_slug is not empty."""
79
+ if not v or not v.strip():
80
+ raise ValueError("destination_slug cannot be empty")
81
+ return v.strip()
82
+
83
+ @property
84
+ def step_label(self) -> str:
85
+ """Get formatted step label (e.g., '1. ')."""
86
+ return f"{self.step_number}. "
87
+
88
+ @property
89
+ def full_label(self) -> str:
90
+ """Get full step label with description."""
91
+ base = f"{self.step_number}. {self.description}"
92
+ if self.technology:
93
+ base = f"{base} [{self.technology}]"
94
+ return base
95
+
96
+ @property
97
+ def is_person_interaction(self) -> bool:
98
+ """Check if this step involves a person."""
99
+ return (
100
+ self.source_type == ElementType.PERSON
101
+ or self.destination_type == ElementType.PERSON
102
+ )
103
+
104
+ @classmethod
105
+ def generate_slug(cls, sequence_name: str, step_number: int) -> str:
106
+ """Generate slug from sequence and step number."""
107
+ return f"{slugify(sequence_name)}-step-{step_number}"
108
+
109
+ def involves_element(self, element_type: ElementType, element_slug: str) -> bool:
110
+ """Check if step involves a specific element."""
111
+ return (
112
+ self.source_type == element_type and self.source_slug == element_slug
113
+ ) or (
114
+ self.destination_type == element_type
115
+ and self.destination_slug == element_slug
116
+ )