julee-hcd 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_hcd/__init__.py +28 -0
- julee_hcd/domain/__init__.py +1 -0
- julee_hcd/domain/models/__init__.py +32 -0
- julee_hcd/domain/models/app.py +229 -0
- julee_hcd/domain/models/base.py +47 -0
- julee_hcd/domain/models/contrib.py +45 -0
- julee_hcd/domain/models/epic.py +82 -0
- julee_hcd/domain/models/integration.py +226 -0
- julee_hcd/domain/models/journey.py +228 -0
- julee_hcd/domain/models/persona.py +208 -0
- julee_hcd/domain/models/story.py +140 -0
- julee_hcd/domain/repositories/__init__.py +29 -0
- julee_hcd/domain/repositories/accelerator.py +23 -0
- julee_hcd/domain/repositories/app.py +58 -0
- julee_hcd/domain/repositories/base.py +71 -0
- julee_hcd/domain/repositories/code_info.py +18 -0
- julee_hcd/domain/repositories/contrib.py +27 -0
- julee_hcd/domain/repositories/epic.py +39 -0
- julee_hcd/domain/repositories/integration.py +80 -0
- julee_hcd/domain/repositories/journey.py +83 -0
- julee_hcd/domain/repositories/persona.py +51 -0
- julee_hcd/domain/repositories/story.py +69 -0
- julee_hcd/domain/services/__init__.py +33 -0
- julee_hcd/domain/services/empty_epic_handler.py +20 -0
- julee_hcd/domain/services/empty_journey_handler.py +20 -0
- julee_hcd/domain/services/epic_created_handler.py +20 -0
- julee_hcd/domain/services/journey_created_handler.py +20 -0
- julee_hcd/domain/services/orphan_story_handler.py +20 -0
- julee_hcd/domain/services/story_created_handler.py +20 -0
- julee_hcd/domain/services/unknown_journey_epic_ref_handler.py +22 -0
- julee_hcd/domain/services/unknown_journey_persona_handler.py +20 -0
- julee_hcd/domain/services/unknown_journey_story_ref_handler.py +22 -0
- julee_hcd/domain/services/unknown_persona_handler.py +20 -0
- julee_hcd/domain/services/unknown_story_ref_handler.py +20 -0
- julee_hcd/infrastructure/__init__.py +0 -0
- julee_hcd/infrastructure/handlers/__init__.py +59 -0
- julee_hcd/infrastructure/handlers/base.py +21 -0
- julee_hcd/infrastructure/handlers/epic_orchestration.py +94 -0
- julee_hcd/infrastructure/handlers/journey_orchestration.py +132 -0
- julee_hcd/infrastructure/handlers/null_handlers.py +307 -0
- julee_hcd/infrastructure/handlers/story_orchestration.py +98 -0
- julee_hcd/infrastructure/repositories/__init__.py +0 -0
- julee_hcd/infrastructure/repositories/file/__init__.py +19 -0
- julee_hcd/infrastructure/repositories/file/app.py +114 -0
- julee_hcd/infrastructure/repositories/file/integration.py +83 -0
- julee_hcd/infrastructure/repositories/file/story.py +122 -0
- julee_hcd/infrastructure/repositories/memory/__init__.py +25 -0
- julee_hcd/infrastructure/repositories/memory/app.py +45 -0
- julee_hcd/infrastructure/repositories/memory/base.py +77 -0
- julee_hcd/infrastructure/repositories/memory/contrib.py +22 -0
- julee_hcd/infrastructure/repositories/memory/epic.py +41 -0
- julee_hcd/infrastructure/repositories/memory/integration.py +70 -0
- julee_hcd/infrastructure/repositories/memory/journey.py +81 -0
- julee_hcd/infrastructure/repositories/memory/persona.py +39 -0
- julee_hcd/infrastructure/repositories/memory/story.py +63 -0
- julee_hcd/infrastructure/repositories/rst/__init__.py +12 -0
- julee_hcd/infrastructure/repositories/rst/app.py +94 -0
- julee_hcd/infrastructure/repositories/rst/base.py +217 -0
- julee_hcd/infrastructure/repositories/rst/epic.py +131 -0
- julee_hcd/infrastructure/repositories/rst/integration.py +120 -0
- julee_hcd/infrastructure/repositories/rst/journey.py +198 -0
- julee_hcd/infrastructure/repositories/rst/persona.py +108 -0
- julee_hcd/infrastructure/repositories/rst/story.py +157 -0
- julee_hcd/parsers/__init__.py +10 -0
- julee_hcd/parsers/directive_specs.py +107 -0
- julee_hcd/parsers/docutils_parser.py +547 -0
- julee_hcd/parsers/gherkin.py +155 -0
- julee_hcd/parsers/yaml.py +185 -0
- julee_hcd/py.typed +0 -0
- julee_hcd/serializers/__init__.py +10 -0
- julee_hcd/serializers/gherkin.py +48 -0
- julee_hcd/serializers/rst.py +183 -0
- julee_hcd/serializers/yaml.py +91 -0
- julee_hcd/templates/__init__.py +5 -0
- julee_hcd/templates/accelerator.rst.j2 +18 -0
- julee_hcd/templates/app.rst.j2 +15 -0
- julee_hcd/templates/base.rst.j2 +58 -0
- julee_hcd/templates/epic.rst.j2 +10 -0
- julee_hcd/templates/integration.rst.j2 +13 -0
- julee_hcd/templates/journey.rst.j2 +29 -0
- julee_hcd/templates/persona.rst.j2 +13 -0
- julee_hcd/templates/rendering.py +51 -0
- julee_hcd/templates/story.rst.j2 +20 -0
- julee_hcd/tests/__init__.py +0 -0
- julee_hcd/tests/entities/__init__.py +0 -0
- julee_hcd/tests/entities/test_app.py +261 -0
- julee_hcd/tests/entities/test_app_interface.py +69 -0
- julee_hcd/tests/entities/test_authored.py +42 -0
- julee_hcd/tests/entities/test_contrib.py +48 -0
- julee_hcd/tests/entities/test_epic.py +177 -0
- julee_hcd/tests/entities/test_integration.py +327 -0
- julee_hcd/tests/entities/test_journey.py +258 -0
- julee_hcd/tests/entities/test_persona.py +187 -0
- julee_hcd/tests/entities/test_persona_authored.py +104 -0
- julee_hcd/tests/entities/test_story.py +216 -0
- julee_hcd/tests/repositories/__init__.py +0 -0
- julee_hcd/tests/repositories/test_app.py +218 -0
- julee_hcd/tests/repositories/test_contrib.py +30 -0
- julee_hcd/tests/repositories/test_epic.py +237 -0
- julee_hcd/tests/repositories/test_integration.py +268 -0
- julee_hcd/tests/repositories/test_journey.py +296 -0
- julee_hcd/tests/repositories/test_memory_base.py +122 -0
- julee_hcd/tests/repositories/test_persona.py +82 -0
- julee_hcd/tests/repositories/test_story.py +236 -0
- julee_hcd/tests/roundtrip/__init__.py +0 -0
- julee_hcd/tests/roundtrip/test_rst_roundtrip.py +103 -0
- julee_hcd/tests/usecases/__init__.py +0 -0
- julee_hcd/tests/usecases/test_crud.py +175 -0
- julee_hcd/tests/usecases/test_derive_personas.py +157 -0
- julee_hcd/tests/usecases/test_validate_accelerators.py +108 -0
- julee_hcd/usecases/__init__.py +6 -0
- julee_hcd/usecases/crud_app.py +168 -0
- julee_hcd/usecases/crud_contrib_module.py +168 -0
- julee_hcd/usecases/crud_epic.py +150 -0
- julee_hcd/usecases/crud_integration.py +174 -0
- julee_hcd/usecases/crud_journey.py +168 -0
- julee_hcd/usecases/crud_persona.py +171 -0
- julee_hcd/usecases/crud_story.py +168 -0
- julee_hcd/usecases/delete.py +187 -0
- julee_hcd/usecases/derive_personas.py +185 -0
- julee_hcd/usecases/epic_orchestration.py +114 -0
- julee_hcd/usecases/get_persona.py +74 -0
- julee_hcd/usecases/journey_orchestration.py +169 -0
- julee_hcd/usecases/resolve_accelerator_references.py +275 -0
- julee_hcd/usecases/resolve_app_references.py +169 -0
- julee_hcd/usecases/resolve_story_references.py +147 -0
- julee_hcd/usecases/story_orchestration.py +116 -0
- julee_hcd/usecases/suggestions.py +40 -0
- julee_hcd/usecases/validate_accelerators.py +120 -0
- julee_hcd-0.1.0.dist-info/METADATA +57 -0
- julee_hcd-0.1.0.dist-info/RECORD +135 -0
- julee_hcd-0.1.0.dist-info/WHEEL +5 -0
- julee_hcd-0.1.0.dist-info/entry_points.txt +2 -0
- julee_hcd-0.1.0.dist-info/licenses/LICENSE +674 -0
- julee_hcd-0.1.0.dist-info/top_level.txt +1 -0
julee_hcd/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Human-centred design models.
|
|
2
|
+
|
|
3
|
+
A julee kit. It holds the people a solution is for and what they are
|
|
4
|
+
trying to do — personas, journeys, epics and stories — together with the
|
|
5
|
+
applications and integrations that serve them, and the contributions and
|
|
6
|
+
accelerators a solution draws on.
|
|
7
|
+
|
|
8
|
+
It describes who a system is for rather than how it is built, so it
|
|
9
|
+
carries no technical domain of its own. julee-viewpoints renders what a
|
|
10
|
+
solution declares here into its documentation, and julee-c4 describes the
|
|
11
|
+
same solution's structure.
|
|
12
|
+
|
|
13
|
+
Install it, then adopt it::
|
|
14
|
+
|
|
15
|
+
[tool.julee]
|
|
16
|
+
kits = ["hcd"]
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from julee.core.entities.kit import Kit
|
|
20
|
+
|
|
21
|
+
kit = Kit(
|
|
22
|
+
slug="hcd",
|
|
23
|
+
name="Human-centred design models",
|
|
24
|
+
package="julee_hcd",
|
|
25
|
+
viewpoint=True,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = ["kit"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The HCD domain: its entities and the repository protocols over them."""
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Human-centred design entities.
|
|
2
|
+
|
|
3
|
+
The people a solution is for, what they are trying to do, and what serves
|
|
4
|
+
them. Code structure is described by julee.core's own entities, which this
|
|
5
|
+
kit reads rather than redefines.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .app import App, AppInterface, AppType
|
|
9
|
+
from .base import Authored
|
|
10
|
+
from .contrib import ContribModule
|
|
11
|
+
from .epic import Epic
|
|
12
|
+
from .integration import Direction, ExternalDependency, Integration
|
|
13
|
+
from .journey import Journey, JourneyStep, StepType
|
|
14
|
+
from .persona import Persona
|
|
15
|
+
from .story import Story
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"App",
|
|
19
|
+
"AppInterface",
|
|
20
|
+
"AppType",
|
|
21
|
+
"Authored",
|
|
22
|
+
"ContribModule",
|
|
23
|
+
"Direction",
|
|
24
|
+
"Epic",
|
|
25
|
+
"ExternalDependency",
|
|
26
|
+
"Integration",
|
|
27
|
+
"Journey",
|
|
28
|
+
"JourneyStep",
|
|
29
|
+
"Persona",
|
|
30
|
+
"StepType",
|
|
31
|
+
"Story",
|
|
32
|
+
]
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""App domain model.
|
|
2
|
+
|
|
3
|
+
Represents an application in the HCD documentation system.
|
|
4
|
+
Apps are defined via YAML manifests in apps/*/app.yaml.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
|
|
9
|
+
from julee.core.utils import normalize_name
|
|
10
|
+
from pydantic import Field, field_validator
|
|
11
|
+
|
|
12
|
+
from .base import Authored
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AppInterface(StrEnum):
|
|
16
|
+
"""How people reach an app, which decides how it is drawn in C4."""
|
|
17
|
+
|
|
18
|
+
SPHINX = "sphinx"
|
|
19
|
+
API = "api"
|
|
20
|
+
MCP = "mcp"
|
|
21
|
+
WEB = "web"
|
|
22
|
+
CLI = "cli"
|
|
23
|
+
UNKNOWN = "unknown"
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_string(cls, value: str) -> "AppInterface":
|
|
27
|
+
"""The interface this names, or UNKNOWN if it names none of them."""
|
|
28
|
+
try:
|
|
29
|
+
return cls(value.lower())
|
|
30
|
+
except ValueError:
|
|
31
|
+
return cls.UNKNOWN
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def user_relationship(self) -> str:
|
|
35
|
+
"""What a person does to the app, for the arrow into it."""
|
|
36
|
+
return {
|
|
37
|
+
AppInterface.SPHINX: "Writes RST",
|
|
38
|
+
AppInterface.API: "HTTP",
|
|
39
|
+
AppInterface.MCP: "MCP",
|
|
40
|
+
AppInterface.WEB: "Uses",
|
|
41
|
+
AppInterface.CLI: "Runs",
|
|
42
|
+
}.get(self, "Uses")
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def accelerator_relationship(self) -> str:
|
|
46
|
+
"""What the app does to an accelerator, for the arrow out of it."""
|
|
47
|
+
return {
|
|
48
|
+
AppInterface.SPHINX: "Documents",
|
|
49
|
+
AppInterface.API: "Exposes",
|
|
50
|
+
AppInterface.MCP: "Provides tools for",
|
|
51
|
+
AppInterface.WEB: "Presents",
|
|
52
|
+
AppInterface.CLI: "Executes",
|
|
53
|
+
}.get(self, "Uses")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AppType(StrEnum):
|
|
57
|
+
"""Application type classification."""
|
|
58
|
+
|
|
59
|
+
STAFF = "staff"
|
|
60
|
+
EXTERNAL = "external"
|
|
61
|
+
MEMBER_TOOL = "member-tool"
|
|
62
|
+
UNKNOWN = "unknown"
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_string(cls, value: str) -> "AppType":
|
|
66
|
+
"""Convert string to AppType, defaulting to UNKNOWN."""
|
|
67
|
+
try:
|
|
68
|
+
return cls(value.lower())
|
|
69
|
+
except ValueError:
|
|
70
|
+
return cls.UNKNOWN
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class App(Authored):
|
|
74
|
+
"""Application entity.
|
|
75
|
+
|
|
76
|
+
Apps represent distinct applications in the system, defined via YAML
|
|
77
|
+
manifests. They serve as containers for stories and provide organization
|
|
78
|
+
for the documentation.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
slug: str = Field(description='URL-safe identifier (e.g., "staff-portal")')
|
|
82
|
+
name: str = Field(description='Display name (e.g., "Staff Portal")')
|
|
83
|
+
app_type: AppType = Field(
|
|
84
|
+
default=AppType.UNKNOWN,
|
|
85
|
+
description="Classification (staff, external, member-tool)",
|
|
86
|
+
)
|
|
87
|
+
status: str | None = Field(
|
|
88
|
+
default=None,
|
|
89
|
+
description='Optional status indicator (e.g., "in-development", "live")',
|
|
90
|
+
)
|
|
91
|
+
description: str = Field(default="", description="Human-readable description")
|
|
92
|
+
interface: AppInterface = Field(
|
|
93
|
+
default=AppInterface.UNKNOWN,
|
|
94
|
+
description="How people reach this app, which decides how C4 draws it",
|
|
95
|
+
)
|
|
96
|
+
technology: str = Field(
|
|
97
|
+
default="",
|
|
98
|
+
description="What the app is built with; inferred from interface if blank",
|
|
99
|
+
)
|
|
100
|
+
accelerators: tuple[str, ...] = Field(
|
|
101
|
+
default_factory=tuple,
|
|
102
|
+
description="List of accelerator slugs associated with this app",
|
|
103
|
+
)
|
|
104
|
+
manifest_path: str = Field(default="", description="Path to the app.yaml file")
|
|
105
|
+
name_normalized: str = Field(default="", description="Lowercase name for matching")
|
|
106
|
+
|
|
107
|
+
@field_validator("slug", mode="before")
|
|
108
|
+
@classmethod
|
|
109
|
+
def validate_slug(cls, v: str) -> str:
|
|
110
|
+
"""Validate slug is not empty."""
|
|
111
|
+
if not v or not v.strip():
|
|
112
|
+
raise ValueError("slug cannot be empty")
|
|
113
|
+
return v.strip()
|
|
114
|
+
|
|
115
|
+
@field_validator("name", mode="before")
|
|
116
|
+
@classmethod
|
|
117
|
+
def validate_name(cls, v: str) -> str:
|
|
118
|
+
"""Validate name is not empty."""
|
|
119
|
+
if not v or not v.strip():
|
|
120
|
+
raise ValueError("name cannot be empty")
|
|
121
|
+
return v.strip()
|
|
122
|
+
|
|
123
|
+
@field_validator("name_normalized", mode="before")
|
|
124
|
+
@classmethod
|
|
125
|
+
def compute_name_normalized(cls, v: str, info) -> str:
|
|
126
|
+
"""Compute normalized name from name if not provided."""
|
|
127
|
+
if v:
|
|
128
|
+
return v
|
|
129
|
+
name = info.data.get("name", "")
|
|
130
|
+
return normalize_name(name) if name else ""
|
|
131
|
+
|
|
132
|
+
def model_post_init(self, __context) -> None:
|
|
133
|
+
"""Ensure normalized fields are computed after init."""
|
|
134
|
+
if not self.name_normalized and self.name:
|
|
135
|
+
object.__setattr__(self, "name_normalized", normalize_name(self.name))
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def from_manifest(
|
|
139
|
+
cls,
|
|
140
|
+
slug: str,
|
|
141
|
+
manifest: dict,
|
|
142
|
+
manifest_path: str,
|
|
143
|
+
) -> "App":
|
|
144
|
+
"""Create an App from a parsed YAML manifest.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
slug: App slug (usually directory name)
|
|
148
|
+
manifest: Parsed YAML content
|
|
149
|
+
manifest_path: Path to the manifest file
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
App instance
|
|
153
|
+
"""
|
|
154
|
+
name = manifest.get("name", slug.replace("-", " ").title())
|
|
155
|
+
app_type = AppType.from_string(manifest.get("type", "unknown"))
|
|
156
|
+
|
|
157
|
+
return cls(
|
|
158
|
+
slug=slug,
|
|
159
|
+
name=name,
|
|
160
|
+
app_type=app_type,
|
|
161
|
+
status=manifest.get("status"),
|
|
162
|
+
description=manifest.get("description", "").strip(),
|
|
163
|
+
accelerators=manifest.get("accelerators", []),
|
|
164
|
+
manifest_path=manifest_path,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def matches_type(self, app_type: AppType | str) -> bool:
|
|
168
|
+
"""Check if this app matches the given type.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
app_type: AppType enum or string to match
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
True if app matches the type
|
|
175
|
+
"""
|
|
176
|
+
if isinstance(app_type, str):
|
|
177
|
+
app_type = AppType.from_string(app_type)
|
|
178
|
+
return self.app_type == app_type
|
|
179
|
+
|
|
180
|
+
def matches_name(self, name: str) -> bool:
|
|
181
|
+
"""Check if this app matches the given name (case-insensitive).
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
name: Name to match against
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
True if normalized names match
|
|
188
|
+
"""
|
|
189
|
+
return self.name_normalized == normalize_name(name)
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def type_label(self) -> str:
|
|
193
|
+
"""Get human-readable type label."""
|
|
194
|
+
labels = {
|
|
195
|
+
AppType.STAFF: "Staff Application",
|
|
196
|
+
AppType.EXTERNAL: "External Application",
|
|
197
|
+
AppType.MEMBER_TOOL: "Member Tool",
|
|
198
|
+
AppType.UNKNOWN: "Unknown",
|
|
199
|
+
}
|
|
200
|
+
return labels.get(self.app_type, str(self.app_type))
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def interface_label(self) -> str:
|
|
204
|
+
"""What to call this app's interface in a diagram."""
|
|
205
|
+
return {
|
|
206
|
+
AppInterface.SPHINX: "Sphinx Extension",
|
|
207
|
+
AppInterface.API: "REST API",
|
|
208
|
+
AppInterface.MCP: "MCP Server",
|
|
209
|
+
AppInterface.WEB: "Web Application",
|
|
210
|
+
AppInterface.CLI: "CLI Tool",
|
|
211
|
+
AppInterface.UNKNOWN: "Application",
|
|
212
|
+
}.get(self.interface, str(self.interface))
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def c4_technology(self) -> str:
|
|
216
|
+
"""What to label this app's technology, guessing from its interface.
|
|
217
|
+
|
|
218
|
+
An app that says what it is built with is believed; one that does
|
|
219
|
+
not gets the usual answer for how it is reached.
|
|
220
|
+
"""
|
|
221
|
+
if self.technology:
|
|
222
|
+
return self.technology
|
|
223
|
+
return {
|
|
224
|
+
AppInterface.SPHINX: "Python/Sphinx",
|
|
225
|
+
AppInterface.API: "FastAPI",
|
|
226
|
+
AppInterface.MCP: "FastMCP",
|
|
227
|
+
AppInterface.WEB: "Python",
|
|
228
|
+
AppInterface.CLI: "Python/Click",
|
|
229
|
+
}.get(self.interface, "Python")
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""What every authored HCD entity carries besides its own content.
|
|
2
|
+
|
|
3
|
+
A persona, an epic, a journey and the rest are written in RST and read
|
|
4
|
+
back out of it, so each one remembers which document it came from and
|
|
5
|
+
the prose that surrounded it. That is the same five fields every time,
|
|
6
|
+
which is why they live here rather than being restated seven times.
|
|
7
|
+
|
|
8
|
+
An entity derived from code rather than authored — a story read out of a
|
|
9
|
+
feature file — still has them, because it may later be written back into
|
|
10
|
+
a document alongside the ones that were authored.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from julee.core.entities.entity import Entity
|
|
14
|
+
from pydantic import Field
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Authored(Entity):
|
|
18
|
+
"""An entity a person wrote into a document, and can be written back.
|
|
19
|
+
|
|
20
|
+
The RST fields exist so a round-trip is lossless: reading a document
|
|
21
|
+
into entities and writing it out again should give back the document,
|
|
22
|
+
including the parts this kit does not model.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
solution_slug: str = Field(
|
|
26
|
+
default="",
|
|
27
|
+
description=(
|
|
28
|
+
"Slug of the solution this belongs to, for a site documenting "
|
|
29
|
+
"more than one"
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
docname: str = Field(
|
|
33
|
+
default="",
|
|
34
|
+
description="RST document this was read from, for incremental builds",
|
|
35
|
+
)
|
|
36
|
+
page_title: str = Field(
|
|
37
|
+
default="",
|
|
38
|
+
description="Title of the document, when it differs from the entity's own",
|
|
39
|
+
)
|
|
40
|
+
preamble_rst: str = Field(
|
|
41
|
+
default="",
|
|
42
|
+
description="Verbatim RST that preceded the directive, kept for round-trip",
|
|
43
|
+
)
|
|
44
|
+
epilogue_rst: str = Field(
|
|
45
|
+
default="",
|
|
46
|
+
description="Verbatim RST that followed the directive, kept for round-trip",
|
|
47
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Contrib module domain model.
|
|
2
|
+
|
|
3
|
+
A contrib module is a reusable utility a solution picks up — a polling
|
|
4
|
+
workflow, an authentication helper — as against an accelerator, which is
|
|
5
|
+
a way of thinking about a problem. The difference is that a solution runs
|
|
6
|
+
a contrib module and reasons with an accelerator.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pydantic import Field, field_validator
|
|
10
|
+
|
|
11
|
+
from .base import Authored
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ContribModule(Authored):
|
|
15
|
+
"""A reusable utility a solution uses."""
|
|
16
|
+
|
|
17
|
+
slug: str = Field(description="Identifier for the contrib module")
|
|
18
|
+
name: str = Field(
|
|
19
|
+
default="", description="Display name; derived from slug if blank"
|
|
20
|
+
)
|
|
21
|
+
description: str = Field(default="", description="What the module does")
|
|
22
|
+
technology: str = Field(
|
|
23
|
+
default="Python", description="What the module is built with"
|
|
24
|
+
)
|
|
25
|
+
code_path: str = Field(
|
|
26
|
+
default="", description="Where the module's code lives in the repository"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
@field_validator("slug", mode="before")
|
|
30
|
+
@classmethod
|
|
31
|
+
def validate_slug(cls, v: str) -> str:
|
|
32
|
+
"""A contrib module without a slug cannot be referred to."""
|
|
33
|
+
if not v or not v.strip():
|
|
34
|
+
raise ValueError("slug cannot be empty")
|
|
35
|
+
return v.strip()
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def display_title(self) -> str:
|
|
39
|
+
"""What to call the module, falling back to a readable slug."""
|
|
40
|
+
return self.name if self.name else self.slug.replace("-", " ").title()
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def c4_description(self) -> str:
|
|
44
|
+
"""What to write on the module's box in a diagram."""
|
|
45
|
+
return self.description if self.description else f"{self.display_title} utility"
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Epic domain model.
|
|
2
|
+
|
|
3
|
+
Represents an epic in the HCD documentation system.
|
|
4
|
+
Epics are defined via RST directives and group related stories together.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from julee.core.utils import normalize_name
|
|
8
|
+
from pydantic import Field, field_validator
|
|
9
|
+
|
|
10
|
+
from .base import Authored
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Epic(Authored):
|
|
14
|
+
"""Epic entity.
|
|
15
|
+
|
|
16
|
+
An epic represents a collection of related stories that together
|
|
17
|
+
deliver a larger piece of functionality or business value.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
slug: str = Field(description='URL-safe identifier (e.g., "credential-creation")')
|
|
21
|
+
description: str = Field(
|
|
22
|
+
default="", description="Human-readable description of the epic"
|
|
23
|
+
)
|
|
24
|
+
story_refs: tuple[str, ...] = Field(
|
|
25
|
+
default_factory=tuple, description="List of story feature titles in this epic"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
@field_validator("slug", mode="before")
|
|
29
|
+
@classmethod
|
|
30
|
+
def validate_slug(cls, v: str) -> str:
|
|
31
|
+
"""Validate slug is not empty."""
|
|
32
|
+
if not v or not v.strip():
|
|
33
|
+
raise ValueError("slug cannot be empty")
|
|
34
|
+
return v.strip()
|
|
35
|
+
|
|
36
|
+
def with_story(self, story_title: str) -> "Epic":
|
|
37
|
+
"""The epic with a story reference added.
|
|
38
|
+
|
|
39
|
+
An entity is immutable, so this returns a new epic rather than
|
|
40
|
+
changing this one. A title already present is not added twice.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
story_title: Feature title of the story to add
|
|
44
|
+
"""
|
|
45
|
+
if story_title in self.story_refs:
|
|
46
|
+
return self
|
|
47
|
+
return self.model_copy(update={"story_refs": (*self.story_refs, story_title)})
|
|
48
|
+
|
|
49
|
+
def has_story(self, story_title: str) -> bool:
|
|
50
|
+
"""Check if this epic contains a specific story.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
story_title: Feature title to check (case-insensitive)
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
True if the story is in this epic
|
|
57
|
+
"""
|
|
58
|
+
story_normalized = normalize_name(story_title)
|
|
59
|
+
return any(normalize_name(ref) == story_normalized for ref in self.story_refs)
|
|
60
|
+
|
|
61
|
+
def get_story_refs_normalized(self) -> list[str]:
|
|
62
|
+
"""Get normalized story references.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
List of normalized story titles
|
|
66
|
+
"""
|
|
67
|
+
return [normalize_name(ref) for ref in self.story_refs]
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def display_title(self) -> str:
|
|
71
|
+
"""Get formatted title for display."""
|
|
72
|
+
return self.slug.replace("-", " ").title()
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def story_count(self) -> int:
|
|
76
|
+
"""Get number of stories in this epic."""
|
|
77
|
+
return len(self.story_refs)
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def has_stories(self) -> bool:
|
|
81
|
+
"""Check if epic has any stories."""
|
|
82
|
+
return len(self.story_refs) > 0
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Integration domain model.
|
|
2
|
+
|
|
3
|
+
Represents an integration module in the HCD documentation system.
|
|
4
|
+
Integrations are defined via YAML manifests in integrations/*/integration.yaml.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
|
|
9
|
+
from julee.core.entities.entity import Entity
|
|
10
|
+
from julee.core.utils import normalize_name
|
|
11
|
+
from pydantic import Field, field_validator
|
|
12
|
+
|
|
13
|
+
from .base import Authored
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Direction(StrEnum):
|
|
17
|
+
"""Integration data flow direction."""
|
|
18
|
+
|
|
19
|
+
INBOUND = "inbound"
|
|
20
|
+
OUTBOUND = "outbound"
|
|
21
|
+
BIDIRECTIONAL = "bidirectional"
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_string(cls, value: str) -> "Direction":
|
|
25
|
+
"""Convert string to Direction, defaulting to BIDIRECTIONAL."""
|
|
26
|
+
try:
|
|
27
|
+
return cls(value.lower())
|
|
28
|
+
except ValueError:
|
|
29
|
+
return cls.BIDIRECTIONAL
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def label(self) -> str:
|
|
33
|
+
"""Get human-readable label."""
|
|
34
|
+
labels = {
|
|
35
|
+
Direction.INBOUND: "Inbound (data source)",
|
|
36
|
+
Direction.OUTBOUND: "Outbound (data sink)",
|
|
37
|
+
Direction.BIDIRECTIONAL: "Bidirectional",
|
|
38
|
+
}
|
|
39
|
+
return labels.get(self, str(self.value))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ExternalDependency(Entity):
|
|
43
|
+
"""External system that an integration depends on."""
|
|
44
|
+
|
|
45
|
+
name: str = Field(description="Display name of the external system")
|
|
46
|
+
url: str | None = Field(
|
|
47
|
+
default=None, description="Optional URL for documentation or reference"
|
|
48
|
+
)
|
|
49
|
+
description: str = Field(default="", description="Optional brief description")
|
|
50
|
+
|
|
51
|
+
@field_validator("name", mode="before")
|
|
52
|
+
@classmethod
|
|
53
|
+
def validate_name(cls, v: str) -> str:
|
|
54
|
+
"""Validate name is not empty."""
|
|
55
|
+
if not v or not v.strip():
|
|
56
|
+
raise ValueError("name cannot be empty")
|
|
57
|
+
return v.strip()
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_dict(cls, data: dict) -> "ExternalDependency":
|
|
61
|
+
"""Create from dictionary (YAML parsed data).
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
data: Dictionary with name, url, description keys
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
ExternalDependency instance
|
|
68
|
+
"""
|
|
69
|
+
return cls(
|
|
70
|
+
name=data.get("name", ""),
|
|
71
|
+
url=data.get("url"),
|
|
72
|
+
description=data.get("description", ""),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Integration(Authored):
|
|
77
|
+
"""Integration module entity.
|
|
78
|
+
|
|
79
|
+
Integrations represent connections to external systems, defining
|
|
80
|
+
data flow direction and external dependencies.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
slug: str = Field(description='URL-safe identifier (e.g., "pilot-data-collection")')
|
|
84
|
+
module: str = Field(
|
|
85
|
+
description='Python module name (e.g., "pilot_data_collection")'
|
|
86
|
+
)
|
|
87
|
+
name: str = Field(description="Display name")
|
|
88
|
+
description: str = Field(default="", description="Human-readable description")
|
|
89
|
+
direction: Direction = Field(
|
|
90
|
+
default=Direction.BIDIRECTIONAL, description="Data flow direction"
|
|
91
|
+
)
|
|
92
|
+
depends_on: tuple[ExternalDependency, ...] = Field(
|
|
93
|
+
default_factory=tuple, description="List of external dependencies"
|
|
94
|
+
)
|
|
95
|
+
manifest_path: str = Field(
|
|
96
|
+
default="", description="Path to the integration.yaml file"
|
|
97
|
+
)
|
|
98
|
+
name_normalized: str = Field(default="", description="Lowercase name for matching")
|
|
99
|
+
|
|
100
|
+
@field_validator("slug", mode="before")
|
|
101
|
+
@classmethod
|
|
102
|
+
def validate_slug(cls, v: str) -> str:
|
|
103
|
+
"""Validate slug is not empty."""
|
|
104
|
+
if not v or not v.strip():
|
|
105
|
+
raise ValueError("slug cannot be empty")
|
|
106
|
+
return v.strip()
|
|
107
|
+
|
|
108
|
+
@field_validator("module", mode="before")
|
|
109
|
+
@classmethod
|
|
110
|
+
def validate_module(cls, v: str) -> str:
|
|
111
|
+
"""Validate module is not empty."""
|
|
112
|
+
if not v or not v.strip():
|
|
113
|
+
raise ValueError("module cannot be empty")
|
|
114
|
+
return v.strip()
|
|
115
|
+
|
|
116
|
+
@field_validator("name", mode="before")
|
|
117
|
+
@classmethod
|
|
118
|
+
def validate_name(cls, v: str) -> str:
|
|
119
|
+
"""Validate name is not empty."""
|
|
120
|
+
if not v or not v.strip():
|
|
121
|
+
raise ValueError("name cannot be empty")
|
|
122
|
+
return v.strip()
|
|
123
|
+
|
|
124
|
+
@field_validator("name_normalized", mode="before")
|
|
125
|
+
@classmethod
|
|
126
|
+
def compute_name_normalized(cls, v: str, info) -> str:
|
|
127
|
+
"""Compute normalized name from name if not provided."""
|
|
128
|
+
if v:
|
|
129
|
+
return v
|
|
130
|
+
name = info.data.get("name", "")
|
|
131
|
+
return normalize_name(name) if name else ""
|
|
132
|
+
|
|
133
|
+
def model_post_init(self, __context) -> None:
|
|
134
|
+
"""Ensure normalized fields are computed after init."""
|
|
135
|
+
if not self.name_normalized and self.name:
|
|
136
|
+
object.__setattr__(self, "name_normalized", normalize_name(self.name))
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def from_manifest(
|
|
140
|
+
cls,
|
|
141
|
+
module_name: str,
|
|
142
|
+
manifest: dict,
|
|
143
|
+
manifest_path: str,
|
|
144
|
+
) -> "Integration":
|
|
145
|
+
"""Create an Integration from a parsed YAML manifest.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
module_name: Module directory name
|
|
149
|
+
manifest: Parsed YAML content
|
|
150
|
+
manifest_path: Path to the manifest file
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Integration instance
|
|
154
|
+
"""
|
|
155
|
+
slug = manifest.get("slug", module_name.replace("_", "-"))
|
|
156
|
+
name = manifest.get("name", slug.replace("-", " ").title())
|
|
157
|
+
direction = Direction.from_string(manifest.get("direction", "bidirectional"))
|
|
158
|
+
|
|
159
|
+
# Parse depends_on list
|
|
160
|
+
depends_on_raw = manifest.get("depends_on", [])
|
|
161
|
+
depends_on = [
|
|
162
|
+
(
|
|
163
|
+
ExternalDependency.from_dict(dep)
|
|
164
|
+
if isinstance(dep, dict)
|
|
165
|
+
else ExternalDependency(name=str(dep))
|
|
166
|
+
)
|
|
167
|
+
for dep in depends_on_raw
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
return cls(
|
|
171
|
+
slug=slug,
|
|
172
|
+
module=module_name,
|
|
173
|
+
name=name,
|
|
174
|
+
description=manifest.get("description", "").strip(),
|
|
175
|
+
direction=direction,
|
|
176
|
+
depends_on=tuple(depends_on),
|
|
177
|
+
manifest_path=manifest_path,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def matches_direction(self, direction: Direction | str) -> bool:
|
|
181
|
+
"""Check if this integration matches the given direction.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
direction: Direction enum or string to match
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
True if integration matches the direction
|
|
188
|
+
"""
|
|
189
|
+
if isinstance(direction, str):
|
|
190
|
+
direction = Direction.from_string(direction)
|
|
191
|
+
return self.direction == direction
|
|
192
|
+
|
|
193
|
+
def matches_name(self, name: str) -> bool:
|
|
194
|
+
"""Check if this integration matches the given name (case-insensitive).
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
name: Name to match against
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
True if normalized names match
|
|
201
|
+
"""
|
|
202
|
+
return self.name_normalized == normalize_name(name)
|
|
203
|
+
|
|
204
|
+
def has_dependency(self, dep_name: str) -> bool:
|
|
205
|
+
"""Check if this integration has a specific dependency.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
dep_name: Dependency name to check (case-insensitive)
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
True if dependency exists
|
|
212
|
+
"""
|
|
213
|
+
dep_normalized = normalize_name(dep_name)
|
|
214
|
+
return any(
|
|
215
|
+
normalize_name(dep.name) == dep_normalized for dep in self.depends_on
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def direction_label(self) -> str:
|
|
220
|
+
"""Get human-readable direction label."""
|
|
221
|
+
return self.direction.label
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def module_path(self) -> str:
|
|
225
|
+
"""Get full module path for display."""
|
|
226
|
+
return f"integrations.{self.module}"
|