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.
- julee_c4/__init__.py +26 -0
- julee_c4/domain/__init__.py +0 -0
- julee_c4/domain/models/__init__.py +11 -0
- julee_c4/domain/models/component.py +101 -0
- julee_c4/domain/models/container.py +121 -0
- julee_c4/domain/models/deployment_node.py +175 -0
- julee_c4/domain/models/diagrams.py +106 -0
- julee_c4/domain/models/dynamic_step.py +116 -0
- julee_c4/domain/models/relationship.py +127 -0
- julee_c4/domain/models/software_system.py +93 -0
- julee_c4/domain/repositories/__init__.py +4 -0
- julee_c4/domain/repositories/component.py +79 -0
- julee_c4/domain/repositories/container.py +93 -0
- julee_c4/domain/repositories/deployment_node.py +97 -0
- julee_c4/domain/repositories/dynamic_step.py +88 -0
- julee_c4/domain/repositories/relationship.py +124 -0
- julee_c4/domain/repositories/software_system.py +89 -0
- julee_c4/infrastructure/__init__.py +4 -0
- julee_c4/infrastructure/repositories/__init__.py +4 -0
- julee_c4/infrastructure/repositories/file/__init__.py +5 -0
- julee_c4/infrastructure/repositories/file/component.py +80 -0
- julee_c4/infrastructure/repositories/file/container.py +90 -0
- julee_c4/infrastructure/repositories/file/deployment_node.py +93 -0
- julee_c4/infrastructure/repositories/file/dynamic_step.py +97 -0
- julee_c4/infrastructure/repositories/file/relationship.py +128 -0
- julee_c4/infrastructure/repositories/file/software_system.py +92 -0
- julee_c4/infrastructure/repositories/memory/__init__.py +5 -0
- julee_c4/infrastructure/repositories/memory/component.py +87 -0
- julee_c4/infrastructure/repositories/memory/container.py +97 -0
- julee_c4/infrastructure/repositories/memory/deployment_node.py +100 -0
- julee_c4/infrastructure/repositories/memory/dynamic_step.py +104 -0
- julee_c4/infrastructure/repositories/memory/relationship.py +142 -0
- julee_c4/infrastructure/repositories/memory/software_system.py +97 -0
- julee_c4/parsers/__init__.py +4 -0
- julee_c4/parsers/rst.py +816 -0
- julee_c4/py.typed +0 -0
- julee_c4/serializers/__init__.py +4 -0
- julee_c4/serializers/plantuml.py +445 -0
- julee_c4/serializers/rst.py +272 -0
- julee_c4/tests/__init__.py +1 -0
- julee_c4/tests/conftest.py +6 -0
- julee_c4/tests/entities/__init__.py +1 -0
- julee_c4/tests/entities/test_component.py +180 -0
- julee_c4/tests/entities/test_container.py +192 -0
- julee_c4/tests/entities/test_deployment_node.py +239 -0
- julee_c4/tests/entities/test_dynamic_step.py +249 -0
- julee_c4/tests/entities/test_relationship.py +246 -0
- julee_c4/tests/entities/test_software_system.py +176 -0
- julee_c4/tests/parsers/__init__.py +1 -0
- julee_c4/tests/parsers/test_rst.py +465 -0
- julee_c4/tests/repositories/__init__.py +1 -0
- julee_c4/tests/repositories/test_component.py +202 -0
- julee_c4/tests/repositories/test_container.py +230 -0
- julee_c4/tests/repositories/test_deployment_node.py +221 -0
- julee_c4/tests/repositories/test_dynamic_step.py +246 -0
- julee_c4/tests/repositories/test_relationship.py +244 -0
- julee_c4/tests/repositories/test_software_system.py +233 -0
- julee_c4/tests/serializers/__init__.py +1 -0
- julee_c4/tests/serializers/test_plantuml.py +93 -0
- julee_c4/tests/use_cases/__init__.py +1 -0
- julee_c4/tests/use_cases/test_component_crud.py +346 -0
- julee_c4/tests/use_cases/test_container_crud.py +326 -0
- julee_c4/tests/use_cases/test_deployment_node_crud.py +366 -0
- julee_c4/tests/use_cases/test_diagram_use_cases.py +758 -0
- julee_c4/tests/use_cases/test_dynamic_step_crud.py +409 -0
- julee_c4/tests/use_cases/test_relationship_crud.py +378 -0
- julee_c4/tests/use_cases/test_software_system_crud.py +323 -0
- julee_c4/usecases/__init__.py +4 -0
- julee_c4/usecases/crud.py +208 -0
- julee_c4/usecases/crud_component.py +156 -0
- julee_c4/usecases/crud_container.py +153 -0
- julee_c4/usecases/crud_deployment_node.py +170 -0
- julee_c4/usecases/crud_dynamic_step.py +173 -0
- julee_c4/usecases/crud_relationship.py +164 -0
- julee_c4/usecases/crud_software_system.py +167 -0
- julee_c4/usecases/delete.py +168 -0
- julee_c4/usecases/diagrams/__init__.py +4 -0
- julee_c4/usecases/diagrams/component_diagram.py +141 -0
- julee_c4/usecases/diagrams/container_diagram.py +118 -0
- julee_c4/usecases/diagrams/deployment_diagram.py +100 -0
- julee_c4/usecases/diagrams/dynamic_diagram.py +129 -0
- julee_c4/usecases/diagrams/system_context.py +106 -0
- julee_c4/usecases/diagrams/system_landscape.py +95 -0
- julee_c4-0.1.0.dist-info/METADATA +49 -0
- julee_c4-0.1.0.dist-info/RECORD +89 -0
- julee_c4-0.1.0.dist-info/WHEEL +5 -0
- julee_c4-0.1.0.dist-info/entry_points.txt +2 -0
- julee_c4-0.1.0.dist-info/licenses/LICENSE +674 -0
- 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
|
+
)
|