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.
Files changed (135) hide show
  1. julee_hcd/__init__.py +28 -0
  2. julee_hcd/domain/__init__.py +1 -0
  3. julee_hcd/domain/models/__init__.py +32 -0
  4. julee_hcd/domain/models/app.py +229 -0
  5. julee_hcd/domain/models/base.py +47 -0
  6. julee_hcd/domain/models/contrib.py +45 -0
  7. julee_hcd/domain/models/epic.py +82 -0
  8. julee_hcd/domain/models/integration.py +226 -0
  9. julee_hcd/domain/models/journey.py +228 -0
  10. julee_hcd/domain/models/persona.py +208 -0
  11. julee_hcd/domain/models/story.py +140 -0
  12. julee_hcd/domain/repositories/__init__.py +29 -0
  13. julee_hcd/domain/repositories/accelerator.py +23 -0
  14. julee_hcd/domain/repositories/app.py +58 -0
  15. julee_hcd/domain/repositories/base.py +71 -0
  16. julee_hcd/domain/repositories/code_info.py +18 -0
  17. julee_hcd/domain/repositories/contrib.py +27 -0
  18. julee_hcd/domain/repositories/epic.py +39 -0
  19. julee_hcd/domain/repositories/integration.py +80 -0
  20. julee_hcd/domain/repositories/journey.py +83 -0
  21. julee_hcd/domain/repositories/persona.py +51 -0
  22. julee_hcd/domain/repositories/story.py +69 -0
  23. julee_hcd/domain/services/__init__.py +33 -0
  24. julee_hcd/domain/services/empty_epic_handler.py +20 -0
  25. julee_hcd/domain/services/empty_journey_handler.py +20 -0
  26. julee_hcd/domain/services/epic_created_handler.py +20 -0
  27. julee_hcd/domain/services/journey_created_handler.py +20 -0
  28. julee_hcd/domain/services/orphan_story_handler.py +20 -0
  29. julee_hcd/domain/services/story_created_handler.py +20 -0
  30. julee_hcd/domain/services/unknown_journey_epic_ref_handler.py +22 -0
  31. julee_hcd/domain/services/unknown_journey_persona_handler.py +20 -0
  32. julee_hcd/domain/services/unknown_journey_story_ref_handler.py +22 -0
  33. julee_hcd/domain/services/unknown_persona_handler.py +20 -0
  34. julee_hcd/domain/services/unknown_story_ref_handler.py +20 -0
  35. julee_hcd/infrastructure/__init__.py +0 -0
  36. julee_hcd/infrastructure/handlers/__init__.py +59 -0
  37. julee_hcd/infrastructure/handlers/base.py +21 -0
  38. julee_hcd/infrastructure/handlers/epic_orchestration.py +94 -0
  39. julee_hcd/infrastructure/handlers/journey_orchestration.py +132 -0
  40. julee_hcd/infrastructure/handlers/null_handlers.py +307 -0
  41. julee_hcd/infrastructure/handlers/story_orchestration.py +98 -0
  42. julee_hcd/infrastructure/repositories/__init__.py +0 -0
  43. julee_hcd/infrastructure/repositories/file/__init__.py +19 -0
  44. julee_hcd/infrastructure/repositories/file/app.py +114 -0
  45. julee_hcd/infrastructure/repositories/file/integration.py +83 -0
  46. julee_hcd/infrastructure/repositories/file/story.py +122 -0
  47. julee_hcd/infrastructure/repositories/memory/__init__.py +25 -0
  48. julee_hcd/infrastructure/repositories/memory/app.py +45 -0
  49. julee_hcd/infrastructure/repositories/memory/base.py +77 -0
  50. julee_hcd/infrastructure/repositories/memory/contrib.py +22 -0
  51. julee_hcd/infrastructure/repositories/memory/epic.py +41 -0
  52. julee_hcd/infrastructure/repositories/memory/integration.py +70 -0
  53. julee_hcd/infrastructure/repositories/memory/journey.py +81 -0
  54. julee_hcd/infrastructure/repositories/memory/persona.py +39 -0
  55. julee_hcd/infrastructure/repositories/memory/story.py +63 -0
  56. julee_hcd/infrastructure/repositories/rst/__init__.py +12 -0
  57. julee_hcd/infrastructure/repositories/rst/app.py +94 -0
  58. julee_hcd/infrastructure/repositories/rst/base.py +217 -0
  59. julee_hcd/infrastructure/repositories/rst/epic.py +131 -0
  60. julee_hcd/infrastructure/repositories/rst/integration.py +120 -0
  61. julee_hcd/infrastructure/repositories/rst/journey.py +198 -0
  62. julee_hcd/infrastructure/repositories/rst/persona.py +108 -0
  63. julee_hcd/infrastructure/repositories/rst/story.py +157 -0
  64. julee_hcd/parsers/__init__.py +10 -0
  65. julee_hcd/parsers/directive_specs.py +107 -0
  66. julee_hcd/parsers/docutils_parser.py +547 -0
  67. julee_hcd/parsers/gherkin.py +155 -0
  68. julee_hcd/parsers/yaml.py +185 -0
  69. julee_hcd/py.typed +0 -0
  70. julee_hcd/serializers/__init__.py +10 -0
  71. julee_hcd/serializers/gherkin.py +48 -0
  72. julee_hcd/serializers/rst.py +183 -0
  73. julee_hcd/serializers/yaml.py +91 -0
  74. julee_hcd/templates/__init__.py +5 -0
  75. julee_hcd/templates/accelerator.rst.j2 +18 -0
  76. julee_hcd/templates/app.rst.j2 +15 -0
  77. julee_hcd/templates/base.rst.j2 +58 -0
  78. julee_hcd/templates/epic.rst.j2 +10 -0
  79. julee_hcd/templates/integration.rst.j2 +13 -0
  80. julee_hcd/templates/journey.rst.j2 +29 -0
  81. julee_hcd/templates/persona.rst.j2 +13 -0
  82. julee_hcd/templates/rendering.py +51 -0
  83. julee_hcd/templates/story.rst.j2 +20 -0
  84. julee_hcd/tests/__init__.py +0 -0
  85. julee_hcd/tests/entities/__init__.py +0 -0
  86. julee_hcd/tests/entities/test_app.py +261 -0
  87. julee_hcd/tests/entities/test_app_interface.py +69 -0
  88. julee_hcd/tests/entities/test_authored.py +42 -0
  89. julee_hcd/tests/entities/test_contrib.py +48 -0
  90. julee_hcd/tests/entities/test_epic.py +177 -0
  91. julee_hcd/tests/entities/test_integration.py +327 -0
  92. julee_hcd/tests/entities/test_journey.py +258 -0
  93. julee_hcd/tests/entities/test_persona.py +187 -0
  94. julee_hcd/tests/entities/test_persona_authored.py +104 -0
  95. julee_hcd/tests/entities/test_story.py +216 -0
  96. julee_hcd/tests/repositories/__init__.py +0 -0
  97. julee_hcd/tests/repositories/test_app.py +218 -0
  98. julee_hcd/tests/repositories/test_contrib.py +30 -0
  99. julee_hcd/tests/repositories/test_epic.py +237 -0
  100. julee_hcd/tests/repositories/test_integration.py +268 -0
  101. julee_hcd/tests/repositories/test_journey.py +296 -0
  102. julee_hcd/tests/repositories/test_memory_base.py +122 -0
  103. julee_hcd/tests/repositories/test_persona.py +82 -0
  104. julee_hcd/tests/repositories/test_story.py +236 -0
  105. julee_hcd/tests/roundtrip/__init__.py +0 -0
  106. julee_hcd/tests/roundtrip/test_rst_roundtrip.py +103 -0
  107. julee_hcd/tests/usecases/__init__.py +0 -0
  108. julee_hcd/tests/usecases/test_crud.py +175 -0
  109. julee_hcd/tests/usecases/test_derive_personas.py +157 -0
  110. julee_hcd/tests/usecases/test_validate_accelerators.py +108 -0
  111. julee_hcd/usecases/__init__.py +6 -0
  112. julee_hcd/usecases/crud_app.py +168 -0
  113. julee_hcd/usecases/crud_contrib_module.py +168 -0
  114. julee_hcd/usecases/crud_epic.py +150 -0
  115. julee_hcd/usecases/crud_integration.py +174 -0
  116. julee_hcd/usecases/crud_journey.py +168 -0
  117. julee_hcd/usecases/crud_persona.py +171 -0
  118. julee_hcd/usecases/crud_story.py +168 -0
  119. julee_hcd/usecases/delete.py +187 -0
  120. julee_hcd/usecases/derive_personas.py +185 -0
  121. julee_hcd/usecases/epic_orchestration.py +114 -0
  122. julee_hcd/usecases/get_persona.py +74 -0
  123. julee_hcd/usecases/journey_orchestration.py +169 -0
  124. julee_hcd/usecases/resolve_accelerator_references.py +275 -0
  125. julee_hcd/usecases/resolve_app_references.py +169 -0
  126. julee_hcd/usecases/resolve_story_references.py +147 -0
  127. julee_hcd/usecases/story_orchestration.py +116 -0
  128. julee_hcd/usecases/suggestions.py +40 -0
  129. julee_hcd/usecases/validate_accelerators.py +120 -0
  130. julee_hcd-0.1.0.dist-info/METADATA +57 -0
  131. julee_hcd-0.1.0.dist-info/RECORD +135 -0
  132. julee_hcd-0.1.0.dist-info/WHEEL +5 -0
  133. julee_hcd-0.1.0.dist-info/entry_points.txt +2 -0
  134. julee_hcd-0.1.0.dist-info/licenses/LICENSE +674 -0
  135. 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}"