julee 0.1.1__py3-none-any.whl → 0.1.3__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 (157) hide show
  1. julee/api/app.py +9 -8
  2. julee/api/dependencies.py +15 -15
  3. julee/api/requests.py +10 -9
  4. julee/api/responses.py +2 -1
  5. julee/api/routers/__init__.py +5 -5
  6. julee/api/routers/assembly_specifications.py +5 -4
  7. julee/api/routers/documents.py +1 -1
  8. julee/api/routers/knowledge_service_configs.py +4 -3
  9. julee/api/routers/knowledge_service_queries.py +7 -6
  10. julee/api/routers/system.py +4 -3
  11. julee/api/routers/workflows.py +4 -5
  12. julee/api/services/system_initialization.py +6 -6
  13. julee/api/tests/routers/test_assembly_specifications.py +4 -3
  14. julee/api/tests/routers/test_documents.py +5 -4
  15. julee/api/tests/routers/test_knowledge_service_configs.py +7 -6
  16. julee/api/tests/routers/test_knowledge_service_queries.py +4 -3
  17. julee/api/tests/routers/test_system.py +5 -4
  18. julee/api/tests/routers/test_workflows.py +5 -4
  19. julee/api/tests/test_app.py +5 -4
  20. julee/api/tests/test_dependencies.py +3 -2
  21. julee/api/tests/test_requests.py +2 -1
  22. julee/contrib/__init__.py +15 -0
  23. julee/contrib/polling/__init__.py +47 -0
  24. julee/contrib/polling/domain/__init__.py +17 -0
  25. julee/contrib/polling/domain/models/__init__.py +13 -0
  26. julee/contrib/polling/domain/models/polling_config.py +39 -0
  27. julee/contrib/polling/domain/services/__init__.py +11 -0
  28. julee/contrib/polling/domain/services/poller.py +39 -0
  29. julee/contrib/polling/infrastructure/__init__.py +15 -0
  30. julee/contrib/polling/infrastructure/services/__init__.py +12 -0
  31. julee/contrib/polling/infrastructure/services/polling/__init__.py +12 -0
  32. julee/contrib/polling/infrastructure/services/polling/http/__init__.py +12 -0
  33. julee/contrib/polling/infrastructure/services/polling/http/http_poller_service.py +80 -0
  34. julee/contrib/polling/infrastructure/temporal/__init__.py +20 -0
  35. julee/contrib/polling/infrastructure/temporal/activities.py +42 -0
  36. julee/contrib/polling/infrastructure/temporal/activity_names.py +20 -0
  37. julee/contrib/polling/infrastructure/temporal/proxies.py +45 -0
  38. julee/contrib/polling/tests/__init__.py +6 -0
  39. julee/contrib/polling/tests/unit/__init__.py +6 -0
  40. julee/contrib/polling/tests/unit/infrastructure/__init__.py +7 -0
  41. julee/contrib/polling/tests/unit/infrastructure/services/__init__.py +6 -0
  42. julee/contrib/polling/tests/unit/infrastructure/services/polling/__init__.py +6 -0
  43. julee/contrib/polling/tests/unit/infrastructure/services/polling/http/__init__.py +7 -0
  44. julee/contrib/polling/tests/unit/infrastructure/services/polling/http/test_http_poller_service.py +163 -0
  45. julee/docs/__init__.py +5 -0
  46. julee/docs/sphinx_hcd/__init__.py +82 -0
  47. julee/docs/sphinx_hcd/accelerators.py +1078 -0
  48. julee/docs/sphinx_hcd/apps.py +499 -0
  49. julee/docs/sphinx_hcd/config.py +148 -0
  50. julee/docs/sphinx_hcd/epics.py +448 -0
  51. julee/docs/sphinx_hcd/integrations.py +306 -0
  52. julee/docs/sphinx_hcd/journeys.py +783 -0
  53. julee/docs/sphinx_hcd/personas.py +435 -0
  54. julee/docs/sphinx_hcd/stories.py +932 -0
  55. julee/docs/sphinx_hcd/utils.py +180 -0
  56. julee/domain/models/__init__.py +5 -6
  57. julee/domain/models/assembly/assembly.py +7 -7
  58. julee/domain/models/assembly/tests/factories.py +2 -1
  59. julee/domain/models/assembly/tests/test_assembly.py +16 -13
  60. julee/domain/models/assembly_specification/assembly_specification.py +11 -10
  61. julee/domain/models/assembly_specification/knowledge_service_query.py +14 -9
  62. julee/domain/models/assembly_specification/tests/factories.py +2 -1
  63. julee/domain/models/assembly_specification/tests/test_assembly_specification.py +9 -6
  64. julee/domain/models/assembly_specification/tests/test_knowledge_service_query.py +3 -1
  65. julee/domain/models/custom_fields/content_stream.py +3 -2
  66. julee/domain/models/custom_fields/tests/test_custom_fields.py +2 -1
  67. julee/domain/models/document/document.py +12 -10
  68. julee/domain/models/document/tests/factories.py +3 -2
  69. julee/domain/models/document/tests/test_document.py +6 -3
  70. julee/domain/models/knowledge_service_config/knowledge_service_config.py +4 -4
  71. julee/domain/models/policy/__init__.py +4 -4
  72. julee/domain/models/policy/document_policy_validation.py +17 -17
  73. julee/domain/models/policy/policy.py +12 -10
  74. julee/domain/models/policy/tests/factories.py +2 -1
  75. julee/domain/models/policy/tests/test_document_policy_validation.py +3 -1
  76. julee/domain/models/policy/tests/test_policy.py +2 -1
  77. julee/domain/repositories/__init__.py +3 -3
  78. julee/domain/repositories/assembly.py +3 -1
  79. julee/domain/repositories/assembly_specification.py +2 -0
  80. julee/domain/repositories/base.py +33 -16
  81. julee/domain/repositories/document.py +3 -1
  82. julee/domain/repositories/document_policy_validation.py +3 -1
  83. julee/domain/repositories/knowledge_service_config.py +2 -0
  84. julee/domain/repositories/knowledge_service_query.py +1 -0
  85. julee/domain/repositories/policy.py +3 -1
  86. julee/domain/use_cases/decorators.py +3 -2
  87. julee/domain/use_cases/extract_assemble_data.py +23 -13
  88. julee/domain/use_cases/initialize_system_data.py +13 -13
  89. julee/domain/use_cases/tests/test_extract_assemble_data.py +10 -10
  90. julee/domain/use_cases/tests/test_initialize_system_data.py +2 -2
  91. julee/domain/use_cases/tests/test_validate_document.py +11 -11
  92. julee/domain/use_cases/validate_document.py +26 -15
  93. julee/maintenance/__init__.py +1 -0
  94. julee/maintenance/release.py +188 -0
  95. julee/repositories/__init__.py +4 -1
  96. julee/repositories/memory/assembly.py +6 -5
  97. julee/repositories/memory/assembly_specification.py +9 -9
  98. julee/repositories/memory/base.py +12 -11
  99. julee/repositories/memory/document.py +8 -7
  100. julee/repositories/memory/document_policy_validation.py +7 -6
  101. julee/repositories/memory/knowledge_service_config.py +9 -7
  102. julee/repositories/memory/knowledge_service_query.py +9 -7
  103. julee/repositories/memory/policy.py +6 -5
  104. julee/repositories/memory/tests/test_document.py +6 -4
  105. julee/repositories/memory/tests/test_document_policy_validation.py +2 -1
  106. julee/repositories/memory/tests/test_policy.py +2 -1
  107. julee/repositories/minio/assembly.py +4 -4
  108. julee/repositories/minio/assembly_specification.py +6 -8
  109. julee/repositories/minio/client.py +22 -25
  110. julee/repositories/minio/document.py +11 -11
  111. julee/repositories/minio/document_policy_validation.py +5 -5
  112. julee/repositories/minio/knowledge_service_config.py +8 -8
  113. julee/repositories/minio/knowledge_service_query.py +6 -9
  114. julee/repositories/minio/policy.py +4 -4
  115. julee/repositories/minio/tests/fake_client.py +11 -9
  116. julee/repositories/minio/tests/test_assembly.py +3 -1
  117. julee/repositories/minio/tests/test_assembly_specification.py +2 -1
  118. julee/repositories/minio/tests/test_client_protocol.py +5 -5
  119. julee/repositories/minio/tests/test_document.py +7 -6
  120. julee/repositories/minio/tests/test_document_policy_validation.py +3 -1
  121. julee/repositories/minio/tests/test_knowledge_service_config.py +4 -2
  122. julee/repositories/minio/tests/test_knowledge_service_query.py +3 -2
  123. julee/repositories/minio/tests/test_policy.py +3 -1
  124. julee/repositories/temporal/activities.py +5 -5
  125. julee/repositories/temporal/proxies.py +5 -5
  126. julee/services/knowledge_service/__init__.py +1 -2
  127. julee/services/knowledge_service/anthropic/knowledge_service.py +8 -7
  128. julee/services/knowledge_service/anthropic/tests/test_knowledge_service.py +11 -10
  129. julee/services/knowledge_service/factory.py +8 -8
  130. julee/services/knowledge_service/knowledge_service.py +22 -18
  131. julee/services/knowledge_service/memory/knowledge_service.py +13 -12
  132. julee/services/knowledge_service/memory/test_knowledge_service.py +10 -7
  133. julee/services/knowledge_service/test_factory.py +11 -10
  134. julee/services/temporal/activities.py +10 -10
  135. julee/services/temporal/proxies.py +2 -2
  136. julee/util/domain.py +6 -6
  137. julee/util/repos/minio/file_storage.py +8 -9
  138. julee/util/repos/temporal/client_proxies/file_storage.py +3 -4
  139. julee/util/repos/temporal/data_converter.py +6 -6
  140. julee/util/repos/temporal/minio_file_storage.py +1 -1
  141. julee/util/repos/temporal/proxies/file_storage.py +2 -3
  142. julee/util/repositories.py +6 -7
  143. julee/util/temporal/activities.py +1 -1
  144. julee/util/temporal/decorators.py +28 -33
  145. julee/util/tests/test_decorators.py +13 -15
  146. julee/util/validation/repository.py +3 -3
  147. julee/util/validation/type_guards.py +12 -11
  148. julee/worker.py +9 -8
  149. julee/workflows/__init__.py +2 -2
  150. julee/workflows/extract_assemble.py +2 -1
  151. julee/workflows/validate_document.py +3 -2
  152. {julee-0.1.1.dist-info → julee-0.1.3.dist-info}/METADATA +4 -1
  153. julee-0.1.3.dist-info/RECORD +197 -0
  154. julee-0.1.1.dist-info/RECORD +0 -161
  155. {julee-0.1.1.dist-info → julee-0.1.3.dist-info}/WHEEL +0 -0
  156. {julee-0.1.1.dist-info → julee-0.1.3.dist-info}/licenses/LICENSE +0 -0
  157. {julee-0.1.1.dist-info → julee-0.1.3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,499 @@
1
+ """Sphinx extension for applications.
2
+
3
+ Scans apps/*/app.yaml for canonical app definitions and provides directives
4
+ to render app information with derived data (personas, journeys, epics, stories).
5
+
6
+ Provides directives:
7
+ - define-app: Render app info from YAML + derived data
8
+ - app-index: Generate index tables grouped by type
9
+ - apps-for-persona: List apps for a persona
10
+ """
11
+
12
+ import yaml
13
+ from pathlib import Path
14
+ from docutils import nodes
15
+ from sphinx.util.docutils import SphinxDirective
16
+ from sphinx.util import logging
17
+
18
+ from .config import get_config
19
+ from .utils import normalize_name, slugify, path_to_root
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # Global registry populated at build init
24
+ _app_registry: dict = {}
25
+
26
+
27
+ def get_app_registry() -> dict:
28
+ """Get the app registry."""
29
+ return _app_registry
30
+
31
+
32
+ def get_documented_apps(env) -> set:
33
+ """Get documented apps set from env, creating if needed."""
34
+ if not hasattr(env, 'documented_apps'):
35
+ env.documented_apps = set()
36
+ return env.documented_apps
37
+
38
+
39
+ def scan_app_manifests(app):
40
+ """Scan apps/*/app.yaml and build the app registry."""
41
+ global _app_registry
42
+ _app_registry = {}
43
+
44
+ config = get_config()
45
+ project_root = config.project_root
46
+ apps_dir = config.get_path('app_manifests')
47
+
48
+ if not apps_dir.exists():
49
+ logger.info(f"apps/ directory not found at {apps_dir} - no app manifests to index")
50
+ return
51
+
52
+ # Scan for app.yaml files
53
+ for app_dir in apps_dir.iterdir():
54
+ if not app_dir.is_dir():
55
+ continue
56
+
57
+ manifest_path = app_dir / "app.yaml"
58
+ if not manifest_path.exists():
59
+ continue
60
+
61
+ app_slug = app_dir.name
62
+
63
+ try:
64
+ with open(manifest_path) as f:
65
+ manifest = yaml.safe_load(f)
66
+ except Exception as e:
67
+ logger.warning(f"Could not read {manifest_path}: {e}")
68
+ continue
69
+
70
+ _app_registry[app_slug] = {
71
+ 'slug': app_slug,
72
+ 'name': manifest.get('name', app_slug.replace('-', ' ').title()),
73
+ 'type': manifest.get('type', 'unknown'),
74
+ 'status': manifest.get('status'),
75
+ 'description': manifest.get('description', '').strip(),
76
+ 'accelerators': manifest.get('accelerators', []),
77
+ 'manifest_path': str(manifest_path),
78
+ }
79
+
80
+ logger.info(f"Indexed {len(_app_registry)} apps from manifests")
81
+
82
+
83
+ def validate_apps(app, env):
84
+ """Validate app coverage after all documents are read."""
85
+ from . import stories
86
+
87
+ documented_apps = get_documented_apps(env)
88
+ _apps_with_stories = stories.get_apps_with_stories()
89
+
90
+ # Check for apps without documentation
91
+ for app_slug in _app_registry:
92
+ if app_slug not in documented_apps:
93
+ logger.warning(
94
+ f"App '{app_slug}' from apps/{app_slug}/app.yaml has no docs page. "
95
+ f"Create applications/{app_slug}.rst with '.. define-app:: {app_slug}' "
96
+ f"(or run 'make clean html' if the file exists)"
97
+ )
98
+
99
+ # Check for apps without stories
100
+ for app_slug in _app_registry:
101
+ if app_slug not in _apps_with_stories:
102
+ logger.info(
103
+ f"App '{app_slug}' has no stories yet "
104
+ f"(add .feature files to tests/e2e/{app_slug}/features/)"
105
+ )
106
+
107
+ # Check for documented apps without manifests
108
+ for app_slug in documented_apps:
109
+ if app_slug not in _app_registry:
110
+ logger.warning(
111
+ f"App '{app_slug}' documented but has no manifest. "
112
+ f"Create apps/{app_slug}/app.yaml"
113
+ )
114
+
115
+
116
+ def get_personas_for_app(app_slug: str, story_registry: list) -> list[str]:
117
+ """Get personas that have stories for this app."""
118
+ personas = set()
119
+ app_normalized = normalize_name(app_slug)
120
+ for story in story_registry:
121
+ if story['app_normalized'] == app_normalized:
122
+ personas.add(story['persona'])
123
+ return sorted(personas)
124
+
125
+
126
+ def get_journeys_for_app(app_slug: str, story_registry: list, journey_registry: dict) -> list[str]:
127
+ """Get journeys that include stories from this app."""
128
+ # Get story titles for this app
129
+ app_normalized = normalize_name(app_slug)
130
+ app_story_titles = {
131
+ normalize_name(s['feature'])
132
+ for s in story_registry
133
+ if s['app_normalized'] == app_normalized
134
+ }
135
+
136
+ # Find journeys that reference these stories
137
+ journeys = []
138
+ for slug, journey in journey_registry.items():
139
+ for step in journey.get('steps', []):
140
+ if step.get('type') == 'story':
141
+ if normalize_name(step['ref']) in app_story_titles:
142
+ journeys.append(slug)
143
+ break
144
+
145
+ return sorted(set(journeys))
146
+
147
+
148
+ def get_epics_for_app(app_slug: str, story_registry: list, epic_registry: dict) -> list[str]:
149
+ """Get epics that include stories from this app."""
150
+ # Get story titles for this app
151
+ app_normalized = normalize_name(app_slug)
152
+ app_story_titles = {
153
+ normalize_name(s['feature'])
154
+ for s in story_registry
155
+ if s['app_normalized'] == app_normalized
156
+ }
157
+
158
+ # Find epics that reference these stories
159
+ epics = []
160
+ for slug, epic in epic_registry.items():
161
+ for story_title in epic.get('stories', []):
162
+ if normalize_name(story_title) in app_story_titles:
163
+ epics.append(slug)
164
+ break
165
+
166
+ return sorted(set(epics))
167
+
168
+
169
+ class DefineAppDirective(SphinxDirective):
170
+ """Render app info from YAML manifest plus derived data.
171
+
172
+ Usage::
173
+
174
+ .. define-app:: credential-tool
175
+ """
176
+
177
+ required_arguments = 1
178
+
179
+ def run(self):
180
+ app_slug = self.arguments[0]
181
+
182
+ # Register that this app is documented (env-based for incremental builds)
183
+ get_documented_apps(self.env).add(app_slug)
184
+
185
+ # Return placeholder - actual rendering in doctree-resolved
186
+ node = DefineAppPlaceholder()
187
+ node['app_slug'] = app_slug
188
+ return [node]
189
+
190
+
191
+ class DefineAppPlaceholder(nodes.General, nodes.Element):
192
+ """Placeholder node for define-app, replaced at doctree-resolved."""
193
+ pass
194
+
195
+
196
+ class AppIndexDirective(SphinxDirective):
197
+ """Generate index tables grouped by app type.
198
+
199
+ Usage::
200
+
201
+ .. app-index::
202
+ """
203
+
204
+ def run(self):
205
+ node = AppIndexPlaceholder()
206
+ return [node]
207
+
208
+
209
+ class AppIndexPlaceholder(nodes.General, nodes.Element):
210
+ """Placeholder node for app-index, replaced at doctree-resolved."""
211
+ pass
212
+
213
+
214
+ class AppsForPersonaDirective(SphinxDirective):
215
+ """List apps for a specific persona.
216
+
217
+ Usage::
218
+
219
+ .. apps-for-persona:: Member Implementer
220
+ """
221
+
222
+ required_arguments = 1
223
+ final_argument_whitespace = True
224
+
225
+ def run(self):
226
+ node = AppsForPersonaPlaceholder()
227
+ node['persona'] = self.arguments[0]
228
+ return [node]
229
+
230
+
231
+ class AppsForPersonaPlaceholder(nodes.General, nodes.Element):
232
+ """Placeholder node for apps-for-persona, replaced at doctree-resolved."""
233
+ pass
234
+
235
+
236
+ def build_app_content(app_slug: str, docname: str, story_registry: list,
237
+ journey_registry: dict, epic_registry: dict, known_personas: set):
238
+ """Build the content nodes for an app."""
239
+ from sphinx.addnodes import seealso
240
+
241
+ config = get_config()
242
+
243
+ if app_slug not in _app_registry:
244
+ para = nodes.paragraph()
245
+ para += nodes.problematic(text=f"App '{app_slug}' not found in apps/")
246
+ return [para]
247
+
248
+ app_data = _app_registry[app_slug]
249
+ result_nodes = []
250
+
251
+ prefix = path_to_root(docname)
252
+
253
+ # Description first
254
+ if app_data['description']:
255
+ desc_para = nodes.paragraph()
256
+ desc_para += nodes.Text(app_data['description'])
257
+ result_nodes.append(desc_para)
258
+
259
+ # Stories count and link
260
+ app_stories = [s for s in story_registry if s['app_normalized'] == normalize_name(app_slug)]
261
+
262
+ if app_stories:
263
+ story_count = len(app_stories)
264
+ stories_para = nodes.paragraph()
265
+ stories_para += nodes.Text(f"The {app_data['name']} has ")
266
+ story_path = f"{prefix}{config.get_doc_path('stories')}/{app_slug}.html"
267
+ ref = nodes.reference("", "", refuri=story_path)
268
+ ref += nodes.Text(f"{story_count} stories")
269
+ stories_para += ref
270
+ stories_para += nodes.Text(".")
271
+ result_nodes.append(stories_para)
272
+
273
+ # Build seealso box with metadata
274
+ seealso_node = seealso()
275
+
276
+ # Type
277
+ type_labels = {
278
+ 'staff': 'Staff Application',
279
+ 'external': 'External Application',
280
+ 'member-tool': 'Member Tool',
281
+ }
282
+ type_para = nodes.paragraph()
283
+ type_para += nodes.strong(text="Type: ")
284
+ type_para += nodes.Text(type_labels.get(app_data['type'], app_data['type']))
285
+ seealso_node += type_para
286
+
287
+ # Status (if present)
288
+ if app_data['status']:
289
+ status_para = nodes.paragraph()
290
+ status_para += nodes.strong(text="Status: ")
291
+ status_para += nodes.Text(app_data['status'])
292
+ seealso_node += status_para
293
+
294
+ # Personas (derived from stories)
295
+ personas = get_personas_for_app(app_slug, story_registry)
296
+ if personas:
297
+ persona_para = nodes.paragraph()
298
+ persona_para += nodes.strong(text="Personas: ")
299
+ for i, persona in enumerate(personas):
300
+ persona_slug = slugify(persona)
301
+ persona_path = f"{prefix}{config.get_doc_path('personas')}/{persona_slug}.html"
302
+ persona_normalized = normalize_name(persona)
303
+
304
+ if persona_normalized in known_personas:
305
+ ref = nodes.reference("", "", refuri=persona_path)
306
+ ref += nodes.Text(persona)
307
+ persona_para += ref
308
+ else:
309
+ persona_para += nodes.Text(persona)
310
+
311
+ if i < len(personas) - 1:
312
+ persona_para += nodes.Text(", ")
313
+
314
+ seealso_node += persona_para
315
+
316
+ # Related Journeys
317
+ journeys = get_journeys_for_app(app_slug, story_registry, journey_registry)
318
+ if journeys:
319
+ journey_para = nodes.paragraph()
320
+ journey_para += nodes.strong(text="Journeys: ")
321
+ for i, journey_slug in enumerate(journeys):
322
+ journey_path = f"{prefix}{config.get_doc_path('journeys')}/{journey_slug}.html"
323
+ ref = nodes.reference("", "", refuri=journey_path)
324
+ ref += nodes.Text(journey_slug.replace("-", " ").title())
325
+ journey_para += ref
326
+ if i < len(journeys) - 1:
327
+ journey_para += nodes.Text(", ")
328
+ seealso_node += journey_para
329
+
330
+ # Related Epics
331
+ epics = get_epics_for_app(app_slug, story_registry, epic_registry)
332
+ if epics:
333
+ epic_para = nodes.paragraph()
334
+ epic_para += nodes.strong(text="Epics: ")
335
+ for i, epic_slug in enumerate(epics):
336
+ epic_path = f"{prefix}{config.get_doc_path('epics')}/{epic_slug}.html"
337
+ ref = nodes.reference("", "", refuri=epic_path)
338
+ ref += nodes.Text(epic_slug.replace("-", " ").title())
339
+ epic_para += ref
340
+ if i < len(epics) - 1:
341
+ epic_para += nodes.Text(", ")
342
+ seealso_node += epic_para
343
+
344
+ result_nodes.append(seealso_node)
345
+
346
+ return result_nodes
347
+
348
+
349
+ def build_app_index(docname: str, story_registry: list):
350
+ """Build the app index grouped by type."""
351
+ config = get_config()
352
+
353
+ if not _app_registry:
354
+ para = nodes.paragraph()
355
+ para += nodes.emphasis(text="No apps defined")
356
+ return [para]
357
+
358
+ prefix = path_to_root(docname)
359
+
360
+ # Group apps by type
361
+ by_type = {'staff': [], 'external': [], 'member-tool': []}
362
+ for slug, app_data in _app_registry.items():
363
+ app_type = app_data['type']
364
+ if app_type in by_type:
365
+ by_type[app_type].append((slug, app_data))
366
+ else:
367
+ by_type.setdefault(app_type, []).append((slug, app_data))
368
+
369
+ result_nodes = []
370
+
371
+ type_sections = [
372
+ ('staff', 'Staff Applications'),
373
+ ('external', 'External Applications'),
374
+ ('member-tool', 'Member Tools'),
375
+ ]
376
+
377
+ for type_key, type_label in type_sections:
378
+ apps = by_type.get(type_key, [])
379
+ if not apps:
380
+ continue
381
+
382
+ # Section heading
383
+ heading = nodes.paragraph()
384
+ heading += nodes.strong(text=type_label)
385
+ result_nodes.append(heading)
386
+
387
+ # App list
388
+ app_list = nodes.bullet_list()
389
+
390
+ for slug, app_data in sorted(apps, key=lambda x: x[1]['name']):
391
+ item = nodes.list_item()
392
+ para = nodes.paragraph()
393
+
394
+ # Link to app
395
+ app_path = f"{slug}.html"
396
+ ref = nodes.reference("", "", refuri=app_path)
397
+ ref += nodes.Text(app_data['name'])
398
+ para += ref
399
+
400
+ # Personas
401
+ personas = get_personas_for_app(slug, story_registry)
402
+ if personas:
403
+ para += nodes.Text(f" ({', '.join(personas)})")
404
+
405
+ item += para
406
+ app_list += item
407
+
408
+ result_nodes.append(app_list)
409
+
410
+ return result_nodes
411
+
412
+
413
+ def build_apps_for_persona(docname: str, persona_arg: str, story_registry: list):
414
+ """Build list of apps for a persona."""
415
+ config = get_config()
416
+ persona_normalized = normalize_name(persona_arg)
417
+
418
+ prefix = path_to_root(docname)
419
+
420
+ # Find apps that have stories for this persona
421
+ matching_apps = []
422
+ for slug in _app_registry:
423
+ personas = get_personas_for_app(slug, story_registry)
424
+ persona_names_normalized = {normalize_name(p) for p in personas}
425
+ if persona_normalized in persona_names_normalized:
426
+ matching_apps.append((slug, _app_registry[slug]))
427
+
428
+ if not matching_apps:
429
+ para = nodes.paragraph()
430
+ para += nodes.emphasis(text=f"No apps found for persona '{persona_arg}'")
431
+ return [para]
432
+
433
+ bullet_list = nodes.bullet_list()
434
+
435
+ for slug, app_data in sorted(matching_apps, key=lambda x: x[1]['name']):
436
+ item = nodes.list_item()
437
+ para = nodes.paragraph()
438
+
439
+ app_path = f"{prefix}{config.get_doc_path('applications')}/{slug}.html"
440
+ ref = nodes.reference("", "", refuri=app_path)
441
+ ref += nodes.Text(app_data['name'])
442
+ para += ref
443
+
444
+ item += para
445
+ bullet_list += item
446
+
447
+ return [bullet_list]
448
+
449
+
450
+ def process_app_placeholders(app, doctree, docname):
451
+ """Replace app placeholders with rendered content."""
452
+ from . import stories, journeys, epics
453
+
454
+ env = app.env
455
+
456
+ _story_registry = stories.get_story_registry()
457
+ _known_personas = stories.get_known_personas()
458
+ journey_registry = journeys.get_journey_registry(env)
459
+ epic_registry = epics.get_epic_registry(env)
460
+
461
+ # Process define-app placeholders
462
+ for node in doctree.traverse(DefineAppPlaceholder):
463
+ app_slug = node['app_slug']
464
+ content = build_app_content(
465
+ app_slug, docname, _story_registry,
466
+ journey_registry, epic_registry, _known_personas
467
+ )
468
+ node.replace_self(content)
469
+
470
+ # Process app-index placeholders
471
+ for node in doctree.traverse(AppIndexPlaceholder):
472
+ content = build_app_index(docname, _story_registry)
473
+ node.replace_self(content)
474
+
475
+ # Process apps-for-persona placeholders
476
+ for node in doctree.traverse(AppsForPersonaPlaceholder):
477
+ persona = node['persona']
478
+ content = build_apps_for_persona(docname, persona, _story_registry)
479
+ node.replace_self(content)
480
+
481
+
482
+ def setup(app):
483
+ app.connect("builder-inited", scan_app_manifests)
484
+ app.connect("env-check-consistency", validate_apps)
485
+ app.connect("doctree-resolved", process_app_placeholders)
486
+
487
+ app.add_directive("define-app", DefineAppDirective)
488
+ app.add_directive("app-index", AppIndexDirective)
489
+ app.add_directive("apps-for-persona", AppsForPersonaDirective)
490
+
491
+ app.add_node(DefineAppPlaceholder)
492
+ app.add_node(AppIndexPlaceholder)
493
+ app.add_node(AppsForPersonaPlaceholder)
494
+
495
+ return {
496
+ "version": "1.0",
497
+ "parallel_read_safe": False,
498
+ "parallel_write_safe": True,
499
+ }
@@ -0,0 +1,148 @@
1
+ """Configuration for sphinx_hcd extension.
2
+
3
+ Provides defaults matching the RBA solution layout, with ability to override
4
+ via sphinx_hcd config dict in conf.py.
5
+ """
6
+
7
+ from copy import deepcopy
8
+ from pathlib import Path
9
+
10
+ DEFAULT_CONFIG = {
11
+ 'paths': {
12
+ # Where to find Gherkin feature files: {app}/features/*.feature
13
+ 'feature_files': 'tests/e2e/',
14
+ # Where to find app manifests: */app.yaml
15
+ 'app_manifests': 'apps/',
16
+ # Where to find integration manifests: */integration.yaml
17
+ 'integration_manifests': 'src/integrations/',
18
+ # Where to find bounded context code: {slug}/ directories
19
+ 'bounded_contexts': 'src/',
20
+ },
21
+ 'docs_structure': {
22
+ # RST file locations relative to docs root
23
+ 'applications': 'applications',
24
+ 'personas': 'users/personas',
25
+ 'journeys': 'users/journeys',
26
+ 'epics': 'users/epics',
27
+ 'accelerators': 'domain/accelerators',
28
+ 'integrations': 'integrations',
29
+ 'stories': 'users/stories',
30
+ },
31
+ }
32
+
33
+
34
+ def config_factory() -> dict:
35
+ """Return a fresh config dict populated with defaults.
36
+
37
+ Usage in conf.py::
38
+
39
+ from julee.docs.sphinx_hcd import config_factory
40
+
41
+ sphinx_hcd = config_factory()
42
+ sphinx_hcd['paths']['feature_files'] = 'tests/bdd/'
43
+
44
+ Returns:
45
+ A deep copy of DEFAULT_CONFIG that can be modified.
46
+ """
47
+ return deepcopy(DEFAULT_CONFIG)
48
+
49
+
50
+ def _deep_merge(base: dict, override: dict) -> dict:
51
+ """Deep merge override into base, returning new dict."""
52
+ result = deepcopy(base)
53
+ for key, value in override.items():
54
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
55
+ result[key] = _deep_merge(result[key], value)
56
+ else:
57
+ result[key] = deepcopy(value)
58
+ return result
59
+
60
+
61
+ class HCDConfig:
62
+ """Configuration holder for sphinx_hcd extension.
63
+
64
+ Provides access to paths and doc structure settings, resolving paths
65
+ relative to the project root.
66
+ """
67
+
68
+ def __init__(self, app):
69
+ """Initialize config from Sphinx app.
70
+
71
+ Args:
72
+ app: Sphinx application instance
73
+ """
74
+ self._app = app
75
+ self._docs_dir = Path(app.srcdir)
76
+ self._project_root = self._docs_dir.parent
77
+
78
+ # Merge user config with defaults
79
+ user_config = getattr(app.config, 'sphinx_hcd', {}) or {}
80
+ self._config = _deep_merge(DEFAULT_CONFIG, user_config)
81
+
82
+ @property
83
+ def project_root(self) -> Path:
84
+ """Project root directory (parent of docs/)."""
85
+ return self._project_root
86
+
87
+ @property
88
+ def docs_dir(self) -> Path:
89
+ """Documentation source directory."""
90
+ return self._docs_dir
91
+
92
+ def get_path(self, key: str) -> Path:
93
+ """Get an absolute path from the paths config.
94
+
95
+ Args:
96
+ key: Path key (e.g., 'feature_files', 'app_manifests')
97
+
98
+ Returns:
99
+ Absolute Path resolved relative to project root
100
+ """
101
+ rel_path = self._config['paths'].get(key, '')
102
+ return self._project_root / rel_path
103
+
104
+ def get_doc_path(self, key: str) -> str:
105
+ """Get a doc structure path.
106
+
107
+ Args:
108
+ key: Doc path key (e.g., 'applications', 'personas')
109
+
110
+ Returns:
111
+ Relative path string for use in doc references
112
+ """
113
+ return self._config['docs_structure'].get(key, key)
114
+
115
+
116
+ # Module-level config instance, set by setup()
117
+ _config: HCDConfig | None = None
118
+
119
+
120
+ def get_config() -> HCDConfig:
121
+ """Get the current HCD configuration.
122
+
123
+ Returns:
124
+ HCDConfig instance
125
+
126
+ Raises:
127
+ RuntimeError: If called before extension is initialized
128
+ """
129
+ if _config is None:
130
+ raise RuntimeError(
131
+ "sphinx_hcd config not initialized. "
132
+ "Ensure 'julee.docs.sphinx_hcd' is in your Sphinx extensions."
133
+ )
134
+ return _config
135
+
136
+
137
+ def init_config(app) -> HCDConfig:
138
+ """Initialize config from Sphinx app. Called by extension setup.
139
+
140
+ Args:
141
+ app: Sphinx application instance
142
+
143
+ Returns:
144
+ HCDConfig instance
145
+ """
146
+ global _config
147
+ _config = HCDConfig(app)
148
+ return _config