julee 0.1.4__py3-none-any.whl → 0.1.6__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/__init__.py +1 -1
- julee/api/tests/routers/test_assembly_specifications.py +2 -0
- julee/api/tests/routers/test_documents.py +2 -0
- julee/api/tests/routers/test_knowledge_service_configs.py +2 -0
- julee/api/tests/routers/test_knowledge_service_queries.py +2 -0
- julee/api/tests/routers/test_system.py +2 -0
- julee/api/tests/routers/test_workflows.py +2 -0
- julee/api/tests/test_app.py +2 -0
- julee/api/tests/test_dependencies.py +2 -0
- julee/api/tests/test_requests.py +2 -0
- julee/contrib/polling/__init__.py +22 -19
- julee/contrib/polling/apps/__init__.py +17 -0
- julee/contrib/polling/apps/worker/__init__.py +17 -0
- julee/contrib/polling/apps/worker/pipelines.py +288 -0
- julee/contrib/polling/domain/__init__.py +7 -9
- julee/contrib/polling/domain/models/__init__.py +6 -7
- julee/contrib/polling/domain/models/polling_config.py +18 -1
- julee/contrib/polling/domain/services/__init__.py +6 -5
- julee/contrib/polling/domain/services/poller.py +1 -1
- julee/contrib/polling/infrastructure/__init__.py +9 -8
- julee/contrib/polling/infrastructure/services/__init__.py +6 -5
- julee/contrib/polling/infrastructure/services/polling/__init__.py +6 -5
- julee/contrib/polling/infrastructure/services/polling/http/__init__.py +6 -5
- julee/contrib/polling/infrastructure/services/polling/http/http_poller_service.py +5 -2
- julee/contrib/polling/infrastructure/temporal/__init__.py +12 -12
- julee/contrib/polling/infrastructure/temporal/activities.py +1 -1
- julee/contrib/polling/infrastructure/temporal/manager.py +291 -0
- julee/contrib/polling/infrastructure/temporal/proxies.py +1 -1
- julee/contrib/polling/tests/unit/apps/worker/test_pipelines.py +580 -0
- julee/contrib/polling/tests/unit/infrastructure/services/polling/http/test_http_poller_service.py +40 -2
- julee/contrib/polling/tests/unit/infrastructure/temporal/__init__.py +7 -0
- julee/contrib/polling/tests/unit/infrastructure/temporal/test_manager.py +475 -0
- julee/docs/sphinx_hcd/__init__.py +146 -13
- julee/docs/sphinx_hcd/domain/__init__.py +5 -0
- julee/docs/sphinx_hcd/domain/models/__init__.py +32 -0
- julee/docs/sphinx_hcd/domain/models/accelerator.py +152 -0
- julee/docs/sphinx_hcd/domain/models/app.py +151 -0
- julee/docs/sphinx_hcd/domain/models/code_info.py +121 -0
- julee/docs/sphinx_hcd/domain/models/epic.py +79 -0
- julee/docs/sphinx_hcd/domain/models/integration.py +230 -0
- julee/docs/sphinx_hcd/domain/models/journey.py +222 -0
- julee/docs/sphinx_hcd/domain/models/persona.py +106 -0
- julee/docs/sphinx_hcd/domain/models/story.py +128 -0
- julee/docs/sphinx_hcd/domain/repositories/__init__.py +25 -0
- julee/docs/sphinx_hcd/domain/repositories/accelerator.py +98 -0
- julee/docs/sphinx_hcd/domain/repositories/app.py +57 -0
- julee/docs/sphinx_hcd/domain/repositories/base.py +89 -0
- julee/docs/sphinx_hcd/domain/repositories/code_info.py +69 -0
- julee/docs/sphinx_hcd/domain/repositories/epic.py +62 -0
- julee/docs/sphinx_hcd/domain/repositories/integration.py +79 -0
- julee/docs/sphinx_hcd/domain/repositories/journey.py +106 -0
- julee/docs/sphinx_hcd/domain/repositories/story.py +68 -0
- julee/docs/sphinx_hcd/domain/use_cases/__init__.py +64 -0
- julee/docs/sphinx_hcd/domain/use_cases/derive_personas.py +166 -0
- julee/docs/sphinx_hcd/domain/use_cases/resolve_accelerator_references.py +236 -0
- julee/docs/sphinx_hcd/domain/use_cases/resolve_app_references.py +144 -0
- julee/docs/sphinx_hcd/domain/use_cases/resolve_story_references.py +121 -0
- julee/docs/sphinx_hcd/parsers/__init__.py +48 -0
- julee/docs/sphinx_hcd/parsers/ast.py +150 -0
- julee/docs/sphinx_hcd/parsers/gherkin.py +155 -0
- julee/docs/sphinx_hcd/parsers/yaml.py +184 -0
- julee/docs/sphinx_hcd/repositories/__init__.py +4 -0
- julee/docs/sphinx_hcd/repositories/memory/__init__.py +25 -0
- julee/docs/sphinx_hcd/repositories/memory/accelerator.py +86 -0
- julee/docs/sphinx_hcd/repositories/memory/app.py +45 -0
- julee/docs/sphinx_hcd/repositories/memory/base.py +106 -0
- julee/docs/sphinx_hcd/repositories/memory/code_info.py +59 -0
- julee/docs/sphinx_hcd/repositories/memory/epic.py +54 -0
- julee/docs/sphinx_hcd/repositories/memory/integration.py +70 -0
- julee/docs/sphinx_hcd/repositories/memory/journey.py +96 -0
- julee/docs/sphinx_hcd/repositories/memory/story.py +63 -0
- julee/docs/sphinx_hcd/sphinx/__init__.py +28 -0
- julee/docs/sphinx_hcd/sphinx/adapters.py +116 -0
- julee/docs/sphinx_hcd/sphinx/context.py +163 -0
- julee/docs/sphinx_hcd/sphinx/directives/__init__.py +160 -0
- julee/docs/sphinx_hcd/sphinx/directives/accelerator.py +576 -0
- julee/docs/sphinx_hcd/sphinx/directives/app.py +349 -0
- julee/docs/sphinx_hcd/sphinx/directives/base.py +211 -0
- julee/docs/sphinx_hcd/sphinx/directives/epic.py +434 -0
- julee/docs/sphinx_hcd/sphinx/directives/integration.py +220 -0
- julee/docs/sphinx_hcd/sphinx/directives/journey.py +642 -0
- julee/docs/sphinx_hcd/sphinx/directives/persona.py +345 -0
- julee/docs/sphinx_hcd/sphinx/directives/story.py +575 -0
- julee/docs/sphinx_hcd/sphinx/event_handlers/__init__.py +16 -0
- julee/docs/sphinx_hcd/sphinx/event_handlers/builder_inited.py +31 -0
- julee/docs/sphinx_hcd/sphinx/event_handlers/doctree_read.py +27 -0
- julee/docs/sphinx_hcd/sphinx/event_handlers/doctree_resolved.py +43 -0
- julee/docs/sphinx_hcd/sphinx/event_handlers/env_purge_doc.py +42 -0
- julee/docs/sphinx_hcd/sphinx/initialization.py +139 -0
- julee/docs/sphinx_hcd/tests/__init__.py +9 -0
- julee/docs/sphinx_hcd/tests/conftest.py +6 -0
- julee/docs/sphinx_hcd/tests/domain/__init__.py +1 -0
- julee/docs/sphinx_hcd/tests/domain/models/__init__.py +1 -0
- julee/docs/sphinx_hcd/tests/domain/models/test_accelerator.py +266 -0
- julee/docs/sphinx_hcd/tests/domain/models/test_app.py +258 -0
- julee/docs/sphinx_hcd/tests/domain/models/test_code_info.py +231 -0
- julee/docs/sphinx_hcd/tests/domain/models/test_epic.py +163 -0
- julee/docs/sphinx_hcd/tests/domain/models/test_integration.py +327 -0
- julee/docs/sphinx_hcd/tests/domain/models/test_journey.py +249 -0
- julee/docs/sphinx_hcd/tests/domain/models/test_persona.py +172 -0
- julee/docs/sphinx_hcd/tests/domain/models/test_story.py +216 -0
- julee/docs/sphinx_hcd/tests/domain/use_cases/__init__.py +1 -0
- julee/docs/sphinx_hcd/tests/domain/use_cases/test_derive_personas.py +314 -0
- julee/docs/sphinx_hcd/tests/domain/use_cases/test_resolve_accelerator_references.py +476 -0
- julee/docs/sphinx_hcd/tests/domain/use_cases/test_resolve_app_references.py +265 -0
- julee/docs/sphinx_hcd/tests/domain/use_cases/test_resolve_story_references.py +229 -0
- julee/docs/sphinx_hcd/tests/integration/__init__.py +1 -0
- julee/docs/sphinx_hcd/tests/parsers/__init__.py +1 -0
- julee/docs/sphinx_hcd/tests/parsers/test_ast.py +298 -0
- julee/docs/sphinx_hcd/tests/parsers/test_gherkin.py +282 -0
- julee/docs/sphinx_hcd/tests/parsers/test_yaml.py +496 -0
- julee/docs/sphinx_hcd/tests/repositories/__init__.py +1 -0
- julee/docs/sphinx_hcd/tests/repositories/test_accelerator.py +298 -0
- julee/docs/sphinx_hcd/tests/repositories/test_app.py +218 -0
- julee/docs/sphinx_hcd/tests/repositories/test_base.py +151 -0
- julee/docs/sphinx_hcd/tests/repositories/test_code_info.py +253 -0
- julee/docs/sphinx_hcd/tests/repositories/test_epic.py +237 -0
- julee/docs/sphinx_hcd/tests/repositories/test_integration.py +268 -0
- julee/docs/sphinx_hcd/tests/repositories/test_journey.py +294 -0
- julee/docs/sphinx_hcd/tests/repositories/test_story.py +236 -0
- julee/docs/sphinx_hcd/tests/sphinx/__init__.py +1 -0
- julee/docs/sphinx_hcd/tests/sphinx/directives/__init__.py +1 -0
- julee/docs/sphinx_hcd/tests/sphinx/directives/test_base.py +160 -0
- julee/docs/sphinx_hcd/tests/sphinx/test_adapters.py +176 -0
- julee/docs/sphinx_hcd/tests/sphinx/test_context.py +257 -0
- julee/domain/models/assembly/tests/test_assembly.py +2 -0
- julee/domain/models/assembly_specification/tests/test_assembly_specification.py +2 -0
- julee/domain/models/assembly_specification/tests/test_knowledge_service_query.py +2 -0
- julee/domain/models/custom_fields/tests/test_custom_fields.py +2 -0
- julee/domain/models/document/tests/test_document.py +2 -0
- julee/domain/models/policy/tests/test_document_policy_validation.py +2 -0
- julee/domain/models/policy/tests/test_policy.py +2 -0
- julee/domain/use_cases/tests/test_extract_assemble_data.py +2 -0
- julee/domain/use_cases/tests/test_initialize_system_data.py +2 -0
- julee/domain/use_cases/tests/test_validate_document.py +2 -0
- julee/maintenance/release.py +10 -5
- julee/repositories/memory/tests/test_document.py +2 -0
- julee/repositories/memory/tests/test_document_policy_validation.py +2 -0
- julee/repositories/memory/tests/test_policy.py +2 -0
- julee/repositories/minio/tests/test_assembly.py +2 -0
- julee/repositories/minio/tests/test_assembly_specification.py +2 -0
- julee/repositories/minio/tests/test_client_protocol.py +3 -0
- julee/repositories/minio/tests/test_document.py +2 -0
- julee/repositories/minio/tests/test_document_policy_validation.py +2 -0
- julee/repositories/minio/tests/test_knowledge_service_config.py +2 -0
- julee/repositories/minio/tests/test_knowledge_service_query.py +2 -0
- julee/repositories/minio/tests/test_policy.py +2 -0
- julee/services/knowledge_service/anthropic/tests/test_knowledge_service.py +2 -0
- julee/services/knowledge_service/memory/test_knowledge_service.py +2 -0
- julee/services/knowledge_service/test_factory.py +2 -0
- julee/util/tests/test_decorators.py +2 -0
- julee-0.1.6.dist-info/METADATA +104 -0
- julee-0.1.6.dist-info/RECORD +288 -0
- julee/docs/sphinx_hcd/accelerators.py +0 -1175
- julee/docs/sphinx_hcd/apps.py +0 -518
- julee/docs/sphinx_hcd/epics.py +0 -453
- julee/docs/sphinx_hcd/integrations.py +0 -310
- julee/docs/sphinx_hcd/journeys.py +0 -797
- julee/docs/sphinx_hcd/personas.py +0 -457
- julee/docs/sphinx_hcd/stories.py +0 -960
- julee-0.1.4.dist-info/METADATA +0 -197
- julee-0.1.4.dist-info/RECORD +0 -196
- {julee-0.1.4.dist-info → julee-0.1.6.dist-info}/WHEEL +0 -0
- {julee-0.1.4.dist-info → julee-0.1.6.dist-info}/licenses/LICENSE +0 -0
- {julee-0.1.4.dist-info → julee-0.1.6.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,230 @@
|
|
|
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 Enum
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field, field_validator
|
|
10
|
+
|
|
11
|
+
from ...utils import normalize_name
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Direction(str, Enum):
|
|
15
|
+
"""Integration data flow direction."""
|
|
16
|
+
|
|
17
|
+
INBOUND = "inbound"
|
|
18
|
+
OUTBOUND = "outbound"
|
|
19
|
+
BIDIRECTIONAL = "bidirectional"
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_string(cls, value: str) -> "Direction":
|
|
23
|
+
"""Convert string to Direction, defaulting to BIDIRECTIONAL."""
|
|
24
|
+
try:
|
|
25
|
+
return cls(value.lower())
|
|
26
|
+
except ValueError:
|
|
27
|
+
return cls.BIDIRECTIONAL
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def label(self) -> str:
|
|
31
|
+
"""Get human-readable label."""
|
|
32
|
+
labels = {
|
|
33
|
+
Direction.INBOUND: "Inbound (data source)",
|
|
34
|
+
Direction.OUTBOUND: "Outbound (data sink)",
|
|
35
|
+
Direction.BIDIRECTIONAL: "Bidirectional",
|
|
36
|
+
}
|
|
37
|
+
return labels.get(self, str(self.value))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ExternalDependency(BaseModel):
|
|
41
|
+
"""External system that an integration depends on.
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
name: Display name of the external system
|
|
45
|
+
url: Optional URL for documentation or reference
|
|
46
|
+
description: Optional brief description
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
name: str
|
|
50
|
+
url: str | None = None
|
|
51
|
+
description: str = ""
|
|
52
|
+
|
|
53
|
+
@field_validator("name", mode="before")
|
|
54
|
+
@classmethod
|
|
55
|
+
def validate_name(cls, v: str) -> str:
|
|
56
|
+
"""Validate name is not empty."""
|
|
57
|
+
if not v or not v.strip():
|
|
58
|
+
raise ValueError("name cannot be empty")
|
|
59
|
+
return v.strip()
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_dict(cls, data: dict) -> "ExternalDependency":
|
|
63
|
+
"""Create from dictionary (YAML parsed data).
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
data: Dictionary with name, url, description keys
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
ExternalDependency instance
|
|
70
|
+
"""
|
|
71
|
+
return cls(
|
|
72
|
+
name=data.get("name", ""),
|
|
73
|
+
url=data.get("url"),
|
|
74
|
+
description=data.get("description", ""),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Integration(BaseModel):
|
|
79
|
+
"""Integration module entity.
|
|
80
|
+
|
|
81
|
+
Integrations represent connections to external systems, defining
|
|
82
|
+
data flow direction and external dependencies.
|
|
83
|
+
|
|
84
|
+
Attributes:
|
|
85
|
+
slug: URL-safe identifier (e.g., "pilot-data-collection")
|
|
86
|
+
module: Python module name (e.g., "pilot_data_collection")
|
|
87
|
+
name: Display name
|
|
88
|
+
description: Human-readable description
|
|
89
|
+
direction: Data flow direction
|
|
90
|
+
depends_on: List of external dependencies
|
|
91
|
+
manifest_path: Path to the integration.yaml file
|
|
92
|
+
name_normalized: Lowercase name for matching
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
slug: str
|
|
96
|
+
module: str
|
|
97
|
+
name: str
|
|
98
|
+
description: str = ""
|
|
99
|
+
direction: Direction = Direction.BIDIRECTIONAL
|
|
100
|
+
depends_on: list[ExternalDependency] = Field(default_factory=list)
|
|
101
|
+
manifest_path: str = ""
|
|
102
|
+
name_normalized: str = ""
|
|
103
|
+
|
|
104
|
+
@field_validator("slug", mode="before")
|
|
105
|
+
@classmethod
|
|
106
|
+
def validate_slug(cls, v: str) -> str:
|
|
107
|
+
"""Validate slug is not empty."""
|
|
108
|
+
if not v or not v.strip():
|
|
109
|
+
raise ValueError("slug cannot be empty")
|
|
110
|
+
return v.strip()
|
|
111
|
+
|
|
112
|
+
@field_validator("module", mode="before")
|
|
113
|
+
@classmethod
|
|
114
|
+
def validate_module(cls, v: str) -> str:
|
|
115
|
+
"""Validate module is not empty."""
|
|
116
|
+
if not v or not v.strip():
|
|
117
|
+
raise ValueError("module cannot be empty")
|
|
118
|
+
return v.strip()
|
|
119
|
+
|
|
120
|
+
@field_validator("name", mode="before")
|
|
121
|
+
@classmethod
|
|
122
|
+
def validate_name(cls, v: str) -> str:
|
|
123
|
+
"""Validate name is not empty."""
|
|
124
|
+
if not v or not v.strip():
|
|
125
|
+
raise ValueError("name cannot be empty")
|
|
126
|
+
return v.strip()
|
|
127
|
+
|
|
128
|
+
@field_validator("name_normalized", mode="before")
|
|
129
|
+
@classmethod
|
|
130
|
+
def compute_name_normalized(cls, v: str, info) -> str:
|
|
131
|
+
"""Compute normalized name from name if not provided."""
|
|
132
|
+
if v:
|
|
133
|
+
return v
|
|
134
|
+
name = info.data.get("name", "")
|
|
135
|
+
return normalize_name(name) if name else ""
|
|
136
|
+
|
|
137
|
+
def model_post_init(self, __context) -> None:
|
|
138
|
+
"""Ensure normalized fields are computed after init."""
|
|
139
|
+
if not self.name_normalized and self.name:
|
|
140
|
+
object.__setattr__(self, "name_normalized", normalize_name(self.name))
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def from_manifest(
|
|
144
|
+
cls,
|
|
145
|
+
module_name: str,
|
|
146
|
+
manifest: dict,
|
|
147
|
+
manifest_path: str,
|
|
148
|
+
) -> "Integration":
|
|
149
|
+
"""Create an Integration from a parsed YAML manifest.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
module_name: Module directory name
|
|
153
|
+
manifest: Parsed YAML content
|
|
154
|
+
manifest_path: Path to the manifest file
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Integration instance
|
|
158
|
+
"""
|
|
159
|
+
slug = manifest.get("slug", module_name.replace("_", "-"))
|
|
160
|
+
name = manifest.get("name", slug.replace("-", " ").title())
|
|
161
|
+
direction = Direction.from_string(manifest.get("direction", "bidirectional"))
|
|
162
|
+
|
|
163
|
+
# Parse depends_on list
|
|
164
|
+
depends_on_raw = manifest.get("depends_on", [])
|
|
165
|
+
depends_on = [
|
|
166
|
+
(
|
|
167
|
+
ExternalDependency.from_dict(dep)
|
|
168
|
+
if isinstance(dep, dict)
|
|
169
|
+
else ExternalDependency(name=str(dep))
|
|
170
|
+
)
|
|
171
|
+
for dep in depends_on_raw
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
return cls(
|
|
175
|
+
slug=slug,
|
|
176
|
+
module=module_name,
|
|
177
|
+
name=name,
|
|
178
|
+
description=manifest.get("description", "").strip(),
|
|
179
|
+
direction=direction,
|
|
180
|
+
depends_on=depends_on,
|
|
181
|
+
manifest_path=manifest_path,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def matches_direction(self, direction: Direction | str) -> bool:
|
|
185
|
+
"""Check if this integration matches the given direction.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
direction: Direction enum or string to match
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
True if integration matches the direction
|
|
192
|
+
"""
|
|
193
|
+
if isinstance(direction, str):
|
|
194
|
+
direction = Direction.from_string(direction)
|
|
195
|
+
return self.direction == direction
|
|
196
|
+
|
|
197
|
+
def matches_name(self, name: str) -> bool:
|
|
198
|
+
"""Check if this integration matches the given name (case-insensitive).
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
name: Name to match against
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
True if normalized names match
|
|
205
|
+
"""
|
|
206
|
+
return self.name_normalized == normalize_name(name)
|
|
207
|
+
|
|
208
|
+
def has_dependency(self, dep_name: str) -> bool:
|
|
209
|
+
"""Check if this integration has a specific dependency.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
dep_name: Dependency name to check (case-insensitive)
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
True if dependency exists
|
|
216
|
+
"""
|
|
217
|
+
dep_normalized = normalize_name(dep_name)
|
|
218
|
+
return any(
|
|
219
|
+
normalize_name(dep.name) == dep_normalized for dep in self.depends_on
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def direction_label(self) -> str:
|
|
224
|
+
"""Get human-readable direction label."""
|
|
225
|
+
return self.direction.label
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def module_path(self) -> str:
|
|
229
|
+
"""Get full module path for display."""
|
|
230
|
+
return f"integrations.{self.module}"
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Journey domain model.
|
|
2
|
+
|
|
3
|
+
Represents a user journey in the HCD documentation system.
|
|
4
|
+
Journeys are defined via RST directives and track a persona's path
|
|
5
|
+
through the system to achieve a goal.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from enum import Enum
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field, field_validator
|
|
11
|
+
|
|
12
|
+
from ...utils import normalize_name
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class StepType(str, Enum):
|
|
16
|
+
"""Type of journey step."""
|
|
17
|
+
|
|
18
|
+
STORY = "story"
|
|
19
|
+
EPIC = "epic"
|
|
20
|
+
PHASE = "phase"
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_string(cls, value: str) -> "StepType":
|
|
24
|
+
"""Convert string to StepType."""
|
|
25
|
+
try:
|
|
26
|
+
return cls(value.lower())
|
|
27
|
+
except ValueError:
|
|
28
|
+
raise ValueError(f"Invalid step type: {value}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class JourneyStep(BaseModel):
|
|
32
|
+
"""A step within a journey.
|
|
33
|
+
|
|
34
|
+
Steps can be stories (feature references), epics (epic references),
|
|
35
|
+
or phases (grouping labels for subsequent steps).
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
step_type: The type of step (story, epic, phase)
|
|
39
|
+
ref: Reference identifier (story title, epic slug, or phase title)
|
|
40
|
+
description: Optional description (primarily for phases)
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
step_type: StepType
|
|
44
|
+
ref: str
|
|
45
|
+
description: str = ""
|
|
46
|
+
|
|
47
|
+
@field_validator("ref", mode="before")
|
|
48
|
+
@classmethod
|
|
49
|
+
def validate_ref(cls, v: str) -> str:
|
|
50
|
+
"""Validate ref is not empty."""
|
|
51
|
+
if not v or not v.strip():
|
|
52
|
+
raise ValueError("ref cannot be empty")
|
|
53
|
+
return v.strip()
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def story(cls, title: str) -> "JourneyStep":
|
|
57
|
+
"""Create a story step.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
title: Story feature title
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
JourneyStep with type STORY
|
|
64
|
+
"""
|
|
65
|
+
return cls(step_type=StepType.STORY, ref=title)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def epic(cls, slug: str) -> "JourneyStep":
|
|
69
|
+
"""Create an epic step.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
slug: Epic slug
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
JourneyStep with type EPIC
|
|
76
|
+
"""
|
|
77
|
+
return cls(step_type=StepType.EPIC, ref=slug)
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def phase(cls, title: str, description: str = "") -> "JourneyStep":
|
|
81
|
+
"""Create a phase step.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
title: Phase title
|
|
85
|
+
description: Optional phase description
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
JourneyStep with type PHASE
|
|
89
|
+
"""
|
|
90
|
+
return cls(step_type=StepType.PHASE, ref=title, description=description)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def is_story(self) -> bool:
|
|
94
|
+
"""Check if this is a story step."""
|
|
95
|
+
return self.step_type == StepType.STORY
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def is_epic(self) -> bool:
|
|
99
|
+
"""Check if this is an epic step."""
|
|
100
|
+
return self.step_type == StepType.EPIC
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def is_phase(self) -> bool:
|
|
104
|
+
"""Check if this is a phase step."""
|
|
105
|
+
return self.step_type == StepType.PHASE
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Journey(BaseModel):
|
|
109
|
+
"""User journey entity.
|
|
110
|
+
|
|
111
|
+
A journey represents a persona's path through the system to achieve
|
|
112
|
+
a goal. It captures the user's motivation, the value delivered, and
|
|
113
|
+
the sequence of steps they follow.
|
|
114
|
+
|
|
115
|
+
Attributes:
|
|
116
|
+
slug: URL-safe identifier (e.g., "build-vocabulary")
|
|
117
|
+
persona: The persona undertaking this journey
|
|
118
|
+
persona_normalized: Lowercase persona for matching
|
|
119
|
+
intent: What the persona wants (their motivation)
|
|
120
|
+
outcome: What success looks like (business value)
|
|
121
|
+
goal: Activity description (what they do)
|
|
122
|
+
depends_on: Journey slugs that must be completed first
|
|
123
|
+
steps: Sequence of journey steps
|
|
124
|
+
preconditions: Conditions that must be true before starting
|
|
125
|
+
postconditions: Conditions that will be true after completion
|
|
126
|
+
docname: RST document name (for incremental builds)
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
slug: str
|
|
130
|
+
persona: str = ""
|
|
131
|
+
persona_normalized: str = ""
|
|
132
|
+
intent: str = ""
|
|
133
|
+
outcome: str = ""
|
|
134
|
+
goal: str = ""
|
|
135
|
+
depends_on: list[str] = Field(default_factory=list)
|
|
136
|
+
steps: list[JourneyStep] = Field(default_factory=list)
|
|
137
|
+
preconditions: list[str] = Field(default_factory=list)
|
|
138
|
+
postconditions: list[str] = Field(default_factory=list)
|
|
139
|
+
docname: str = ""
|
|
140
|
+
|
|
141
|
+
@field_validator("slug", mode="before")
|
|
142
|
+
@classmethod
|
|
143
|
+
def validate_slug(cls, v: str) -> str:
|
|
144
|
+
"""Validate slug is not empty."""
|
|
145
|
+
if not v or not v.strip():
|
|
146
|
+
raise ValueError("slug cannot be empty")
|
|
147
|
+
return v.strip()
|
|
148
|
+
|
|
149
|
+
@field_validator("persona_normalized", mode="before")
|
|
150
|
+
@classmethod
|
|
151
|
+
def compute_persona_normalized(cls, v: str, info) -> str:
|
|
152
|
+
"""Compute normalized persona from persona if not provided."""
|
|
153
|
+
if v:
|
|
154
|
+
return v
|
|
155
|
+
persona = info.data.get("persona", "")
|
|
156
|
+
return normalize_name(persona) if persona else ""
|
|
157
|
+
|
|
158
|
+
def model_post_init(self, __context) -> None:
|
|
159
|
+
"""Ensure normalized fields are computed after init."""
|
|
160
|
+
if not self.persona_normalized and self.persona:
|
|
161
|
+
object.__setattr__(self, "persona_normalized", normalize_name(self.persona))
|
|
162
|
+
|
|
163
|
+
def matches_persona(self, persona_name: str) -> bool:
|
|
164
|
+
"""Check if this journey matches the given persona (case-insensitive).
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
persona_name: Persona name to match against
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
True if normalized names match
|
|
171
|
+
"""
|
|
172
|
+
return self.persona_normalized == normalize_name(persona_name)
|
|
173
|
+
|
|
174
|
+
def has_dependency(self, journey_slug: str) -> bool:
|
|
175
|
+
"""Check if this journey depends on another journey.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
journey_slug: Slug of potential dependency
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
True if this journey depends on the given journey
|
|
182
|
+
"""
|
|
183
|
+
return journey_slug in self.depends_on
|
|
184
|
+
|
|
185
|
+
def add_step(self, step: JourneyStep) -> None:
|
|
186
|
+
"""Add a step to this journey.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
step: JourneyStep to add
|
|
190
|
+
"""
|
|
191
|
+
self.steps.append(step)
|
|
192
|
+
|
|
193
|
+
def get_story_refs(self) -> list[str]:
|
|
194
|
+
"""Get all story references from steps.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
List of story titles referenced in steps
|
|
198
|
+
"""
|
|
199
|
+
return [step.ref for step in self.steps if step.is_story]
|
|
200
|
+
|
|
201
|
+
def get_epic_refs(self) -> list[str]:
|
|
202
|
+
"""Get all epic references from steps.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
List of epic slugs referenced in steps
|
|
206
|
+
"""
|
|
207
|
+
return [step.ref for step in self.steps if step.is_epic]
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def display_title(self) -> str:
|
|
211
|
+
"""Get formatted title for display."""
|
|
212
|
+
return self.slug.replace("-", " ").title()
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def has_steps(self) -> bool:
|
|
216
|
+
"""Check if journey has any steps."""
|
|
217
|
+
return len(self.steps) > 0
|
|
218
|
+
|
|
219
|
+
@property
|
|
220
|
+
def step_count(self) -> int:
|
|
221
|
+
"""Get number of steps."""
|
|
222
|
+
return len(self.steps)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Persona domain model.
|
|
2
|
+
|
|
3
|
+
Represents a persona derived from story data in the HCD documentation system.
|
|
4
|
+
Personas are not defined directly but are extracted from user stories.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field, computed_field, field_validator
|
|
8
|
+
|
|
9
|
+
from ...utils import normalize_name
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Persona(BaseModel):
|
|
13
|
+
"""Persona entity.
|
|
14
|
+
|
|
15
|
+
A persona represents a type of user who interacts with the system.
|
|
16
|
+
Personas are derived from user stories - they are the "As a..." in
|
|
17
|
+
"As a [persona], I want to...".
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
name: Display name of the persona (e.g., "Knowledge Curator")
|
|
21
|
+
app_slugs: List of app slugs this persona uses
|
|
22
|
+
epic_slugs: List of epic slugs containing stories for this persona
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
name: str
|
|
26
|
+
app_slugs: list[str] = Field(default_factory=list)
|
|
27
|
+
epic_slugs: list[str] = Field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
@field_validator("name", mode="before")
|
|
30
|
+
@classmethod
|
|
31
|
+
def validate_name(cls, v: str) -> str:
|
|
32
|
+
"""Validate name is not empty."""
|
|
33
|
+
if not v or not v.strip():
|
|
34
|
+
raise ValueError("name cannot be empty")
|
|
35
|
+
return v.strip()
|
|
36
|
+
|
|
37
|
+
@computed_field
|
|
38
|
+
@property
|
|
39
|
+
def normalized_name(self) -> str:
|
|
40
|
+
"""Get normalized name for matching."""
|
|
41
|
+
return normalize_name(self.name)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def display_name(self) -> str:
|
|
45
|
+
"""Get formatted name for display (same as name)."""
|
|
46
|
+
return self.name
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def app_count(self) -> int:
|
|
50
|
+
"""Get number of apps this persona uses."""
|
|
51
|
+
return len(self.app_slugs)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def epic_count(self) -> int:
|
|
55
|
+
"""Get number of epics this persona participates in."""
|
|
56
|
+
return len(self.epic_slugs)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def has_apps(self) -> bool:
|
|
60
|
+
"""Check if persona uses any apps."""
|
|
61
|
+
return len(self.app_slugs) > 0
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def has_epics(self) -> bool:
|
|
65
|
+
"""Check if persona participates in any epics."""
|
|
66
|
+
return len(self.epic_slugs) > 0
|
|
67
|
+
|
|
68
|
+
def uses_app(self, app_slug: str) -> bool:
|
|
69
|
+
"""Check if persona uses a specific app.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
app_slug: App slug to check
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
True if persona uses this app
|
|
76
|
+
"""
|
|
77
|
+
return app_slug in self.app_slugs
|
|
78
|
+
|
|
79
|
+
def participates_in_epic(self, epic_slug: str) -> bool:
|
|
80
|
+
"""Check if persona participates in a specific epic.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
epic_slug: Epic slug to check
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
True if persona has stories in this epic
|
|
87
|
+
"""
|
|
88
|
+
return epic_slug in self.epic_slugs
|
|
89
|
+
|
|
90
|
+
def add_app(self, app_slug: str) -> None:
|
|
91
|
+
"""Add an app to this persona's app list.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
app_slug: App slug to add (duplicates ignored)
|
|
95
|
+
"""
|
|
96
|
+
if app_slug not in self.app_slugs:
|
|
97
|
+
self.app_slugs.append(app_slug)
|
|
98
|
+
|
|
99
|
+
def add_epic(self, epic_slug: str) -> None:
|
|
100
|
+
"""Add an epic to this persona's epic list.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
epic_slug: Epic slug to add (duplicates ignored)
|
|
104
|
+
"""
|
|
105
|
+
if epic_slug not in self.epic_slugs:
|
|
106
|
+
self.epic_slugs.append(epic_slug)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Story domain model.
|
|
2
|
+
|
|
3
|
+
Represents a user story extracted from a Gherkin .feature file.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, field_validator
|
|
7
|
+
|
|
8
|
+
from ...utils import normalize_name, slugify
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Story(BaseModel):
|
|
12
|
+
"""A user story extracted from a Gherkin feature file.
|
|
13
|
+
|
|
14
|
+
Stories are the primary unit of user-facing functionality in HCD.
|
|
15
|
+
They capture who wants to do what and why.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
slug: URL-safe identifier derived from feature title
|
|
19
|
+
feature_title: The Feature: line from the Gherkin file
|
|
20
|
+
persona: The actor from "As a <persona>"
|
|
21
|
+
persona_normalized: Lowercase, spaces-normalized persona for matching
|
|
22
|
+
i_want: The action from "I want to <action>"
|
|
23
|
+
so_that: The benefit from "So that <benefit>"
|
|
24
|
+
app_slug: The application this story belongs to
|
|
25
|
+
app_normalized: Lowercase, spaces-normalized app name for matching
|
|
26
|
+
file_path: Relative path to the .feature file
|
|
27
|
+
abs_path: Absolute path to the .feature file
|
|
28
|
+
gherkin_snippet: The story header portion of the feature file
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
slug: str
|
|
32
|
+
feature_title: str
|
|
33
|
+
persona: str
|
|
34
|
+
persona_normalized: str = ""
|
|
35
|
+
i_want: str = "do something"
|
|
36
|
+
so_that: str = "achieve a goal"
|
|
37
|
+
app_slug: str
|
|
38
|
+
app_normalized: str = ""
|
|
39
|
+
file_path: str
|
|
40
|
+
abs_path: str = ""
|
|
41
|
+
gherkin_snippet: str = ""
|
|
42
|
+
|
|
43
|
+
@field_validator("slug")
|
|
44
|
+
@classmethod
|
|
45
|
+
def validate_slug(cls, v: str) -> str:
|
|
46
|
+
"""Ensure slug is not empty."""
|
|
47
|
+
if not v or not v.strip():
|
|
48
|
+
raise ValueError("Story slug cannot be empty")
|
|
49
|
+
return v.strip()
|
|
50
|
+
|
|
51
|
+
@field_validator("feature_title")
|
|
52
|
+
@classmethod
|
|
53
|
+
def validate_feature_title(cls, v: str) -> str:
|
|
54
|
+
"""Ensure feature title is not empty."""
|
|
55
|
+
if not v or not v.strip():
|
|
56
|
+
raise ValueError("Feature title cannot be empty")
|
|
57
|
+
return v.strip()
|
|
58
|
+
|
|
59
|
+
@field_validator("persona")
|
|
60
|
+
@classmethod
|
|
61
|
+
def validate_persona(cls, v: str) -> str:
|
|
62
|
+
"""Ensure persona is not empty, default to 'unknown'."""
|
|
63
|
+
if not v or not v.strip():
|
|
64
|
+
return "unknown"
|
|
65
|
+
return v.strip()
|
|
66
|
+
|
|
67
|
+
@field_validator("app_slug")
|
|
68
|
+
@classmethod
|
|
69
|
+
def validate_app_slug(cls, v: str) -> str:
|
|
70
|
+
"""Ensure app slug is not empty, default to 'unknown'."""
|
|
71
|
+
if not v or not v.strip():
|
|
72
|
+
return "unknown"
|
|
73
|
+
return v.strip()
|
|
74
|
+
|
|
75
|
+
def model_post_init(self, __context) -> None:
|
|
76
|
+
"""Compute normalized fields after initialization."""
|
|
77
|
+
if not self.persona_normalized:
|
|
78
|
+
self.persona_normalized = normalize_name(self.persona)
|
|
79
|
+
if not self.app_normalized:
|
|
80
|
+
self.app_normalized = normalize_name(self.app_slug)
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def from_feature_file(
|
|
84
|
+
cls,
|
|
85
|
+
feature_title: str,
|
|
86
|
+
persona: str,
|
|
87
|
+
i_want: str,
|
|
88
|
+
so_that: str,
|
|
89
|
+
app_slug: str,
|
|
90
|
+
file_path: str,
|
|
91
|
+
abs_path: str = "",
|
|
92
|
+
gherkin_snippet: str = "",
|
|
93
|
+
) -> "Story":
|
|
94
|
+
"""Create a Story from parsed feature file data.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
feature_title: The Feature: line content
|
|
98
|
+
persona: The "As a" actor
|
|
99
|
+
i_want: The "I want to" action
|
|
100
|
+
so_that: The "So that" benefit
|
|
101
|
+
app_slug: Application slug (from directory structure)
|
|
102
|
+
file_path: Relative path to .feature file
|
|
103
|
+
abs_path: Absolute path to .feature file
|
|
104
|
+
gherkin_snippet: The story header text
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
A new Story instance
|
|
108
|
+
"""
|
|
109
|
+
# Include app_slug in slug to avoid collisions between apps
|
|
110
|
+
return cls(
|
|
111
|
+
slug=f"{app_slug}--{slugify(feature_title)}",
|
|
112
|
+
feature_title=feature_title,
|
|
113
|
+
persona=persona,
|
|
114
|
+
i_want=i_want,
|
|
115
|
+
so_that=so_that,
|
|
116
|
+
app_slug=app_slug,
|
|
117
|
+
file_path=file_path,
|
|
118
|
+
abs_path=abs_path,
|
|
119
|
+
gherkin_snippet=gherkin_snippet,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def matches_persona(self, persona_name: str) -> bool:
|
|
123
|
+
"""Check if this story belongs to a persona (case-insensitive)."""
|
|
124
|
+
return self.persona_normalized == normalize_name(persona_name)
|
|
125
|
+
|
|
126
|
+
def matches_app(self, app_name: str) -> bool:
|
|
127
|
+
"""Check if this story belongs to an app (case-insensitive)."""
|
|
128
|
+
return self.app_normalized == normalize_name(app_name)
|