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.
Files changed (165) hide show
  1. julee/__init__.py +1 -1
  2. julee/api/tests/routers/test_assembly_specifications.py +2 -0
  3. julee/api/tests/routers/test_documents.py +2 -0
  4. julee/api/tests/routers/test_knowledge_service_configs.py +2 -0
  5. julee/api/tests/routers/test_knowledge_service_queries.py +2 -0
  6. julee/api/tests/routers/test_system.py +2 -0
  7. julee/api/tests/routers/test_workflows.py +2 -0
  8. julee/api/tests/test_app.py +2 -0
  9. julee/api/tests/test_dependencies.py +2 -0
  10. julee/api/tests/test_requests.py +2 -0
  11. julee/contrib/polling/__init__.py +22 -19
  12. julee/contrib/polling/apps/__init__.py +17 -0
  13. julee/contrib/polling/apps/worker/__init__.py +17 -0
  14. julee/contrib/polling/apps/worker/pipelines.py +288 -0
  15. julee/contrib/polling/domain/__init__.py +7 -9
  16. julee/contrib/polling/domain/models/__init__.py +6 -7
  17. julee/contrib/polling/domain/models/polling_config.py +18 -1
  18. julee/contrib/polling/domain/services/__init__.py +6 -5
  19. julee/contrib/polling/domain/services/poller.py +1 -1
  20. julee/contrib/polling/infrastructure/__init__.py +9 -8
  21. julee/contrib/polling/infrastructure/services/__init__.py +6 -5
  22. julee/contrib/polling/infrastructure/services/polling/__init__.py +6 -5
  23. julee/contrib/polling/infrastructure/services/polling/http/__init__.py +6 -5
  24. julee/contrib/polling/infrastructure/services/polling/http/http_poller_service.py +5 -2
  25. julee/contrib/polling/infrastructure/temporal/__init__.py +12 -12
  26. julee/contrib/polling/infrastructure/temporal/activities.py +1 -1
  27. julee/contrib/polling/infrastructure/temporal/manager.py +291 -0
  28. julee/contrib/polling/infrastructure/temporal/proxies.py +1 -1
  29. julee/contrib/polling/tests/unit/apps/worker/test_pipelines.py +580 -0
  30. julee/contrib/polling/tests/unit/infrastructure/services/polling/http/test_http_poller_service.py +40 -2
  31. julee/contrib/polling/tests/unit/infrastructure/temporal/__init__.py +7 -0
  32. julee/contrib/polling/tests/unit/infrastructure/temporal/test_manager.py +475 -0
  33. julee/docs/sphinx_hcd/__init__.py +146 -13
  34. julee/docs/sphinx_hcd/domain/__init__.py +5 -0
  35. julee/docs/sphinx_hcd/domain/models/__init__.py +32 -0
  36. julee/docs/sphinx_hcd/domain/models/accelerator.py +152 -0
  37. julee/docs/sphinx_hcd/domain/models/app.py +151 -0
  38. julee/docs/sphinx_hcd/domain/models/code_info.py +121 -0
  39. julee/docs/sphinx_hcd/domain/models/epic.py +79 -0
  40. julee/docs/sphinx_hcd/domain/models/integration.py +230 -0
  41. julee/docs/sphinx_hcd/domain/models/journey.py +222 -0
  42. julee/docs/sphinx_hcd/domain/models/persona.py +106 -0
  43. julee/docs/sphinx_hcd/domain/models/story.py +128 -0
  44. julee/docs/sphinx_hcd/domain/repositories/__init__.py +25 -0
  45. julee/docs/sphinx_hcd/domain/repositories/accelerator.py +98 -0
  46. julee/docs/sphinx_hcd/domain/repositories/app.py +57 -0
  47. julee/docs/sphinx_hcd/domain/repositories/base.py +89 -0
  48. julee/docs/sphinx_hcd/domain/repositories/code_info.py +69 -0
  49. julee/docs/sphinx_hcd/domain/repositories/epic.py +62 -0
  50. julee/docs/sphinx_hcd/domain/repositories/integration.py +79 -0
  51. julee/docs/sphinx_hcd/domain/repositories/journey.py +106 -0
  52. julee/docs/sphinx_hcd/domain/repositories/story.py +68 -0
  53. julee/docs/sphinx_hcd/domain/use_cases/__init__.py +64 -0
  54. julee/docs/sphinx_hcd/domain/use_cases/derive_personas.py +166 -0
  55. julee/docs/sphinx_hcd/domain/use_cases/resolve_accelerator_references.py +236 -0
  56. julee/docs/sphinx_hcd/domain/use_cases/resolve_app_references.py +144 -0
  57. julee/docs/sphinx_hcd/domain/use_cases/resolve_story_references.py +121 -0
  58. julee/docs/sphinx_hcd/parsers/__init__.py +48 -0
  59. julee/docs/sphinx_hcd/parsers/ast.py +150 -0
  60. julee/docs/sphinx_hcd/parsers/gherkin.py +155 -0
  61. julee/docs/sphinx_hcd/parsers/yaml.py +184 -0
  62. julee/docs/sphinx_hcd/repositories/__init__.py +4 -0
  63. julee/docs/sphinx_hcd/repositories/memory/__init__.py +25 -0
  64. julee/docs/sphinx_hcd/repositories/memory/accelerator.py +86 -0
  65. julee/docs/sphinx_hcd/repositories/memory/app.py +45 -0
  66. julee/docs/sphinx_hcd/repositories/memory/base.py +106 -0
  67. julee/docs/sphinx_hcd/repositories/memory/code_info.py +59 -0
  68. julee/docs/sphinx_hcd/repositories/memory/epic.py +54 -0
  69. julee/docs/sphinx_hcd/repositories/memory/integration.py +70 -0
  70. julee/docs/sphinx_hcd/repositories/memory/journey.py +96 -0
  71. julee/docs/sphinx_hcd/repositories/memory/story.py +63 -0
  72. julee/docs/sphinx_hcd/sphinx/__init__.py +28 -0
  73. julee/docs/sphinx_hcd/sphinx/adapters.py +116 -0
  74. julee/docs/sphinx_hcd/sphinx/context.py +163 -0
  75. julee/docs/sphinx_hcd/sphinx/directives/__init__.py +160 -0
  76. julee/docs/sphinx_hcd/sphinx/directives/accelerator.py +576 -0
  77. julee/docs/sphinx_hcd/sphinx/directives/app.py +349 -0
  78. julee/docs/sphinx_hcd/sphinx/directives/base.py +211 -0
  79. julee/docs/sphinx_hcd/sphinx/directives/epic.py +434 -0
  80. julee/docs/sphinx_hcd/sphinx/directives/integration.py +220 -0
  81. julee/docs/sphinx_hcd/sphinx/directives/journey.py +642 -0
  82. julee/docs/sphinx_hcd/sphinx/directives/persona.py +345 -0
  83. julee/docs/sphinx_hcd/sphinx/directives/story.py +575 -0
  84. julee/docs/sphinx_hcd/sphinx/event_handlers/__init__.py +16 -0
  85. julee/docs/sphinx_hcd/sphinx/event_handlers/builder_inited.py +31 -0
  86. julee/docs/sphinx_hcd/sphinx/event_handlers/doctree_read.py +27 -0
  87. julee/docs/sphinx_hcd/sphinx/event_handlers/doctree_resolved.py +43 -0
  88. julee/docs/sphinx_hcd/sphinx/event_handlers/env_purge_doc.py +42 -0
  89. julee/docs/sphinx_hcd/sphinx/initialization.py +139 -0
  90. julee/docs/sphinx_hcd/tests/__init__.py +9 -0
  91. julee/docs/sphinx_hcd/tests/conftest.py +6 -0
  92. julee/docs/sphinx_hcd/tests/domain/__init__.py +1 -0
  93. julee/docs/sphinx_hcd/tests/domain/models/__init__.py +1 -0
  94. julee/docs/sphinx_hcd/tests/domain/models/test_accelerator.py +266 -0
  95. julee/docs/sphinx_hcd/tests/domain/models/test_app.py +258 -0
  96. julee/docs/sphinx_hcd/tests/domain/models/test_code_info.py +231 -0
  97. julee/docs/sphinx_hcd/tests/domain/models/test_epic.py +163 -0
  98. julee/docs/sphinx_hcd/tests/domain/models/test_integration.py +327 -0
  99. julee/docs/sphinx_hcd/tests/domain/models/test_journey.py +249 -0
  100. julee/docs/sphinx_hcd/tests/domain/models/test_persona.py +172 -0
  101. julee/docs/sphinx_hcd/tests/domain/models/test_story.py +216 -0
  102. julee/docs/sphinx_hcd/tests/domain/use_cases/__init__.py +1 -0
  103. julee/docs/sphinx_hcd/tests/domain/use_cases/test_derive_personas.py +314 -0
  104. julee/docs/sphinx_hcd/tests/domain/use_cases/test_resolve_accelerator_references.py +476 -0
  105. julee/docs/sphinx_hcd/tests/domain/use_cases/test_resolve_app_references.py +265 -0
  106. julee/docs/sphinx_hcd/tests/domain/use_cases/test_resolve_story_references.py +229 -0
  107. julee/docs/sphinx_hcd/tests/integration/__init__.py +1 -0
  108. julee/docs/sphinx_hcd/tests/parsers/__init__.py +1 -0
  109. julee/docs/sphinx_hcd/tests/parsers/test_ast.py +298 -0
  110. julee/docs/sphinx_hcd/tests/parsers/test_gherkin.py +282 -0
  111. julee/docs/sphinx_hcd/tests/parsers/test_yaml.py +496 -0
  112. julee/docs/sphinx_hcd/tests/repositories/__init__.py +1 -0
  113. julee/docs/sphinx_hcd/tests/repositories/test_accelerator.py +298 -0
  114. julee/docs/sphinx_hcd/tests/repositories/test_app.py +218 -0
  115. julee/docs/sphinx_hcd/tests/repositories/test_base.py +151 -0
  116. julee/docs/sphinx_hcd/tests/repositories/test_code_info.py +253 -0
  117. julee/docs/sphinx_hcd/tests/repositories/test_epic.py +237 -0
  118. julee/docs/sphinx_hcd/tests/repositories/test_integration.py +268 -0
  119. julee/docs/sphinx_hcd/tests/repositories/test_journey.py +294 -0
  120. julee/docs/sphinx_hcd/tests/repositories/test_story.py +236 -0
  121. julee/docs/sphinx_hcd/tests/sphinx/__init__.py +1 -0
  122. julee/docs/sphinx_hcd/tests/sphinx/directives/__init__.py +1 -0
  123. julee/docs/sphinx_hcd/tests/sphinx/directives/test_base.py +160 -0
  124. julee/docs/sphinx_hcd/tests/sphinx/test_adapters.py +176 -0
  125. julee/docs/sphinx_hcd/tests/sphinx/test_context.py +257 -0
  126. julee/domain/models/assembly/tests/test_assembly.py +2 -0
  127. julee/domain/models/assembly_specification/tests/test_assembly_specification.py +2 -0
  128. julee/domain/models/assembly_specification/tests/test_knowledge_service_query.py +2 -0
  129. julee/domain/models/custom_fields/tests/test_custom_fields.py +2 -0
  130. julee/domain/models/document/tests/test_document.py +2 -0
  131. julee/domain/models/policy/tests/test_document_policy_validation.py +2 -0
  132. julee/domain/models/policy/tests/test_policy.py +2 -0
  133. julee/domain/use_cases/tests/test_extract_assemble_data.py +2 -0
  134. julee/domain/use_cases/tests/test_initialize_system_data.py +2 -0
  135. julee/domain/use_cases/tests/test_validate_document.py +2 -0
  136. julee/maintenance/release.py +10 -5
  137. julee/repositories/memory/tests/test_document.py +2 -0
  138. julee/repositories/memory/tests/test_document_policy_validation.py +2 -0
  139. julee/repositories/memory/tests/test_policy.py +2 -0
  140. julee/repositories/minio/tests/test_assembly.py +2 -0
  141. julee/repositories/minio/tests/test_assembly_specification.py +2 -0
  142. julee/repositories/minio/tests/test_client_protocol.py +3 -0
  143. julee/repositories/minio/tests/test_document.py +2 -0
  144. julee/repositories/minio/tests/test_document_policy_validation.py +2 -0
  145. julee/repositories/minio/tests/test_knowledge_service_config.py +2 -0
  146. julee/repositories/minio/tests/test_knowledge_service_query.py +2 -0
  147. julee/repositories/minio/tests/test_policy.py +2 -0
  148. julee/services/knowledge_service/anthropic/tests/test_knowledge_service.py +2 -0
  149. julee/services/knowledge_service/memory/test_knowledge_service.py +2 -0
  150. julee/services/knowledge_service/test_factory.py +2 -0
  151. julee/util/tests/test_decorators.py +2 -0
  152. julee-0.1.6.dist-info/METADATA +104 -0
  153. julee-0.1.6.dist-info/RECORD +288 -0
  154. julee/docs/sphinx_hcd/accelerators.py +0 -1175
  155. julee/docs/sphinx_hcd/apps.py +0 -518
  156. julee/docs/sphinx_hcd/epics.py +0 -453
  157. julee/docs/sphinx_hcd/integrations.py +0 -310
  158. julee/docs/sphinx_hcd/journeys.py +0 -797
  159. julee/docs/sphinx_hcd/personas.py +0 -457
  160. julee/docs/sphinx_hcd/stories.py +0 -960
  161. julee-0.1.4.dist-info/METADATA +0 -197
  162. julee-0.1.4.dist-info/RECORD +0 -196
  163. {julee-0.1.4.dist-info → julee-0.1.6.dist-info}/WHEEL +0 -0
  164. {julee-0.1.4.dist-info → julee-0.1.6.dist-info}/licenses/LICENSE +0 -0
  165. {julee-0.1.4.dist-info → julee-0.1.6.dist-info}/top_level.txt +0 -0
