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