julee 0.1.2__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.
- julee/api/app.py +9 -8
- julee/api/dependencies.py +15 -15
- julee/api/requests.py +10 -9
- julee/api/responses.py +2 -1
- julee/api/routers/__init__.py +5 -5
- julee/api/routers/assembly_specifications.py +5 -4
- julee/api/routers/documents.py +1 -1
- julee/api/routers/knowledge_service_configs.py +4 -3
- julee/api/routers/knowledge_service_queries.py +7 -6
- julee/api/routers/system.py +4 -3
- julee/api/routers/workflows.py +4 -5
- julee/api/services/system_initialization.py +6 -6
- julee/api/tests/routers/test_assembly_specifications.py +4 -3
- julee/api/tests/routers/test_documents.py +5 -4
- julee/api/tests/routers/test_knowledge_service_configs.py +7 -6
- julee/api/tests/routers/test_knowledge_service_queries.py +4 -3
- julee/api/tests/routers/test_system.py +5 -4
- julee/api/tests/routers/test_workflows.py +5 -4
- julee/api/tests/test_app.py +5 -4
- julee/api/tests/test_dependencies.py +3 -2
- julee/api/tests/test_requests.py +2 -1
- julee/contrib/__init__.py +15 -0
- julee/contrib/polling/__init__.py +47 -0
- julee/contrib/polling/domain/__init__.py +17 -0
- julee/contrib/polling/domain/models/__init__.py +13 -0
- julee/contrib/polling/domain/models/polling_config.py +39 -0
- julee/contrib/polling/domain/services/__init__.py +11 -0
- julee/contrib/polling/domain/services/poller.py +39 -0
- julee/contrib/polling/infrastructure/__init__.py +15 -0
- julee/contrib/polling/infrastructure/services/__init__.py +12 -0
- julee/contrib/polling/infrastructure/services/polling/__init__.py +12 -0
- julee/contrib/polling/infrastructure/services/polling/http/__init__.py +12 -0
- julee/contrib/polling/infrastructure/services/polling/http/http_poller_service.py +80 -0
- julee/contrib/polling/infrastructure/temporal/__init__.py +20 -0
- julee/contrib/polling/infrastructure/temporal/activities.py +42 -0
- julee/contrib/polling/infrastructure/temporal/activity_names.py +20 -0
- julee/contrib/polling/infrastructure/temporal/proxies.py +45 -0
- julee/contrib/polling/tests/__init__.py +6 -0
- julee/contrib/polling/tests/unit/__init__.py +6 -0
- julee/contrib/polling/tests/unit/infrastructure/__init__.py +7 -0
- julee/contrib/polling/tests/unit/infrastructure/services/__init__.py +6 -0
- julee/contrib/polling/tests/unit/infrastructure/services/polling/__init__.py +6 -0
- julee/contrib/polling/tests/unit/infrastructure/services/polling/http/__init__.py +7 -0
- julee/contrib/polling/tests/unit/infrastructure/services/polling/http/test_http_poller_service.py +163 -0
- julee/docs/__init__.py +5 -0
- julee/docs/sphinx_hcd/__init__.py +82 -0
- julee/docs/sphinx_hcd/accelerators.py +1078 -0
- julee/docs/sphinx_hcd/apps.py +499 -0
- julee/docs/sphinx_hcd/config.py +148 -0
- julee/docs/sphinx_hcd/epics.py +448 -0
- julee/docs/sphinx_hcd/integrations.py +306 -0
- julee/docs/sphinx_hcd/journeys.py +783 -0
- julee/docs/sphinx_hcd/personas.py +435 -0
- julee/docs/sphinx_hcd/stories.py +932 -0
- julee/docs/sphinx_hcd/utils.py +180 -0
- julee/domain/models/__init__.py +5 -6
- julee/domain/models/assembly/assembly.py +7 -7
- julee/domain/models/assembly/tests/factories.py +2 -1
- julee/domain/models/assembly/tests/test_assembly.py +16 -13
- julee/domain/models/assembly_specification/assembly_specification.py +11 -10
- julee/domain/models/assembly_specification/knowledge_service_query.py +7 -6
- julee/domain/models/assembly_specification/tests/factories.py +2 -1
- julee/domain/models/assembly_specification/tests/test_assembly_specification.py +9 -6
- julee/domain/models/assembly_specification/tests/test_knowledge_service_query.py +3 -1
- julee/domain/models/custom_fields/content_stream.py +3 -2
- julee/domain/models/custom_fields/tests/test_custom_fields.py +2 -1
- julee/domain/models/document/document.py +12 -10
- julee/domain/models/document/tests/factories.py +3 -2
- julee/domain/models/document/tests/test_document.py +6 -3
- julee/domain/models/knowledge_service_config/knowledge_service_config.py +4 -4
- julee/domain/models/policy/__init__.py +4 -4
- julee/domain/models/policy/document_policy_validation.py +17 -17
- julee/domain/models/policy/policy.py +10 -10
- julee/domain/models/policy/tests/factories.py +2 -1
- julee/domain/models/policy/tests/test_document_policy_validation.py +3 -1
- julee/domain/models/policy/tests/test_policy.py +2 -1
- julee/domain/repositories/__init__.py +3 -3
- julee/domain/repositories/assembly.py +3 -1
- julee/domain/repositories/assembly_specification.py +2 -0
- julee/domain/repositories/base.py +5 -4
- julee/domain/repositories/document.py +3 -1
- julee/domain/repositories/document_policy_validation.py +3 -1
- julee/domain/repositories/knowledge_service_config.py +2 -0
- julee/domain/repositories/knowledge_service_query.py +1 -0
- julee/domain/repositories/policy.py +3 -1
- julee/domain/use_cases/decorators.py +3 -2
- julee/domain/use_cases/extract_assemble_data.py +13 -12
- julee/domain/use_cases/initialize_system_data.py +13 -13
- julee/domain/use_cases/tests/test_extract_assemble_data.py +10 -10
- julee/domain/use_cases/tests/test_initialize_system_data.py +2 -2
- julee/domain/use_cases/tests/test_validate_document.py +11 -11
- julee/domain/use_cases/validate_document.py +14 -14
- julee/maintenance/__init__.py +1 -0
- julee/maintenance/release.py +188 -0
- julee/repositories/memory/assembly.py +6 -5
- julee/repositories/memory/assembly_specification.py +8 -9
- julee/repositories/memory/base.py +12 -11
- julee/repositories/memory/document.py +8 -7
- julee/repositories/memory/document_policy_validation.py +7 -6
- julee/repositories/memory/knowledge_service_config.py +8 -7
- julee/repositories/memory/knowledge_service_query.py +8 -7
- julee/repositories/memory/policy.py +6 -5
- julee/repositories/memory/tests/test_document.py +6 -4
- julee/repositories/memory/tests/test_document_policy_validation.py +2 -1
- julee/repositories/memory/tests/test_policy.py +2 -1
- julee/repositories/minio/assembly.py +4 -4
- julee/repositories/minio/assembly_specification.py +6 -8
- julee/repositories/minio/client.py +22 -25
- julee/repositories/minio/document.py +11 -11
- julee/repositories/minio/document_policy_validation.py +5 -5
- julee/repositories/minio/knowledge_service_config.py +6 -6
- julee/repositories/minio/knowledge_service_query.py +6 -9
- julee/repositories/minio/policy.py +4 -4
- julee/repositories/minio/tests/fake_client.py +11 -9
- julee/repositories/minio/tests/test_assembly.py +3 -1
- julee/repositories/minio/tests/test_assembly_specification.py +2 -1
- julee/repositories/minio/tests/test_client_protocol.py +5 -5
- julee/repositories/minio/tests/test_document.py +7 -6
- julee/repositories/minio/tests/test_document_policy_validation.py +3 -1
- julee/repositories/minio/tests/test_knowledge_service_config.py +4 -2
- julee/repositories/minio/tests/test_knowledge_service_query.py +3 -2
- julee/repositories/minio/tests/test_policy.py +3 -1
- julee/repositories/temporal/activities.py +5 -5
- julee/repositories/temporal/proxies.py +5 -5
- julee/services/knowledge_service/__init__.py +1 -2
- julee/services/knowledge_service/anthropic/knowledge_service.py +8 -7
- julee/services/knowledge_service/anthropic/tests/test_knowledge_service.py +11 -10
- julee/services/knowledge_service/factory.py +8 -8
- julee/services/knowledge_service/knowledge_service.py +12 -14
- julee/services/knowledge_service/memory/knowledge_service.py +13 -12
- julee/services/knowledge_service/memory/test_knowledge_service.py +10 -7
- julee/services/knowledge_service/test_factory.py +11 -10
- julee/services/temporal/activities.py +10 -10
- julee/services/temporal/proxies.py +2 -2
- julee/util/domain.py +6 -6
- julee/util/repos/minio/file_storage.py +8 -9
- julee/util/repos/temporal/client_proxies/file_storage.py +3 -4
- julee/util/repos/temporal/data_converter.py +6 -6
- julee/util/repos/temporal/minio_file_storage.py +1 -1
- julee/util/repos/temporal/proxies/file_storage.py +2 -3
- julee/util/repositories.py +4 -3
- julee/util/temporal/decorators.py +20 -18
- julee/util/tests/test_decorators.py +13 -15
- julee/util/validation/repository.py +3 -3
- julee/util/validation/type_guards.py +12 -11
- julee/worker.py +9 -8
- julee/workflows/__init__.py +2 -2
- julee/workflows/extract_assemble.py +2 -1
- julee/workflows/validate_document.py +3 -2
- {julee-0.1.2.dist-info → julee-0.1.3.dist-info}/METADATA +2 -1
- julee-0.1.3.dist-info/RECORD +197 -0
- julee-0.1.2.dist-info/RECORD +0 -161
- {julee-0.1.2.dist-info → julee-0.1.3.dist-info}/WHEEL +0 -0
- {julee-0.1.2.dist-info → julee-0.1.3.dist-info}/licenses/LICENSE +0 -0
- {julee-0.1.2.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
|