julee 0.1.5__py3-none-any.whl → 0.1.7__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 (108) hide show
  1. julee/__init__.py +1 -1
  2. julee/contrib/polling/apps/worker/pipelines.py +3 -1
  3. julee/contrib/polling/tests/unit/apps/worker/test_pipelines.py +3 -0
  4. julee/docs/sphinx_hcd/__init__.py +146 -13
  5. julee/docs/sphinx_hcd/domain/__init__.py +5 -0
  6. julee/docs/sphinx_hcd/domain/models/__init__.py +32 -0
  7. julee/docs/sphinx_hcd/domain/models/accelerator.py +152 -0
  8. julee/docs/sphinx_hcd/domain/models/app.py +151 -0
  9. julee/docs/sphinx_hcd/domain/models/code_info.py +121 -0
  10. julee/docs/sphinx_hcd/domain/models/epic.py +79 -0
  11. julee/docs/sphinx_hcd/domain/models/integration.py +230 -0
  12. julee/docs/sphinx_hcd/domain/models/journey.py +222 -0
  13. julee/docs/sphinx_hcd/domain/models/persona.py +106 -0
  14. julee/docs/sphinx_hcd/domain/models/story.py +128 -0
  15. julee/docs/sphinx_hcd/domain/repositories/__init__.py +25 -0
  16. julee/docs/sphinx_hcd/domain/repositories/accelerator.py +98 -0
  17. julee/docs/sphinx_hcd/domain/repositories/app.py +57 -0
  18. julee/docs/sphinx_hcd/domain/repositories/base.py +89 -0
  19. julee/docs/sphinx_hcd/domain/repositories/code_info.py +69 -0
  20. julee/docs/sphinx_hcd/domain/repositories/epic.py +62 -0
  21. julee/docs/sphinx_hcd/domain/repositories/integration.py +79 -0
  22. julee/docs/sphinx_hcd/domain/repositories/journey.py +106 -0
  23. julee/docs/sphinx_hcd/domain/repositories/story.py +68 -0
  24. julee/docs/sphinx_hcd/domain/use_cases/__init__.py +64 -0
  25. julee/docs/sphinx_hcd/domain/use_cases/derive_personas.py +166 -0
  26. julee/docs/sphinx_hcd/domain/use_cases/resolve_accelerator_references.py +236 -0
  27. julee/docs/sphinx_hcd/domain/use_cases/resolve_app_references.py +144 -0
  28. julee/docs/sphinx_hcd/domain/use_cases/resolve_story_references.py +121 -0
  29. julee/docs/sphinx_hcd/parsers/__init__.py +48 -0
  30. julee/docs/sphinx_hcd/parsers/ast.py +150 -0
  31. julee/docs/sphinx_hcd/parsers/gherkin.py +155 -0
  32. julee/docs/sphinx_hcd/parsers/yaml.py +184 -0
  33. julee/docs/sphinx_hcd/repositories/__init__.py +4 -0
  34. julee/docs/sphinx_hcd/repositories/memory/__init__.py +25 -0
  35. julee/docs/sphinx_hcd/repositories/memory/accelerator.py +86 -0
  36. julee/docs/sphinx_hcd/repositories/memory/app.py +45 -0
  37. julee/docs/sphinx_hcd/repositories/memory/base.py +106 -0
  38. julee/docs/sphinx_hcd/repositories/memory/code_info.py +59 -0
  39. julee/docs/sphinx_hcd/repositories/memory/epic.py +54 -0
  40. julee/docs/sphinx_hcd/repositories/memory/integration.py +70 -0
  41. julee/docs/sphinx_hcd/repositories/memory/journey.py +96 -0
  42. julee/docs/sphinx_hcd/repositories/memory/story.py +63 -0
  43. julee/docs/sphinx_hcd/sphinx/__init__.py +28 -0
  44. julee/docs/sphinx_hcd/sphinx/adapters.py +116 -0
  45. julee/docs/sphinx_hcd/sphinx/context.py +163 -0
  46. julee/docs/sphinx_hcd/sphinx/directives/__init__.py +160 -0
  47. julee/docs/sphinx_hcd/sphinx/directives/accelerator.py +576 -0
  48. julee/docs/sphinx_hcd/sphinx/directives/app.py +349 -0
  49. julee/docs/sphinx_hcd/sphinx/directives/base.py +211 -0
  50. julee/docs/sphinx_hcd/sphinx/directives/epic.py +434 -0
  51. julee/docs/sphinx_hcd/sphinx/directives/integration.py +220 -0
  52. julee/docs/sphinx_hcd/sphinx/directives/journey.py +642 -0
  53. julee/docs/sphinx_hcd/sphinx/directives/persona.py +345 -0
  54. julee/docs/sphinx_hcd/sphinx/directives/story.py +575 -0
  55. julee/docs/sphinx_hcd/sphinx/event_handlers/__init__.py +16 -0
  56. julee/docs/sphinx_hcd/sphinx/event_handlers/builder_inited.py +31 -0
  57. julee/docs/sphinx_hcd/sphinx/event_handlers/doctree_read.py +27 -0
  58. julee/docs/sphinx_hcd/sphinx/event_handlers/doctree_resolved.py +43 -0
  59. julee/docs/sphinx_hcd/sphinx/event_handlers/env_purge_doc.py +42 -0
  60. julee/docs/sphinx_hcd/sphinx/initialization.py +139 -0
  61. julee/docs/sphinx_hcd/tests/__init__.py +9 -0
  62. julee/docs/sphinx_hcd/tests/conftest.py +6 -0
  63. julee/docs/sphinx_hcd/tests/domain/__init__.py +1 -0
  64. julee/docs/sphinx_hcd/tests/domain/models/__init__.py +1 -0
  65. julee/docs/sphinx_hcd/tests/domain/models/test_accelerator.py +266 -0
  66. julee/docs/sphinx_hcd/tests/domain/models/test_app.py +258 -0
  67. julee/docs/sphinx_hcd/tests/domain/models/test_code_info.py +231 -0
  68. julee/docs/sphinx_hcd/tests/domain/models/test_epic.py +163 -0
  69. julee/docs/sphinx_hcd/tests/domain/models/test_integration.py +327 -0
  70. julee/docs/sphinx_hcd/tests/domain/models/test_journey.py +249 -0
  71. julee/docs/sphinx_hcd/tests/domain/models/test_persona.py +172 -0
  72. julee/docs/sphinx_hcd/tests/domain/models/test_story.py +216 -0
  73. julee/docs/sphinx_hcd/tests/domain/use_cases/__init__.py +1 -0
  74. julee/docs/sphinx_hcd/tests/domain/use_cases/test_derive_personas.py +314 -0
  75. julee/docs/sphinx_hcd/tests/domain/use_cases/test_resolve_accelerator_references.py +476 -0
  76. julee/docs/sphinx_hcd/tests/domain/use_cases/test_resolve_app_references.py +265 -0
  77. julee/docs/sphinx_hcd/tests/domain/use_cases/test_resolve_story_references.py +229 -0
  78. julee/docs/sphinx_hcd/tests/integration/__init__.py +1 -0
  79. julee/docs/sphinx_hcd/tests/parsers/__init__.py +1 -0
  80. julee/docs/sphinx_hcd/tests/parsers/test_ast.py +298 -0
  81. julee/docs/sphinx_hcd/tests/parsers/test_gherkin.py +282 -0
  82. julee/docs/sphinx_hcd/tests/parsers/test_yaml.py +496 -0
  83. julee/docs/sphinx_hcd/tests/repositories/__init__.py +1 -0
  84. julee/docs/sphinx_hcd/tests/repositories/test_accelerator.py +298 -0
  85. julee/docs/sphinx_hcd/tests/repositories/test_app.py +218 -0
  86. julee/docs/sphinx_hcd/tests/repositories/test_base.py +151 -0
  87. julee/docs/sphinx_hcd/tests/repositories/test_code_info.py +253 -0
  88. julee/docs/sphinx_hcd/tests/repositories/test_epic.py +237 -0
  89. julee/docs/sphinx_hcd/tests/repositories/test_integration.py +268 -0
  90. julee/docs/sphinx_hcd/tests/repositories/test_journey.py +294 -0
  91. julee/docs/sphinx_hcd/tests/repositories/test_story.py +236 -0
  92. julee/docs/sphinx_hcd/tests/sphinx/__init__.py +1 -0
  93. julee/docs/sphinx_hcd/tests/sphinx/directives/__init__.py +1 -0
  94. julee/docs/sphinx_hcd/tests/sphinx/directives/test_base.py +160 -0
  95. julee/docs/sphinx_hcd/tests/sphinx/test_adapters.py +176 -0
  96. julee/docs/sphinx_hcd/tests/sphinx/test_context.py +257 -0
  97. {julee-0.1.5.dist-info → julee-0.1.7.dist-info}/METADATA +2 -1
  98. {julee-0.1.5.dist-info → julee-0.1.7.dist-info}/RECORD +101 -16
  99. julee/docs/sphinx_hcd/accelerators.py +0 -1175
  100. julee/docs/sphinx_hcd/apps.py +0 -518
  101. julee/docs/sphinx_hcd/epics.py +0 -453
  102. julee/docs/sphinx_hcd/integrations.py +0 -310
  103. julee/docs/sphinx_hcd/journeys.py +0 -797
  104. julee/docs/sphinx_hcd/personas.py +0 -457
  105. julee/docs/sphinx_hcd/stories.py +0 -960
  106. {julee-0.1.5.dist-info → julee-0.1.7.dist-info}/WHEEL +0 -0
  107. {julee-0.1.5.dist-info → julee-0.1.7.dist-info}/licenses/LICENSE +0 -0
  108. {julee-0.1.5.dist-info → julee-0.1.7.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,434 @@
1
+ """Epic directives for sphinx_hcd.
2
+
3
+ Provides directives for defining and cross-referencing epics:
4
+ - define-epic: Define an epic with description
5
+ - epic-story: Reference a story as part of the epic
6
+ - epic-index: Render index of all epics
7
+ - epics-for-persona: List epics for a persona (derived from stories)
8
+ """
9
+
10
+ from docutils import nodes
11
+
12
+ from ...domain.models.epic import Epic
13
+ from ...domain.use_cases import derive_personas, get_epics_for_persona
14
+ from ...utils import normalize_name, path_to_root
15
+ from .base import HCDDirective
16
+
17
+
18
+ class EpicIndexPlaceholder(nodes.General, nodes.Element):
19
+ """Placeholder node for epic index, replaced at doctree-resolved."""
20
+
21
+ pass
22
+
23
+
24
+ class EpicsForPersonaPlaceholder(nodes.General, nodes.Element):
25
+ """Placeholder node for epics-for-persona, replaced at doctree-resolved."""
26
+
27
+ pass
28
+
29
+
30
+ class DefineEpicDirective(HCDDirective):
31
+ """Define an epic with description.
32
+
33
+ Usage::
34
+
35
+ .. define-epic:: credential-creation
36
+
37
+ Covers the creation, attachment, and verification of UNTP-compliant
38
+ credentials including DPPs, DFRs, and DCCs.
39
+ """
40
+
41
+ required_arguments = 1 # epic slug
42
+ has_content = True
43
+ option_spec = {}
44
+
45
+ def run(self):
46
+ epic_slug = self.arguments[0]
47
+ docname = self.env.docname
48
+ description = "\n".join(self.content).strip()
49
+
50
+ # Create and register the epic entity
51
+ epic = Epic(
52
+ slug=epic_slug,
53
+ description=description,
54
+ story_refs=[], # Will be populated by epic-story
55
+ docname=docname,
56
+ )
57
+
58
+ # Add to repository
59
+ self.hcd_context.epic_repo.save(epic)
60
+
61
+ # Track current epic in environment for epic-story
62
+ if not hasattr(self.env, "epic_current"):
63
+ self.env.epic_current = {}
64
+ self.env.epic_current[docname] = epic_slug
65
+
66
+ # Build output nodes
67
+ result_nodes = []
68
+
69
+ if description:
70
+ desc_para = nodes.paragraph(text=description)
71
+ result_nodes.append(desc_para)
72
+
73
+ # Add a placeholder for stories (filled in doctree-resolved)
74
+ stories_placeholder = nodes.container()
75
+ stories_placeholder["classes"].append("epic-stories-placeholder")
76
+ stories_placeholder["epic_slug"] = epic_slug
77
+ result_nodes.append(stories_placeholder)
78
+
79
+ return result_nodes
80
+
81
+
82
+ class EpicStoryDirective(HCDDirective):
83
+ """Reference a story as part of the epic.
84
+
85
+ Usage::
86
+
87
+ .. epic-story:: Create DPP from Product Sheet
88
+ """
89
+
90
+ required_arguments = 1
91
+ final_argument_whitespace = True
92
+
93
+ def run(self):
94
+ story_title = self.arguments[0]
95
+ docname = self.env.docname
96
+
97
+ # Get current epic
98
+ epic_current = getattr(self.env, "epic_current", {})
99
+ epic_slug = epic_current.get(docname)
100
+
101
+ if epic_slug:
102
+ # Get the epic from repository and update story_refs
103
+ epic = self.hcd_context.epic_repo.get(epic_slug)
104
+ if epic:
105
+ # Add story to epic's story_refs
106
+ if story_title not in epic.story_refs:
107
+ epic.story_refs.append(story_title)
108
+
109
+ # Return empty - rendering happens in doctree-resolved
110
+ return []
111
+
112
+
113
+ class EpicIndexDirective(HCDDirective):
114
+ """Render index of all epics.
115
+
116
+ Usage::
117
+
118
+ .. epic-index::
119
+ """
120
+
121
+ def run(self):
122
+ # Return placeholder - actual rendering in doctree-resolved
123
+ node = EpicIndexPlaceholder()
124
+ return [node]
125
+
126
+
127
+ class EpicsForPersonaDirective(HCDDirective):
128
+ """List epics for a specific persona (derived from stories).
129
+
130
+ Usage::
131
+
132
+ .. epics-for-persona:: Member Implementer
133
+ """
134
+
135
+ required_arguments = 1
136
+ final_argument_whitespace = True
137
+
138
+ def run(self):
139
+ # Return placeholder - actual rendering in doctree-resolved
140
+ node = EpicsForPersonaPlaceholder()
141
+ node["persona"] = self.arguments[0]
142
+ return [node]
143
+
144
+
145
+ def render_epic_stories(epic: Epic, docname: str, hcd_context):
146
+ """Render epic stories as a simple bullet list."""
147
+ from ...config import get_config
148
+
149
+ config = get_config()
150
+ prefix = path_to_root(docname)
151
+
152
+ # Get all stories
153
+ all_stories = hcd_context.story_repo.list_all()
154
+ all_apps = hcd_context.app_repo.list_all()
155
+ known_apps = {normalize_name(a.name) for a in all_apps}
156
+
157
+ # Find stories referenced by this epic
158
+ stories_data = []
159
+ for story_title in epic.story_refs:
160
+ story_normalized = normalize_name(story_title)
161
+ for story in all_stories:
162
+ if normalize_name(story.feature_title) == story_normalized:
163
+ stories_data.append(story)
164
+ break
165
+
166
+ if not stories_data:
167
+ return None
168
+
169
+ result_nodes = []
170
+
171
+ # Stories heading
172
+ stories_heading = nodes.paragraph()
173
+ stories_heading += nodes.strong(text="Stories")
174
+ result_nodes.append(stories_heading)
175
+
176
+ # Simple bullet list
177
+ story_list = nodes.bullet_list()
178
+
179
+ for story in sorted(stories_data, key=lambda s: s.feature_title.lower()):
180
+ story_item = nodes.list_item()
181
+ story_para = nodes.paragraph()
182
+
183
+ # Build story link manually
184
+ story_doc = f"{config.get_doc_path('stories')}/{story.app_slug}"
185
+ story_ref_uri = _build_relative_uri(docname, story_doc, story.slug)
186
+ story_ref = nodes.reference("", "", refuri=story_ref_uri)
187
+ story_ref += nodes.Text(story.i_want)
188
+ story_para += story_ref
189
+
190
+ # App in parentheses
191
+ story_para += nodes.Text(" (")
192
+ app_path = (
193
+ f"{prefix}{config.get_doc_path('applications')}/{story.app_slug}.html"
194
+ )
195
+ app_valid = story.app_normalized in known_apps
196
+
197
+ if app_valid:
198
+ app_ref = nodes.reference("", "", refuri=app_path)
199
+ app_ref += nodes.Text(story.app_slug.replace("-", " ").title())
200
+ story_para += app_ref
201
+ else:
202
+ story_para += nodes.Text(story.app_slug.replace("-", " ").title())
203
+
204
+ story_para += nodes.Text(")")
205
+
206
+ story_item += story_para
207
+ story_list += story_item
208
+
209
+ result_nodes.append(story_list)
210
+
211
+ return result_nodes
212
+
213
+
214
+ def _build_relative_uri(from_docname: str, target_doc: str, anchor: str = None) -> str:
215
+ """Build a relative URI from one doc to another."""
216
+ from_parts = from_docname.split("/")
217
+ target_parts = target_doc.split("/")
218
+
219
+ common = 0
220
+ for i in range(min(len(from_parts), len(target_parts))):
221
+ if from_parts[i] == target_parts[i]:
222
+ common += 1
223
+ else:
224
+ break
225
+
226
+ up_levels = len(from_parts) - common - 1
227
+ down_path = "/".join(target_parts[common:])
228
+
229
+ if up_levels > 0:
230
+ rel_path = "../" * up_levels + down_path + ".html"
231
+ else:
232
+ rel_path = down_path + ".html"
233
+
234
+ if anchor:
235
+ return f"{rel_path}#{anchor}"
236
+ return rel_path
237
+
238
+
239
+ def build_epic_index(env, docname: str, hcd_context):
240
+ """Build the epic index listing all epics, plus unassigned stories."""
241
+ from ...config import get_config
242
+
243
+ config = get_config()
244
+ prefix = path_to_root(docname)
245
+
246
+ all_epics = hcd_context.epic_repo.list_all()
247
+ all_stories = hcd_context.story_repo.list_all()
248
+ all_apps = hcd_context.app_repo.list_all()
249
+ known_apps = {normalize_name(a.name) for a in all_apps}
250
+
251
+ if not all_epics:
252
+ para = nodes.paragraph()
253
+ para += nodes.emphasis(text="No epics defined")
254
+ return [para]
255
+
256
+ result_nodes = []
257
+ bullet_list = nodes.bullet_list()
258
+
259
+ # Collect all stories assigned to epics
260
+ assigned_stories = set()
261
+ for epic in all_epics:
262
+ for story_title in epic.story_refs:
263
+ assigned_stories.add(normalize_name(story_title))
264
+
265
+ for epic in sorted(all_epics, key=lambda e: e.slug):
266
+ item = nodes.list_item()
267
+ para = nodes.paragraph()
268
+
269
+ # Link to epic
270
+ epic_path = f"{epic.slug}.html"
271
+ epic_ref = nodes.reference("", "", refuri=epic_path)
272
+ epic_ref += nodes.Text(epic.slug.replace("-", " ").title())
273
+ para += epic_ref
274
+
275
+ # Story count
276
+ story_count = len(epic.story_refs)
277
+ para += nodes.Text(f" ({story_count} stories)")
278
+
279
+ item += para
280
+ bullet_list += item
281
+
282
+ result_nodes.append(bullet_list)
283
+
284
+ # Find unassigned stories
285
+ unassigned_stories = []
286
+ for story in all_stories:
287
+ if normalize_name(story.feature_title) not in assigned_stories:
288
+ unassigned_stories.append(story)
289
+
290
+ if unassigned_stories:
291
+ heading = nodes.paragraph()
292
+ heading += nodes.strong(text="Unassigned Stories")
293
+ result_nodes.append(heading)
294
+
295
+ intro = nodes.paragraph()
296
+ intro += nodes.Text(
297
+ f"{len(unassigned_stories)} stories not yet assigned to an epic:"
298
+ )
299
+ result_nodes.append(intro)
300
+
301
+ unassigned_list = nodes.bullet_list()
302
+ for story in sorted(unassigned_stories, key=lambda s: s.feature_title.lower()):
303
+ item = nodes.list_item()
304
+ para = nodes.paragraph()
305
+
306
+ # Story link
307
+ story_doc = f"{config.get_doc_path('stories')}/{story.app_slug}"
308
+ story_ref_uri = _build_relative_uri(docname, story_doc, story.slug)
309
+ story_ref = nodes.reference("", "", refuri=story_ref_uri)
310
+ story_ref += nodes.Text(story.i_want)
311
+ para += story_ref
312
+
313
+ # App in parentheses
314
+ para += nodes.Text(" (")
315
+ app_path = (
316
+ f"{prefix}{config.get_doc_path('applications')}/{story.app_slug}.html"
317
+ )
318
+ app_valid = story.app_normalized in known_apps
319
+
320
+ if app_valid:
321
+ app_ref = nodes.reference("", "", refuri=app_path)
322
+ app_ref += nodes.Text(story.app_slug.replace("-", " ").title())
323
+ para += app_ref
324
+ else:
325
+ para += nodes.Text(story.app_slug.replace("-", " ").title())
326
+
327
+ para += nodes.Text(")")
328
+
329
+ item += para
330
+ unassigned_list += item
331
+
332
+ result_nodes.append(unassigned_list)
333
+
334
+ return result_nodes
335
+
336
+
337
+ def build_epics_for_persona(env, docname: str, persona_arg: str, hcd_context):
338
+ """Build list of epics for a persona."""
339
+ from ...config import get_config
340
+
341
+ config = get_config()
342
+ prefix = path_to_root(docname)
343
+
344
+ all_stories = hcd_context.story_repo.list_all()
345
+ all_epics = hcd_context.epic_repo.list_all()
346
+
347
+ # Derive personas to get their epic associations
348
+ personas = derive_personas(all_stories, all_epics)
349
+ persona_normalized = normalize_name(persona_arg)
350
+
351
+ # Find the persona
352
+ persona = None
353
+ for p in personas:
354
+ if p.normalized_name == persona_normalized:
355
+ persona = p
356
+ break
357
+
358
+ if not persona:
359
+ para = nodes.paragraph()
360
+ para += nodes.emphasis(text=f"No epics found for persona '{persona_arg}'")
361
+ return [para]
362
+
363
+ # Get epics for this persona
364
+ matching_epics = get_epics_for_persona(persona, all_epics, all_stories)
365
+
366
+ if not matching_epics:
367
+ para = nodes.paragraph()
368
+ para += nodes.emphasis(text=f"No epics found for persona '{persona_arg}'")
369
+ return [para]
370
+
371
+ bullet_list = nodes.bullet_list()
372
+
373
+ for epic in sorted(matching_epics, key=lambda e: e.slug):
374
+ item = nodes.list_item()
375
+ para = nodes.paragraph()
376
+
377
+ epic_path = f"{prefix}{config.get_doc_path('epics')}/{epic.slug}.html"
378
+ epic_ref = nodes.reference("", "", refuri=epic_path)
379
+ epic_ref += nodes.Text(epic.slug.replace("-", " ").title())
380
+ para += epic_ref
381
+
382
+ item += para
383
+ bullet_list += item
384
+
385
+ return [bullet_list]
386
+
387
+
388
+ def clear_epic_state(app, env, docname):
389
+ """Clear epic state when a document is re-read."""
390
+ from ..context import get_hcd_context
391
+
392
+ # Clear current epic tracker
393
+ if hasattr(env, "epic_current") and docname in env.epic_current:
394
+ del env.epic_current[docname]
395
+
396
+ # Clear epics from this document via repository
397
+ hcd_context = get_hcd_context(app)
398
+ hcd_context.epic_repo.run_async(
399
+ hcd_context.epic_repo.async_repo.clear_by_docname(docname)
400
+ )
401
+
402
+
403
+ def process_epic_placeholders(app, doctree, docname):
404
+ """Replace epic placeholders with rendered content."""
405
+ from ..context import get_hcd_context
406
+
407
+ env = app.env
408
+ hcd_context = get_hcd_context(app)
409
+ epic_current = getattr(env, "epic_current", {})
410
+
411
+ # Process epic stories placeholder
412
+ epic_slug = epic_current.get(docname)
413
+ if epic_slug:
414
+ epic = hcd_context.epic_repo.get(epic_slug)
415
+ if epic:
416
+ for node in doctree.traverse(nodes.container):
417
+ if "epic-stories-placeholder" in node.get("classes", []):
418
+ stories_nodes = render_epic_stories(epic, docname, hcd_context)
419
+ if stories_nodes:
420
+ node.replace_self(stories_nodes)
421
+ else:
422
+ node.replace_self([])
423
+ break
424
+
425
+ # Process epic index placeholder
426
+ for node in doctree.traverse(EpicIndexPlaceholder):
427
+ index_node = build_epic_index(env, docname, hcd_context)
428
+ node.replace_self(index_node)
429
+
430
+ # Process epics-for-persona placeholder
431
+ for node in doctree.traverse(EpicsForPersonaPlaceholder):
432
+ persona = node["persona"]
433
+ epics_node = build_epics_for_persona(env, docname, persona, hcd_context)
434
+ node.replace_self(epics_node)
@@ -0,0 +1,220 @@
1
+ """Integration directives for sphinx_hcd.
2
+
3
+ Provides directives to render integration information with external dependencies.
4
+
5
+ Provides directives:
6
+ - define-integration: Render integration info from YAML
7
+ - integration-index: Generate index with architecture diagram
8
+ """
9
+
10
+ import os
11
+
12
+ from docutils import nodes
13
+
14
+ from ...domain.models.integration import Direction
15
+ from .base import HCDDirective
16
+
17
+
18
+ class DefineIntegrationPlaceholder(nodes.General, nodes.Element):
19
+ """Placeholder node for define-integration, replaced at doctree-resolved."""
20
+
21
+ pass
22
+
23
+
24
+ class IntegrationIndexPlaceholder(nodes.General, nodes.Element):
25
+ """Placeholder node for integration-index, replaced at doctree-resolved."""
26
+
27
+ pass
28
+
29
+
30
+ class DefineIntegrationDirective(HCDDirective):
31
+ """Render integration info from YAML manifest.
32
+
33
+ Usage::
34
+
35
+ .. define-integration:: pilot-data-collection
36
+ """
37
+
38
+ required_arguments = 1
39
+
40
+ def run(self):
41
+ slug = self.arguments[0]
42
+
43
+ # Track documented integrations
44
+ if not hasattr(self.env, "documented_integrations"):
45
+ self.env.documented_integrations = set()
46
+ self.env.documented_integrations.add(slug)
47
+
48
+ node = DefineIntegrationPlaceholder()
49
+ node["integration_slug"] = slug
50
+ return [node]
51
+
52
+
53
+ class IntegrationIndexDirective(HCDDirective):
54
+ """Generate integration index with architecture diagram.
55
+
56
+ Usage::
57
+
58
+ .. integration-index::
59
+ """
60
+
61
+ def run(self):
62
+ return [IntegrationIndexPlaceholder()]
63
+
64
+
65
+ def build_integration_content(slug: str, docname: str, hcd_context):
66
+ """Build content nodes for an integration page."""
67
+ from sphinx.addnodes import seealso
68
+
69
+ integration = hcd_context.integration_repo.get(slug)
70
+
71
+ if not integration:
72
+ para = nodes.paragraph()
73
+ para += nodes.problematic(text=f"Integration '{slug}' not found")
74
+ return [para]
75
+
76
+ result_nodes = []
77
+
78
+ # Description
79
+ if integration.description:
80
+ desc_para = nodes.paragraph()
81
+ desc_para += nodes.Text(integration.description)
82
+ result_nodes.append(desc_para)
83
+
84
+ # Seealso with metadata
85
+ seealso_node = seealso()
86
+
87
+ # Direction
88
+ direction_labels = {
89
+ Direction.INBOUND: "Inbound (data source)",
90
+ Direction.OUTBOUND: "Outbound (data sink)",
91
+ Direction.BIDIRECTIONAL: "Bidirectional",
92
+ }
93
+ dir_para = nodes.paragraph()
94
+ dir_para += nodes.strong(text="Direction: ")
95
+ dir_para += nodes.Text(
96
+ direction_labels.get(integration.direction, str(integration.direction))
97
+ )
98
+ seealso_node += dir_para
99
+
100
+ # Module
101
+ mod_para = nodes.paragraph()
102
+ mod_para += nodes.strong(text="Module: ")
103
+ mod_para += nodes.literal(text=f"integrations.{integration.module}")
104
+ seealso_node += mod_para
105
+
106
+ # External dependencies
107
+ if integration.depends_on:
108
+ deps_para = nodes.paragraph()
109
+ deps_para += nodes.strong(text="Depends On: ")
110
+ for i, dep in enumerate(integration.depends_on):
111
+ if dep.url:
112
+ ref = nodes.reference("", "", refuri=dep.url)
113
+ ref += nodes.Text(dep.name)
114
+ deps_para += ref
115
+ else:
116
+ deps_para += nodes.Text(dep.name)
117
+ if i < len(integration.depends_on) - 1:
118
+ deps_para += nodes.Text(", ")
119
+ seealso_node += deps_para
120
+
121
+ result_nodes.append(seealso_node)
122
+ return result_nodes
123
+
124
+
125
+ def build_integration_index(docname: str, hcd_context):
126
+ """Build integration index with architecture diagram."""
127
+ try:
128
+ from sphinxcontrib.plantuml import plantuml
129
+ except ImportError:
130
+ para = nodes.paragraph()
131
+ para += nodes.emphasis(text="PlantUML extension not available")
132
+ return [para]
133
+
134
+ all_integrations = hcd_context.integration_repo.list_all()
135
+
136
+ if not all_integrations:
137
+ para = nodes.paragraph()
138
+ para += nodes.emphasis(text="No integrations defined")
139
+ return [para]
140
+
141
+ # Build PlantUML diagram
142
+ lines = [
143
+ "@startuml",
144
+ "skinparam componentStyle rectangle",
145
+ "skinparam defaultTextAlignment center",
146
+ "skinparam component {",
147
+ " BackgroundColor<<integration>> LightBlue",
148
+ " BackgroundColor<<external>> LightYellow",
149
+ " BackgroundColor<<core>> LightGreen",
150
+ "}",
151
+ "",
152
+ 'title "Integration Architecture"',
153
+ "",
154
+ "' Core system",
155
+ 'component "Julee\\nSolution" as core <<core>>',
156
+ "",
157
+ "' Integrations and their external dependencies",
158
+ ]
159
+
160
+ for integration in sorted(all_integrations, key=lambda i: i.slug):
161
+ int_id = integration.slug.replace("-", "_")
162
+ lines.append(f'component "{integration.name}" as {int_id} <<integration>>')
163
+
164
+ for dep in integration.depends_on:
165
+ dep_id = dep.name.lower().replace(" ", "_").replace("-", "_")
166
+ dep_label = dep.name
167
+ if dep.description:
168
+ dep_label += f"\\n({dep.description})"
169
+ lines.append(f'component "{dep_label}" as {dep_id} <<external>>')
170
+
171
+ lines.append("")
172
+ lines.append("' Relationships")
173
+
174
+ for integration in sorted(all_integrations, key=lambda i: i.slug):
175
+ int_id = integration.slug.replace("-", "_")
176
+
177
+ # Core to/from integration
178
+ if integration.direction == Direction.INBOUND:
179
+ lines.append(f"{int_id} --> core")
180
+ elif integration.direction == Direction.OUTBOUND:
181
+ lines.append(f"core --> {int_id}")
182
+ else:
183
+ lines.append(f"core <--> {int_id}")
184
+
185
+ # Integration to external dependencies
186
+ for dep in integration.depends_on:
187
+ dep_id = dep.name.lower().replace(" ", "_").replace("-", "_")
188
+ if integration.direction == Direction.INBOUND:
189
+ lines.append(f"{dep_id} --> {int_id}")
190
+ elif integration.direction == Direction.OUTBOUND:
191
+ lines.append(f"{int_id} --> {dep_id}")
192
+ else:
193
+ lines.append(f"{int_id} <--> {dep_id}")
194
+
195
+ lines.append("")
196
+ lines.append("@enduml")
197
+
198
+ puml_source = "\n".join(lines)
199
+ node = plantuml(puml_source)
200
+ node["uml"] = puml_source
201
+ node["incdir"] = os.path.dirname(docname)
202
+ node["filename"] = os.path.basename(docname) + ".rst"
203
+
204
+ return [node]
205
+
206
+
207
+ def process_integration_placeholders(app, doctree, docname):
208
+ """Replace integration placeholders after all documents are read."""
209
+ from ..context import get_hcd_context
210
+
211
+ hcd_context = get_hcd_context(app)
212
+
213
+ for node in doctree.traverse(DefineIntegrationPlaceholder):
214
+ slug = node["integration_slug"]
215
+ content = build_integration_content(slug, docname, hcd_context)
216
+ node.replace_self(content)
217
+
218
+ for node in doctree.traverse(IntegrationIndexPlaceholder):
219
+ content = build_integration_index(docname, hcd_context)
220
+ node.replace_self(content)