@@ -1,797 +0,0 @@
1
- """Sphinx extension for defining and cross-referencing user journeys.
2
-
3
- A journey represents a persona's path through the system to achieve a goal.
4
- Each journey captures the user's motivation, the value they receive, and
5
- the sequence of steps (stories/epics) they follow.
6
-
7
- Provides directives:
8
- - define-journey: Define a journey with persona, intent, outcome, and steps
9
- - step-story: Reference a story as a journey step
10
- - step-epic: Reference an epic as a journey step
11
- - step-phase: Optional grouping label for steps
12
- - journey-index: Render index of all journeys
13
- - journey-dependency-graph: Generate PlantUML graph of journey dependencies
14
- - journeys-for-persona: List journeys for a specific persona
15
- """
16
-
17
- from docutils import nodes
18
- from docutils.parsers.rst import directives
19
- from sphinx.util import logging
20
- from sphinx.util.docutils import SphinxDirective
21
-
22
- from .config import get_config
23
- from .utils import (
24
- normalize_name,
25
- parse_csv_option,
26
- parse_list_option,
27
- path_to_root,
28
- slugify,
29
- )
30
-
31
- logger = logging.getLogger(__name__)
32
-
33
-
34
- def get_journey_registry(env):
35
- """Get or create the journey registry on the environment."""
36
- if not hasattr(env, "journey_registry"):
37
- env.journey_registry = {}
38
- return env.journey_registry
39
-
40
-
41
- def get_current_journey(env):
42
- """Get or create the current journey tracker on the environment."""
43
- if not hasattr(env, "journey_current"):
44
- env.journey_current = {}
45
- return env.journey_current
46
-
47
-
48
- class DefineJourneyDirective(SphinxDirective):
49
- """Define a journey with persona, intent, outcome, and metadata.
50
-
51
- Options:
52
- :persona: (required) The persona undertaking this journey.
53
-
54
- :intent: The user's underlying motivation - why they care about this
55
- journey. Answers "what does the persona want?" Focus on their
56
- goal, not the system's features. Renders as:
57
- "The [Persona] wants to [intent]."
58
-
59
- :outcome: The business value delivered when the journey succeeds.
60
- Answers "what does success look like?" Focus on measurable or
61
- observable results, not activities. Renders as:
62
- "Success means [outcome]."
63
-
64
- :depends-on: Comma-separated list of journey slugs that must be
65
- completed before this journey makes sense.
66
-
67
- :preconditions: Bullet list of conditions that must be true before
68
- starting this journey.
69
-
70
- :postconditions: Bullet list of conditions that will be true after
71
- completing this journey.
72
-
73
- Content:
74
- The directive body describes *what* the persona does (activities).
75
- If :intent: is provided, this becomes supplementary description.
76
- If :intent: is omitted, this becomes the goal (backward compatibility).
77
-
78
- Example::
79
-
80
- .. define-journey:: build-vocabulary
81
- :persona: Knowledge Curator
82
- :intent: Ensure consistent terminology across RBA programs
83
- :outcome: Semantic interoperability enabling automated compliance mapping
84
- :depends-on: operate-pipelines
85
- :preconditions:
86
- - Source materials available
87
- - SME accessible
88
- :postconditions:
89
- - SVC published and versioned
90
-
91
- Create a Sustainable Vocabulary Catalog from source materials.
92
- """
93
-
94
- required_arguments = 1 # journey slug
95
- has_content = True
96
- option_spec = {
97
- "persona": directives.unchanged_required,
98
- "intent": directives.unchanged,
99
- "outcome": directives.unchanged,
100
- "depends-on": directives.unchanged,
101
- "preconditions": directives.unchanged,
102
- "postconditions": directives.unchanged,
103
- }
104
-
105
- def run(self):
106
- from . import stories
107
-
108
- config = get_config()
109
- journey_slug = self.arguments[0]
110
- docname = self.env.docname
111
-
112
- # Parse options
113
- persona = self.options.get("persona", "").strip()
114
- intent = self.options.get("intent", "").strip()
115
- outcome = self.options.get("outcome", "").strip()
116
- depends_on = parse_csv_option(self.options.get("depends-on", ""))
117
- preconditions = parse_list_option(self.options.get("preconditions", ""))
118
- postconditions = parse_list_option(self.options.get("postconditions", ""))
119
-
120
- # Goal is the directive content (what they do)
121
- goal = "\n".join(self.content).strip()
122
-
123
- # Register the journey in environment
124
- journey_registry = get_journey_registry(self.env)
125
- current_journey = get_current_journey(self.env)
126
-
127
- journey_data = {
128
- "slug": journey_slug,
129
- "persona": persona,
130
- "persona_normalized": normalize_name(persona),
131
- "intent": intent,
132
- "outcome": outcome,
133
- "goal": goal,
134
- "depends_on": depends_on,
135
- "preconditions": preconditions,
136
- "postconditions": postconditions,
137
- "steps": [], # Will be populated by step-story/step-epic/step-phase
138
- "docname": docname,
139
- }
140
- journey_registry[journey_slug] = journey_data
141
- current_journey[docname] = journey_slug
142
-
143
- # Build output nodes
144
- result_nodes = []
145
-
146
- # Import story extension to get known personas
147
- _known_personas = stories.get_known_personas()
148
-
149
- # Calculate paths
150
- prefix = path_to_root(docname)
151
-
152
- # Intent and outcome as single paragraph:
153
- # "The [Persona] wants to [intent]. Success means [outcome]."
154
- if persona and intent:
155
- intro_para = nodes.paragraph()
156
- intro_para += nodes.Text("The ")
157
-
158
- persona_slug = slugify(persona)
159
- persona_path = (
160
- f"{prefix}{config.get_doc_path('personas')}/{persona_slug}.html"
161
- )
162
- persona_valid = normalize_name(persona) in _known_personas
163
-
164
- if persona_valid:
165
- persona_ref = nodes.reference("", "", refuri=persona_path)
166
- persona_ref += nodes.Text(persona)
167
- intro_para += persona_ref
168
- else:
169
- intro_para += nodes.Text(persona)
170
-
171
- intro_para += nodes.Text(" wants to ")
172
- # Lowercase first letter of intent to flow as sentence
173
- intent_text = intent[0].lower() + intent[1:] if intent else ""
174
- intro_para += nodes.Text(intent_text + ".")
175
-
176
- # Append outcome to same paragraph if present
177
- if outcome:
178
- intro_para += nodes.Text(" Success means ")
179
- outcome_text = outcome[0].lower() + outcome[1:] if outcome else ""
180
- intro_para += nodes.Text(outcome_text + ".")
181
-
182
- result_nodes.append(intro_para)
183
-
184
- # Goal paragraph (what they do) - only if intent not provided (backward compat)
185
- if goal and not intent:
186
- # Fall back to old format if no intent specified
187
- intro_para = nodes.paragraph()
188
- intro_para += nodes.Text("The goal of the ")
189
-
190
- persona_slug = slugify(persona)
191
- persona_path = (
192
- f"{prefix}{config.get_doc_path('personas')}/{persona_slug}.html"
193
- )
194
- persona_valid = normalize_name(persona) in _known_personas
195
-
196
- if persona_valid:
197
- persona_ref = nodes.reference("", "", refuri=persona_path)
198
- persona_ref += nodes.Text(persona)
199
- intro_para += persona_ref
200
- else:
201
- intro_para += nodes.Text(persona)
202
-
203
- intro_para += nodes.Text(" is to ")
204
- goal_text = goal[0].lower() + goal[1:] if goal else ""
205
- intro_para += nodes.Text(goal_text)
206
- result_nodes.append(intro_para)
207
- elif goal and intent:
208
- # If intent provided, goal becomes activity description
209
- goal_para = nodes.paragraph()
210
- goal_para += nodes.Text(goal)
211
- result_nodes.append(goal_para)
212
-
213
- # Add a placeholder for steps (will be filled in doctree-resolved)
214
- steps_placeholder = nodes.container()
215
- steps_placeholder["classes"].append("journey-steps-placeholder")
216
- steps_placeholder["journey_slug"] = journey_slug
217
- result_nodes.append(steps_placeholder)
218
-
219
- return result_nodes
220
-
221
-
222
- class StepStoryDirective(SphinxDirective):
223
- """Reference a story as a journey step.
224
-
225
- Usage::
226
-
227
- .. step-story:: Upload Scheme Documentation
228
- """
229
-
230
- required_arguments = 1
231
- final_argument_whitespace = True
232
-
233
- def run(self):
234
- story_title = self.arguments[0]
235
- docname = self.env.docname
236
-
237
- # Add to current journey's steps
238
- journey_registry = get_journey_registry(self.env)
239
- current_journey = get_current_journey(self.env)
240
-
241
- journey_slug = current_journey.get(docname)
242
- if journey_slug and journey_slug in journey_registry:
243
- journey_registry[journey_slug]["steps"].append(
244
- {
245
- "type": "story",
246
- "ref": story_title,
247
- }
248
- )
249
-
250
- # Return empty - rendering happens in doctree-resolved
251
- return []
252
-
253
-
254
- class StepEpicDirective(SphinxDirective):
255
- """Reference an epic as a journey step.
256
-
257
- Usage::
258
-
259
- .. step-epic:: vocabulary-management
260
- """
261
-
262
- required_arguments = 1
263
-
264
- def run(self):
265
- epic_slug = self.arguments[0]
266
- docname = self.env.docname
267
-
268
- # Add to current journey's steps
269
- journey_registry = get_journey_registry(self.env)
270
- current_journey = get_current_journey(self.env)
271
-
272
- journey_slug = current_journey.get(docname)
273
- if journey_slug and journey_slug in journey_registry:
274
- journey_registry[journey_slug]["steps"].append(
275
- {
276
- "type": "epic",
277
- "ref": epic_slug,
278
- }
279
- )
280
-
281
- # Return empty - rendering happens in doctree-resolved
282
- return []
283
-
284
-
285
- class StepPhaseDirective(SphinxDirective):
286
- """Optional grouping label for journey steps.
287
-
288
- Usage::
289
-
290
- .. step-phase:: Upload Sources
291
-
292
- Add reference materials to the knowledge base.
293
- """
294
-
295
- required_arguments = 1
296
- final_argument_whitespace = True
297
- has_content = True
298
-
299
- def run(self):
300
- phase_title = self.arguments[0]
301
- docname = self.env.docname
302
- description = "\n".join(self.content).strip()
303
-
304
- # Add to current journey's steps
305
- journey_registry = get_journey_registry(self.env)
306
- current_journey = get_current_journey(self.env)
307
-
308
- journey_slug = current_journey.get(docname)
309
- if journey_slug and journey_slug in journey_registry:
310
- journey_registry[journey_slug]["steps"].append(
311
- {
312
- "type": "phase",
313
- "ref": phase_title,
314
- "description": description,
315
- }
316
- )
317
-
318
- # Return empty - rendering happens in doctree-resolved
319
- return []
320
-
321
-
322
- class JourneyIndexDirective(SphinxDirective):
323
- """Render index of all journeys.
324
-
325
- Usage::
326
-
327
- .. journey-index::
328
- """
329
-
330
- def run(self):
331
- journey_registry = get_journey_registry(self.env)
332
-
333
- if not journey_registry:
334
- para = nodes.paragraph()
335
- para += nodes.emphasis(text="No journeys defined")
336
- return [para]
337
-
338
- result_nodes = []
339
- bullet_list = nodes.bullet_list()
340
-
341
- for slug in sorted(journey_registry.keys()):
342
- journey = journey_registry[slug]
343
-
344
- item = nodes.list_item()
345
- para = nodes.paragraph()
346
-
347
- # Link to journey
348
- journey_path = f"{slug}.html"
349
- journey_ref = nodes.reference("", "", refuri=journey_path)
350
- journey_ref += nodes.strong(text=slug.replace("-", " ").title())
351
- para += journey_ref
352
-
353
- # Persona in parentheses
354
- if journey["persona"]:
355
- para += nodes.Text(f" ({journey['persona']})")
356
-
357
- item += para
358
-
359
- # Intent as sub-paragraph (preferred), fall back to goal
360
- display_text = journey.get("intent") or journey.get("goal", "")
361
- if display_text:
362
- desc_para = nodes.paragraph()
363
- # Truncate if too long
364
- if len(display_text) > 100:
365
- display_text = display_text[:100] + "..."
366
- desc_para += nodes.Text(display_text)
367
- item += desc_para
368
-
369
- bullet_list += item
370
-
371
- result_nodes.append(bullet_list)
372
- return result_nodes
373
-
374
-
375
- class JourneyDependencyGraphPlaceholder(nodes.General, nodes.Element):
376
- """Placeholder node for journey dependency graph, replaced at doctree-resolved."""
377
-
378
- pass
379
-
380
-
381
- class JourneyDependencyGraphDirective(SphinxDirective):
382
- """Generate a PlantUML dependency graph from journey dependencies.
383
-
384
- Usage::
385
-
386
- .. journey-dependency-graph::
387
- """
388
-
389
- def run(self):
390
- # Return a placeholder that will be replaced in doctree-resolved
391
- # when all journeys have been registered
392
- node = JourneyDependencyGraphPlaceholder()
393
- return [node]
394
-
395
-
396
- def build_dependency_graph_node(env):
397
- """Build the PlantUML node for the journey dependency graph."""
398
- from sphinxcontrib.plantuml import plantuml
399
-
400
- journey_registry = get_journey_registry(env)
401
-
402
- if not journey_registry:
403
- para = nodes.paragraph()
404
- para += nodes.emphasis(text="No journeys defined")
405
- return para
406
-
407
- # Build PlantUML content
408
- lines = [
409
- "@startuml",
410
- "skinparam componentStyle rectangle",
411
- "skinparam defaultTextAlignment center",
412
- "",
413
- ]
414
-
415
- # Add all journeys as components
416
- for slug in sorted(journey_registry.keys()):
417
- title = slug.replace("-", " ").title()
418
- lines.append(f"[{title}] as {slug.replace('-', '_')}")
419
-
420
- lines.append("")
421
-
422
- # Add dependency arrows (A depends on B => A --> B)
423
- for slug, journey in sorted(journey_registry.items()):
424
- for dep in journey["depends_on"]:
425
- if dep in journey_registry:
426
- from_id = slug.replace("-", "_")
427
- to_id = dep.replace("-", "_")
428
- lines.append(f"{from_id} --> {to_id}")
429
-
430
- lines.append("")
431
- lines.append("@enduml")
432
-
433
- puml_content = "\n".join(lines)
434
-
435
- # Use the sphinxcontrib.plantuml extension's node type
436
- puml_node = plantuml(puml_content)
437
- puml_node["uml"] = puml_content
438
- puml_node["incdir"] = ""
439
- puml_node["filename"] = "journey-dependency-graph"
440
-
441
- return puml_node
442
-
443
-
444
- def process_dependency_graph_placeholder(app, doctree, docname):
445
- """Replace dependency graph placeholder with actual PlantUML node.
446
-
447
- Uses doctree-resolved event (fires after ALL documents read) to ensure
448
- the journey registry is complete when building the graph.
449
- """
450
- env = app.env
451
-
452
- for node in doctree.traverse(JourneyDependencyGraphPlaceholder):
453
- puml_node = build_dependency_graph_node(env)
454
- node.replace_self(puml_node)
455
-
456
-
457
- class JourneysForPersonaDirective(SphinxDirective):
458
- """List journeys for a specific persona.
459
-
460
- Usage::
461
-
462
- .. journeys-for-persona:: Analyst
463
- """
464
-
465
- required_arguments = 1
466
- final_argument_whitespace = True
467
-
468
- def run(self):
469
- config = get_config()
470
- persona_arg = self.arguments[0]
471
- persona_normalized = normalize_name(persona_arg)
472
- docname = self.env.docname
473
-
474
- journey_registry = get_journey_registry(self.env)
475
-
476
- # Find journeys for this persona
477
- journeys = [
478
- j
479
- for j in journey_registry.values()
480
- if j["persona_normalized"] == persona_normalized
481
- ]
482
-
483
- if not journeys:
484
- para = nodes.paragraph()
485
- para += nodes.emphasis(
486
- text=f"No journeys found for persona '{persona_arg}'"
487
- )
488
- return [para]
489
-
490
- prefix = path_to_root(docname)
491
-
492
- bullet_list = nodes.bullet_list()
493
-
494
- for journey in sorted(journeys, key=lambda j: j["slug"]):
495
- item = nodes.list_item()
496
- para = nodes.paragraph()
497
-
498
- journey_path = (
499
- f"{prefix}{config.get_doc_path('journeys')}/{journey['slug']}.html"
500
- )
501
- journey_ref = nodes.reference("", "", refuri=journey_path)
502
- journey_ref += nodes.Text(journey["slug"].replace("-", " ").title())
503
- para += journey_ref
504
-
505
- item += para
506
- bullet_list += item
507
-
508
- return [bullet_list]
509
-
510
-
511
- def clear_journey_state(app, env, docname):
512
- """Clear journey state when a document is re-read."""
513
- current_journey = get_current_journey(env)
514
- journey_registry = get_journey_registry(env)
515
-
516
- if docname in current_journey:
517
- del current_journey[docname]
518
-
519
- # Remove journeys defined in this document
520
- to_remove = [
521
- slug for slug, j in journey_registry.items() if j["docname"] == docname
522
- ]
523
- for slug in to_remove:
524
- del journey_registry[slug]
525
-
526
-
527
- def validate_journeys(app, env):
528
- """Validate journey references after all documents are read."""
529
- from . import stories
530
-
531
- journey_registry = get_journey_registry(env)
532
- _story_registry = stories.get_story_registry()
533
- _known_personas = stories.get_known_personas()
534
-
535
- story_titles = {normalize_name(s["feature"]) for s in _story_registry}
536
-
537
- for slug, journey in journey_registry.items():
538
- # Validate persona
539
- if journey["persona"] and journey["persona_normalized"] not in _known_personas:
540
- logger.warning(
541
- f"Journey '{slug}' references unknown persona: '{journey['persona']}'"
542
- )
543
-
544
- # Validate depends-on journeys
545
- for dep in journey["depends_on"]:
546
- if dep not in journey_registry:
547
- logger.warning(
548
- f"Journey '{slug}' references unknown dependency: '{dep}'"
549
- )
550
-
551
- # Validate story steps
552
- for step in journey["steps"]:
553
- if step["type"] == "story":
554
- if normalize_name(step["ref"]) not in story_titles:
555
- logger.warning(
556
- f"Journey '{slug}' references unknown story: '{step['ref']}'"
557
- )
558
-
559
-
560
- def build_story_node(story_title: str, docname: str):
561
- """Build a paragraph node for a story reference."""
562
- from . import stories
563
-
564
- config = get_config()
565
- _story_registry = stories.get_story_registry()
566
- _known_apps = stories.get_known_apps()
567
-
568
- # Find the story
569
- story_normalized = normalize_name(story_title)
570
- story = None
571
- for s in _story_registry:
572
- if normalize_name(s["feature"]) == story_normalized:
573
- story = s
574
- break
575
-
576
- para = nodes.paragraph()
577
-
578
- if story:
579
- # Create link to story
580
- para += stories.make_story_reference(story, docname, story["feature"])
581
-
582
- # Add app in parentheses
583
- prefix = path_to_root(docname)
584
- app_path = f"{prefix}{config.get_doc_path('applications')}/{story['app']}.html"
585
- app_valid = story["app_normalized"] in _known_apps
586
-
587
- para += nodes.Text(" (")
588
- if app_valid:
589
- app_ref = nodes.reference("", "", refuri=app_path)
590
- app_ref += nodes.Text(story["app"].replace("-", " ").title())
591
- para += app_ref
592
- else:
593
- para += nodes.Text(story["app"].replace("-", " ").title())
594
- para += nodes.Text(")")
595
- else:
596
- # Story not found - show warning style
597
- para += nodes.problematic(text=f"{story_title} [not found]")
598
-
599
- return para
600
-
601
-
602
- def build_epic_node(epic_slug: str, docname: str):
603
- """Build a paragraph node for an epic reference."""
604
- config = get_config()
605
- prefix = path_to_root(docname)
606
- epic_path = f"{prefix}{config.get_doc_path('epics')}/{epic_slug}.html"
607
-
608
- para = nodes.paragraph()
609
- epic_ref = nodes.reference("", "", refuri=epic_path)
610
- epic_ref += nodes.Text(epic_slug.replace("-", " ").title())
611
- para += epic_ref
612
- para += nodes.Text(" (epic)")
613
-
614
- return para
615
-
616
-
617
- def render_journey_steps(journey: dict, docname: str):
618
- """Render journey steps as a numbered list with phases grouping stories."""
619
- steps = journey["steps"]
620
- if not steps:
621
- return None
622
-
623
- # Group steps: phases contain subsequent stories/epics until next phase
624
- phases = []
625
- current_phase = None
626
-
627
- for step in steps:
628
- if step["type"] == "phase":
629
- # Start a new phase
630
- current_phase = {
631
- "title": step["ref"],
632
- "description": step.get("description", ""),
633
- "items": [],
634
- }
635
- phases.append(current_phase)
636
- elif step["type"] in ("story", "epic"):
637
- if current_phase is None:
638
- # Stories before any phase - create implicit phase
639
- current_phase = {"title": None, "description": "", "items": []}
640
- phases.append(current_phase)
641
- current_phase["items"].append(step)
642
-
643
- # Build enumerated list
644
- enum_list = nodes.enumerated_list()
645
- enum_list["enumtype"] = "arabic"
646
-
647
- for phase in phases:
648
- list_item = nodes.list_item()
649
-
650
- # Phase header paragraph: "Title — Description" or just items if no title
651
- if phase["title"]:
652
- header_para = nodes.paragraph()
653
- header_para += nodes.strong(text=phase["title"])
654
- if phase["description"]:
655
- header_para += nodes.Text(" — ")
656
- header_para += nodes.Text(phase["description"])
657
- list_item += header_para
658
-
659
- # Nested bullet list for stories/epics
660
- if phase["items"]:
661
- bullet_list = nodes.bullet_list()
662
- for item in phase["items"]:
663
- bullet_item = nodes.list_item()
664
- if item["type"] == "story":
665
- bullet_item += build_story_node(item["ref"], docname)
666
- elif item["type"] == "epic":
667
- bullet_item += build_epic_node(item["ref"], docname)
668
- bullet_list += bullet_item
669
- list_item += bullet_list
670
-
671
- enum_list += list_item
672
-
673
- return enum_list
674
-
675
-
676
- def make_labelled_list(
677
- term: str, items: list, env, docname: str = None, item_type: str = "text"
678
- ):
679
- """Create a labelled bullet list with term as heading.
680
-
681
- Uses 'simple' class on bullet list for compact vertical spacing.
682
-
683
- item_type can be 'text' (plain text) or 'journey' (journey links).
684
- """
685
- journey_registry = get_journey_registry(env)
686
- container = nodes.container()
687
-
688
- # Term as bold paragraph
689
- term_para = nodes.paragraph()
690
- term_para += nodes.strong(text=term)
691
- container += term_para
692
-
693
- # Bullet list
694
- bullet_list = nodes.bullet_list()
695
-
696
- for item in items:
697
- list_item = nodes.list_item()
698
- # Use inline container for content to avoid paragraph gaps
699
- inline = nodes.inline()
700
-
701
- if item_type == "journey":
702
- related_slug = item
703
- related_path = f"{related_slug}.html"
704
- if related_slug in journey_registry:
705
- ref = nodes.reference("", "", refuri=related_path)
706
- ref += nodes.Text(related_slug.replace("-", " ").title())
707
- inline += ref
708
- else:
709
- inline += nodes.Text(related_slug.replace("-", " ").title())
710
- inline += nodes.emphasis(text=" [not found]")
711
- else:
712
- inline += nodes.Text(item)
713
-
714
- list_item += inline
715
- bullet_list += list_item
716
-
717
- container += bullet_list
718
-
719
- return container
720
-
721
-
722
- def process_journey_steps(app, doctree):
723
- """Replace journey steps placeholder with rendered steps.
724
-
725
- Uses doctree-read event (fires after all directives parsed, before pickle)
726
- to ensure modifications are preserved in both HTML and LaTeX builds.
727
- """
728
- env = app.env
729
- docname = env.docname # Available during doctree-read
730
- current_journey = get_current_journey(env)
731
- journey_registry = get_journey_registry(env)
732
-
733
- journey_slug = current_journey.get(docname)
734
- if not journey_slug or journey_slug not in journey_registry:
735
- return
736
-
737
- journey = journey_registry[journey_slug]
738
-
739
- # Find and replace the steps placeholder
740
- for node in doctree.traverse(nodes.container):
741
- if "journey-steps-placeholder" in node.get("classes", []):
742
- steps_node = render_journey_steps(journey, docname)
743
- if steps_node:
744
- node.replace_self(steps_node)
745
- else:
746
- node.replace_self([])
747
- break
748
-
749
- # Add preconditions if present (after steps)
750
- if journey["preconditions"]:
751
- doctree += make_labelled_list("Preconditions", journey["preconditions"], env)
752
-
753
- # Add postconditions if present
754
- if journey["postconditions"]:
755
- doctree += make_labelled_list("Postconditions", journey["postconditions"], env)
756
-
757
- # Add depends-on journeys if present
758
- if journey["depends_on"]:
759
- doctree += make_labelled_list(
760
- "Depends On", journey["depends_on"], env, docname, item_type="journey"
761
- )
762
-
763
- # Add depended-on-by journeys (inferred from other journeys' depends_on)
764
- depended_on_by = [
765
- j["slug"] for j in journey_registry.values() if journey_slug in j["depends_on"]
766
- ]
767
- if depended_on_by:
768
- doctree += make_labelled_list(
769
- "Depended On By", sorted(depended_on_by), env, docname, item_type="journey"
770
- )
771
-
772
-
773
- def setup(app):
774
- app.connect("env-purge-doc", clear_journey_state)
775
- app.connect("env-check-consistency", validate_journeys)
776
- # Use doctree-read (not doctree-resolved) so modifications persist in pickled
777
- # doctrees for both HTML and LaTeX builders
778
- app.connect("doctree-read", process_journey_steps)
779
- # Dependency graph uses doctree-resolved (fires after ALL docs read)
780
- # so journey_registry is complete when building the graph
781
- app.connect("doctree-resolved", process_dependency_graph_placeholder)
782
-
783
- app.add_directive("define-journey", DefineJourneyDirective)
784
- app.add_directive("step-story", StepStoryDirective)
785
- app.add_directive("step-epic", StepEpicDirective)
786
- app.add_directive("step-phase", StepPhaseDirective)
787
- app.add_directive("journey-index", JourneyIndexDirective)
788
- app.add_directive("journey-dependency-graph", JourneyDependencyGraphDirective)
789
- app.add_directive("journeys-for-persona", JourneysForPersonaDirective)
790
-
791
- app.add_node(JourneyDependencyGraphPlaceholder)
792
-
793
- return {
794
- "version": "1.0",
795
- "parallel_read_safe": False, # Uses global state
796
- "parallel_write_safe": True,
797
- }