specfact-cli 0.6.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.
- specfact_cli/__init__.py +14 -0
- specfact_cli/agents/__init__.py +24 -0
- specfact_cli/agents/analyze_agent.py +391 -0
- specfact_cli/agents/base.py +95 -0
- specfact_cli/agents/plan_agent.py +202 -0
- specfact_cli/agents/registry.py +176 -0
- specfact_cli/agents/sync_agent.py +133 -0
- specfact_cli/analyzers/__init__.py +12 -0
- specfact_cli/analyzers/ambiguity_scanner.py +592 -0
- specfact_cli/analyzers/code_analyzer.py +1228 -0
- specfact_cli/analyzers/contract_extractor.py +419 -0
- specfact_cli/analyzers/control_flow_analyzer.py +281 -0
- specfact_cli/analyzers/requirement_extractor.py +337 -0
- specfact_cli/analyzers/test_pattern_extractor.py +330 -0
- specfact_cli/cli.py +264 -0
- specfact_cli/commands/__init__.py +7 -0
- specfact_cli/commands/constitution.py +261 -0
- specfact_cli/commands/enforce.py +96 -0
- specfact_cli/commands/import_cmd.py +694 -0
- specfact_cli/commands/init.py +143 -0
- specfact_cli/commands/plan.py +2398 -0
- specfact_cli/commands/repro.py +214 -0
- specfact_cli/commands/sync.py +744 -0
- specfact_cli/common/__init__.py +25 -0
- specfact_cli/common/logger_setup.py +654 -0
- specfact_cli/common/logging_utils.py +41 -0
- specfact_cli/common/text_utils.py +52 -0
- specfact_cli/common/utils.py +48 -0
- specfact_cli/comparators/__init__.py +11 -0
- specfact_cli/comparators/plan_comparator.py +391 -0
- specfact_cli/enrichers/constitution_enricher.py +765 -0
- specfact_cli/enrichers/plan_enricher.py +268 -0
- specfact_cli/generators/__init__.py +14 -0
- specfact_cli/generators/plan_generator.py +105 -0
- specfact_cli/generators/protocol_generator.py +115 -0
- specfact_cli/generators/report_generator.py +200 -0
- specfact_cli/generators/workflow_generator.py +120 -0
- specfact_cli/importers/__init__.py +7 -0
- specfact_cli/importers/speckit_converter.py +1051 -0
- specfact_cli/importers/speckit_scanner.py +776 -0
- specfact_cli/models/__init__.py +33 -0
- specfact_cli/models/deviation.py +105 -0
- specfact_cli/models/enforcement.py +150 -0
- specfact_cli/models/plan.py +139 -0
- specfact_cli/models/protocol.py +28 -0
- specfact_cli/modes/__init__.py +19 -0
- specfact_cli/modes/detector.py +126 -0
- specfact_cli/modes/router.py +153 -0
- specfact_cli/resources/mappings/node-async.yaml +49 -0
- specfact_cli/resources/mappings/python-async.yaml +47 -0
- specfact_cli/resources/mappings/speckit-default.yaml +82 -0
- specfact_cli/resources/prompts/specfact-enforce.md +185 -0
- specfact_cli/resources/prompts/specfact-import-from-code.md +597 -0
- specfact_cli/resources/prompts/specfact-plan-add-feature.md +188 -0
- specfact_cli/resources/prompts/specfact-plan-add-story.md +212 -0
- specfact_cli/resources/prompts/specfact-plan-compare.md +571 -0
- specfact_cli/resources/prompts/specfact-plan-init.md +531 -0
- specfact_cli/resources/prompts/specfact-plan-promote.md +352 -0
- specfact_cli/resources/prompts/specfact-plan-review.md +869 -0
- specfact_cli/resources/prompts/specfact-plan-select.md +401 -0
- specfact_cli/resources/prompts/specfact-plan-update-feature.md +234 -0
- specfact_cli/resources/prompts/specfact-plan-update-idea.md +211 -0
- specfact_cli/resources/prompts/specfact-repro.md +268 -0
- specfact_cli/resources/prompts/specfact-sync.md +457 -0
- specfact_cli/resources/schemas/deviation.schema.json +61 -0
- specfact_cli/resources/schemas/plan.schema.json +204 -0
- specfact_cli/resources/schemas/protocol.schema.json +53 -0
- specfact_cli/resources/semgrep/async.yml +285 -0
- specfact_cli/resources/templates/github-action.yml.j2 +140 -0
- specfact_cli/resources/templates/plan.bundle.yaml.j2 +141 -0
- specfact_cli/resources/templates/pr-template.md.j2 +58 -0
- specfact_cli/resources/templates/protocol.yaml.j2 +24 -0
- specfact_cli/resources/templates/telemetry.yaml.example +35 -0
- specfact_cli/sync/__init__.py +21 -0
- specfact_cli/sync/repository_sync.py +279 -0
- specfact_cli/sync/speckit_sync.py +388 -0
- specfact_cli/sync/watcher.py +268 -0
- specfact_cli/telemetry.py +440 -0
- specfact_cli/utils/__init__.py +58 -0
- specfact_cli/utils/console.py +70 -0
- specfact_cli/utils/enrichment_parser.py +445 -0
- specfact_cli/utils/feature_keys.py +212 -0
- specfact_cli/utils/git.py +241 -0
- specfact_cli/utils/github_annotations.py +399 -0
- specfact_cli/utils/ide_setup.py +389 -0
- specfact_cli/utils/prompts.py +180 -0
- specfact_cli/utils/structure.py +674 -0
- specfact_cli/utils/yaml_utils.py +200 -0
- specfact_cli/validators/__init__.py +20 -0
- specfact_cli/validators/fsm.py +262 -0
- specfact_cli/validators/repro_checker.py +780 -0
- specfact_cli/validators/schema.py +196 -0
- specfact_cli-0.6.3.dist-info/METADATA +456 -0
- specfact_cli-0.6.3.dist-info/RECORD +97 -0
- specfact_cli-0.6.3.dist-info/WHEEL +4 -0
- specfact_cli-0.6.3.dist-info/entry_points.txt +2 -0
- specfact_cli-0.6.3.dist-info/licenses/LICENSE.md +202 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Plan bundle enricher for automatic enhancement of vague acceptance criteria,
|
|
3
|
+
incomplete requirements, and generic tasks.
|
|
4
|
+
|
|
5
|
+
This module provides automatic enrichment capabilities that can be triggered
|
|
6
|
+
during plan review to improve plan quality without manual intervention.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from beartype import beartype
|
|
14
|
+
from icontract import ensure, require
|
|
15
|
+
|
|
16
|
+
from specfact_cli.models.plan import PlanBundle
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PlanEnricher:
|
|
20
|
+
"""
|
|
21
|
+
Enricher for automatically enhancing plan bundles.
|
|
22
|
+
|
|
23
|
+
Detects and fixes vague acceptance criteria, incomplete requirements,
|
|
24
|
+
and generic tasks using pattern-based improvements.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
@beartype
|
|
28
|
+
@require(lambda plan_bundle: isinstance(plan_bundle, PlanBundle), "Plan bundle must be PlanBundle")
|
|
29
|
+
@ensure(lambda result: isinstance(result, dict), "Must return dict with enrichment summary")
|
|
30
|
+
def enrich_plan(self, plan_bundle: PlanBundle) -> dict[str, Any]:
|
|
31
|
+
"""
|
|
32
|
+
Enrich plan bundle by enhancing vague acceptance criteria, incomplete requirements, and generic tasks.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
plan_bundle: Plan bundle to enrich
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Dictionary with enrichment summary (features_updated, stories_updated, tasks_updated, etc.)
|
|
39
|
+
"""
|
|
40
|
+
summary: dict[str, Any] = {
|
|
41
|
+
"features_updated": 0,
|
|
42
|
+
"stories_updated": 0,
|
|
43
|
+
"acceptance_criteria_enhanced": 0,
|
|
44
|
+
"requirements_enhanced": 0,
|
|
45
|
+
"tasks_enhanced": 0,
|
|
46
|
+
"changes": [],
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for feature in plan_bundle.features:
|
|
50
|
+
feature_updated = False
|
|
51
|
+
|
|
52
|
+
# Enhance incomplete requirements in outcomes
|
|
53
|
+
enhanced_outcomes = []
|
|
54
|
+
for outcome in feature.outcomes:
|
|
55
|
+
enhanced = self._enhance_incomplete_requirement(outcome, feature.title)
|
|
56
|
+
if enhanced != outcome:
|
|
57
|
+
enhanced_outcomes.append(enhanced)
|
|
58
|
+
summary["requirements_enhanced"] += 1
|
|
59
|
+
summary["changes"].append(f"Feature {feature.key}: Enhanced requirement '{outcome}' → '{enhanced}'")
|
|
60
|
+
feature_updated = True
|
|
61
|
+
else:
|
|
62
|
+
enhanced_outcomes.append(outcome)
|
|
63
|
+
|
|
64
|
+
if feature_updated:
|
|
65
|
+
feature.outcomes = enhanced_outcomes
|
|
66
|
+
summary["features_updated"] += 1
|
|
67
|
+
|
|
68
|
+
# Enhance stories
|
|
69
|
+
for story in feature.stories:
|
|
70
|
+
story_updated = False
|
|
71
|
+
|
|
72
|
+
# Enhance vague acceptance criteria
|
|
73
|
+
enhanced_acceptance = []
|
|
74
|
+
for acc in story.acceptance:
|
|
75
|
+
enhanced = self._enhance_vague_acceptance_criteria(acc, story.title, feature.title)
|
|
76
|
+
if enhanced != acc:
|
|
77
|
+
enhanced_acceptance.append(enhanced)
|
|
78
|
+
summary["acceptance_criteria_enhanced"] += 1
|
|
79
|
+
summary["changes"].append(
|
|
80
|
+
f"Story {story.key}: Enhanced acceptance criteria '{acc}' → '{enhanced}'"
|
|
81
|
+
)
|
|
82
|
+
story_updated = True
|
|
83
|
+
else:
|
|
84
|
+
enhanced_acceptance.append(acc)
|
|
85
|
+
|
|
86
|
+
if story_updated:
|
|
87
|
+
story.acceptance = enhanced_acceptance
|
|
88
|
+
summary["stories_updated"] += 1
|
|
89
|
+
|
|
90
|
+
# Enhance generic tasks
|
|
91
|
+
if story.tasks:
|
|
92
|
+
enhanced_tasks = []
|
|
93
|
+
for task in story.tasks:
|
|
94
|
+
enhanced = self._enhance_generic_task(task, story.title, feature.title)
|
|
95
|
+
if enhanced != task:
|
|
96
|
+
enhanced_tasks.append(enhanced)
|
|
97
|
+
summary["tasks_enhanced"] += 1
|
|
98
|
+
summary["changes"].append(f"Story {story.key}: Enhanced task '{task}' → '{enhanced}'")
|
|
99
|
+
story_updated = True
|
|
100
|
+
else:
|
|
101
|
+
enhanced_tasks.append(task)
|
|
102
|
+
|
|
103
|
+
if story_updated and enhanced_tasks:
|
|
104
|
+
story.tasks = enhanced_tasks
|
|
105
|
+
|
|
106
|
+
return summary
|
|
107
|
+
|
|
108
|
+
@beartype
|
|
109
|
+
@require(lambda requirement: isinstance(requirement, str), "Requirement must be string")
|
|
110
|
+
@require(lambda feature_title: isinstance(feature_title, str), "Feature title must be string")
|
|
111
|
+
@ensure(lambda result: isinstance(result, str), "Must return string")
|
|
112
|
+
@ensure(lambda result: len(result) > 0, "Result must be non-empty")
|
|
113
|
+
def _enhance_incomplete_requirement(self, requirement: str, feature_title: str) -> str:
|
|
114
|
+
"""
|
|
115
|
+
Enhance incomplete requirement (e.g., "System MUST Helper class" → "System MUST provide a Helper class").
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
requirement: Requirement text to enhance
|
|
119
|
+
feature_title: Feature title for context
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Enhanced requirement text
|
|
123
|
+
"""
|
|
124
|
+
requirement_lower = requirement.lower()
|
|
125
|
+
incomplete_patterns = [
|
|
126
|
+
("system must", "System MUST provide"),
|
|
127
|
+
("system should", "System SHOULD provide"),
|
|
128
|
+
("must", "MUST provide"),
|
|
129
|
+
("should", "SHOULD provide"),
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
for pattern, replacement_prefix in incomplete_patterns:
|
|
133
|
+
if requirement_lower.startswith(pattern):
|
|
134
|
+
remaining = requirement[len(pattern) :].strip()
|
|
135
|
+
# Check if it's incomplete (just a noun phrase without verb)
|
|
136
|
+
if (
|
|
137
|
+
remaining
|
|
138
|
+
and len(remaining.split()) < 3
|
|
139
|
+
and any(
|
|
140
|
+
keyword in remaining.lower()
|
|
141
|
+
for keyword in ["class", "helper", "module", "component", "service", "function"]
|
|
142
|
+
)
|
|
143
|
+
):
|
|
144
|
+
# Enhance: "System MUST Helper class" → "System MUST provide a Helper class for [feature]"
|
|
145
|
+
# Extract the component name
|
|
146
|
+
component_name = remaining.strip()
|
|
147
|
+
# Capitalize first letter if needed
|
|
148
|
+
if component_name and component_name[0].islower():
|
|
149
|
+
component_name = component_name[0].upper() + component_name[1:]
|
|
150
|
+
# Generate enhanced requirement
|
|
151
|
+
if "class" in component_name.lower():
|
|
152
|
+
return f"{replacement_prefix} a {component_name} for {feature_title.lower()} operations"
|
|
153
|
+
if "helper" in component_name.lower():
|
|
154
|
+
return f"{replacement_prefix} a {component_name} class for {feature_title.lower()} operations"
|
|
155
|
+
return f"{replacement_prefix} a {component_name} for {feature_title.lower()}"
|
|
156
|
+
|
|
157
|
+
return requirement
|
|
158
|
+
|
|
159
|
+
@beartype
|
|
160
|
+
@require(lambda acceptance: isinstance(acceptance, str), "Acceptance must be string")
|
|
161
|
+
@require(lambda story_title: isinstance(story_title, str), "Story title must be string")
|
|
162
|
+
@require(lambda feature_title: isinstance(feature_title, str), "Feature title must be string")
|
|
163
|
+
@ensure(lambda result: isinstance(result, str), "Must return string")
|
|
164
|
+
@ensure(lambda result: len(result) > 0, "Result must be non-empty")
|
|
165
|
+
def _enhance_vague_acceptance_criteria(self, acceptance: str, story_title: str, feature_title: str) -> str:
|
|
166
|
+
"""
|
|
167
|
+
Enhance vague acceptance criteria (e.g., "is implemented" → "Given [state], When [action], Then [outcome]").
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
acceptance: Acceptance criteria text to enhance
|
|
171
|
+
story_title: Story title for context
|
|
172
|
+
feature_title: Feature title for context
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Enhanced acceptance criteria in Given/When/Then format
|
|
176
|
+
"""
|
|
177
|
+
acceptance_lower = acceptance.lower()
|
|
178
|
+
vague_patterns = [
|
|
179
|
+
(
|
|
180
|
+
"is implemented",
|
|
181
|
+
"Given a developer wants to use {story}, When they interact with the system, Then {story} is functional and verified",
|
|
182
|
+
),
|
|
183
|
+
(
|
|
184
|
+
"is functional",
|
|
185
|
+
"Given a user wants to use {story}, When they perform the action, Then {story} works as expected",
|
|
186
|
+
),
|
|
187
|
+
(
|
|
188
|
+
"works",
|
|
189
|
+
"Given a user wants to use {story}, When they interact with the system, Then {story} works correctly",
|
|
190
|
+
),
|
|
191
|
+
(
|
|
192
|
+
"is done",
|
|
193
|
+
"Given a user wants to complete {story}, When they perform the action, Then {story} is completed successfully",
|
|
194
|
+
),
|
|
195
|
+
(
|
|
196
|
+
"is complete",
|
|
197
|
+
"Given a user wants to complete {story}, When they perform the action, Then {story} is completed successfully",
|
|
198
|
+
),
|
|
199
|
+
(
|
|
200
|
+
"is ready",
|
|
201
|
+
"Given a user wants to use {story}, When they access the system, Then {story} is ready and available",
|
|
202
|
+
),
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
for pattern, template in vague_patterns:
|
|
206
|
+
if pattern in acceptance_lower:
|
|
207
|
+
# Replace placeholder with story title
|
|
208
|
+
return template.format(story=story_title.lower())
|
|
209
|
+
|
|
210
|
+
# If no vague pattern found, check if it's already in Given/When/Then format
|
|
211
|
+
if "given" in acceptance_lower and "when" in acceptance_lower and "then" in acceptance_lower:
|
|
212
|
+
return acceptance
|
|
213
|
+
|
|
214
|
+
# If it's a simple statement without testable keywords, enhance it
|
|
215
|
+
testable_keywords = ["must", "should", "will", "verify", "validate", "check", "ensure"]
|
|
216
|
+
if not any(keyword in acceptance_lower for keyword in testable_keywords):
|
|
217
|
+
# Convert to testable format
|
|
218
|
+
if acceptance_lower.startswith(("user can", "system can")):
|
|
219
|
+
return f"Must verify {acceptance.lower()}"
|
|
220
|
+
# Generate Given/When/Then from simple statement
|
|
221
|
+
return f"Given a user wants to use {story_title.lower()}, When they perform the action, Then {acceptance}"
|
|
222
|
+
|
|
223
|
+
return acceptance
|
|
224
|
+
|
|
225
|
+
@beartype
|
|
226
|
+
@require(lambda task: isinstance(task, str), "Task must be string")
|
|
227
|
+
@require(lambda story_title: isinstance(story_title, str), "Story title must be string")
|
|
228
|
+
@require(lambda feature_title: isinstance(feature_title, str), "Feature title must be string")
|
|
229
|
+
@ensure(lambda result: isinstance(result, str), "Must return string")
|
|
230
|
+
@ensure(lambda result: len(result) > 0, "Result must be non-empty")
|
|
231
|
+
def _enhance_generic_task(self, task: str, story_title: str, feature_title: str) -> str:
|
|
232
|
+
"""
|
|
233
|
+
Enhance generic task (e.g., "Implement [story]" → "Implement [story] in src/... with methods for ...").
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
task: Task description to enhance
|
|
237
|
+
story_title: Story title for context
|
|
238
|
+
feature_title: Feature title for context
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
Enhanced task with implementation details
|
|
242
|
+
"""
|
|
243
|
+
task_lower = task.lower()
|
|
244
|
+
generic_patterns = [
|
|
245
|
+
("implement", "Implement {story} in src/specfact_cli/... with methods for {feature} operations"),
|
|
246
|
+
("create", "Create {story} component in src/specfact_cli/... with {feature} functionality"),
|
|
247
|
+
("add", "Add {story} functionality to src/specfact_cli/... with {feature} support"),
|
|
248
|
+
("set up", "Set up {story} infrastructure in src/specfact_cli/... for {feature} operations"),
|
|
249
|
+
]
|
|
250
|
+
|
|
251
|
+
# Check if task is generic (has pattern but no implementation details)
|
|
252
|
+
has_details = any(
|
|
253
|
+
detail in task_lower
|
|
254
|
+
for detail in ["file", "path", "method", "class", "component", "module", "function", "src/", "tests/"]
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
if not has_details:
|
|
258
|
+
for pattern, template in generic_patterns:
|
|
259
|
+
if pattern in task_lower:
|
|
260
|
+
# Extract the story/feature name from task
|
|
261
|
+
remaining = task_lower.replace(pattern, "").strip()
|
|
262
|
+
if not remaining or remaining == story_title.lower():
|
|
263
|
+
# Use template with story and feature
|
|
264
|
+
return template.format(story=story_title, feature=feature_title.lower())
|
|
265
|
+
# Keep original but add implementation details
|
|
266
|
+
return f"{task} in src/specfact_cli/... with methods and tests"
|
|
267
|
+
|
|
268
|
+
return task
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Generators for plan bundles, protocols, reports, and workflows."""
|
|
2
|
+
|
|
3
|
+
from specfact_cli.generators.plan_generator import PlanGenerator
|
|
4
|
+
from specfact_cli.generators.protocol_generator import ProtocolGenerator
|
|
5
|
+
from specfact_cli.generators.report_generator import ReportGenerator
|
|
6
|
+
from specfact_cli.generators.workflow_generator import WorkflowGenerator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"PlanGenerator",
|
|
11
|
+
"ProtocolGenerator",
|
|
12
|
+
"ReportGenerator",
|
|
13
|
+
"WorkflowGenerator",
|
|
14
|
+
]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Plan bundle generator using direct YAML serialization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from beartype import beartype
|
|
8
|
+
from icontract import ensure, require
|
|
9
|
+
from jinja2 import Environment, FileSystemLoader
|
|
10
|
+
|
|
11
|
+
from specfact_cli.models.plan import PlanBundle
|
|
12
|
+
from specfact_cli.utils.yaml_utils import dump_yaml, yaml_to_string
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PlanGenerator:
|
|
16
|
+
"""
|
|
17
|
+
Generator for plan bundle YAML files.
|
|
18
|
+
|
|
19
|
+
Uses direct YAML serialization for reliable output.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
@beartype
|
|
23
|
+
def __init__(self, templates_dir: Path | None = None) -> None:
|
|
24
|
+
"""
|
|
25
|
+
Initialize plan generator.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
templates_dir: Directory containing Jinja2 templates (default: resources/templates)
|
|
29
|
+
"""
|
|
30
|
+
if templates_dir is None:
|
|
31
|
+
# Default to resources/templates relative to project root
|
|
32
|
+
templates_dir = Path(__file__).parent.parent.parent.parent / "resources" / "templates"
|
|
33
|
+
|
|
34
|
+
self.templates_dir = Path(templates_dir)
|
|
35
|
+
self.env = Environment(
|
|
36
|
+
loader=FileSystemLoader(self.templates_dir),
|
|
37
|
+
trim_blocks=True,
|
|
38
|
+
lstrip_blocks=True,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
@beartype
|
|
42
|
+
@require(lambda plan_bundle: isinstance(plan_bundle, PlanBundle), "Must be PlanBundle instance")
|
|
43
|
+
@require(lambda output_path: output_path is not None, "Output path must not be None")
|
|
44
|
+
@ensure(lambda output_path: output_path.exists(), "Output file must exist after generation")
|
|
45
|
+
def generate(self, plan_bundle: PlanBundle, output_path: Path) -> None:
|
|
46
|
+
"""
|
|
47
|
+
Generate plan bundle YAML file from model.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
plan_bundle: PlanBundle model to generate from
|
|
51
|
+
output_path: Path to write the generated YAML file
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
IOError: If unable to write output file
|
|
55
|
+
"""
|
|
56
|
+
# Convert model to dict, excluding None values
|
|
57
|
+
plan_data = plan_bundle.model_dump(exclude_none=True)
|
|
58
|
+
|
|
59
|
+
# Write to file using YAML dump
|
|
60
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
dump_yaml(plan_data, output_path)
|
|
62
|
+
|
|
63
|
+
@beartype
|
|
64
|
+
@require(
|
|
65
|
+
lambda template_name: isinstance(template_name, str) and len(template_name) > 0,
|
|
66
|
+
"Template name must be non-empty string",
|
|
67
|
+
)
|
|
68
|
+
@require(lambda context: isinstance(context, dict), "Context must be dictionary")
|
|
69
|
+
@require(lambda output_path: output_path is not None, "Output path must not be None")
|
|
70
|
+
@ensure(lambda output_path: output_path.exists(), "Output file must exist after generation")
|
|
71
|
+
def generate_from_template(self, template_name: str, context: dict, output_path: Path) -> None:
|
|
72
|
+
"""
|
|
73
|
+
Generate file from custom template.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
template_name: Name of the template file
|
|
77
|
+
context: Context dictionary for template rendering
|
|
78
|
+
output_path: Path to write the generated file
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
FileNotFoundError: If template file doesn't exist
|
|
82
|
+
IOError: If unable to write output file
|
|
83
|
+
"""
|
|
84
|
+
template = self.env.get_template(template_name)
|
|
85
|
+
rendered = template.render(**context)
|
|
86
|
+
|
|
87
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
output_path.write_text(rendered, encoding="utf-8")
|
|
89
|
+
|
|
90
|
+
@beartype
|
|
91
|
+
@require(lambda plan_bundle: isinstance(plan_bundle, PlanBundle), "Must be PlanBundle instance")
|
|
92
|
+
@ensure(lambda result: isinstance(result, str), "Must return string")
|
|
93
|
+
@ensure(lambda result: len(result) > 0, "Result must be non-empty")
|
|
94
|
+
def render_string(self, plan_bundle: PlanBundle) -> str:
|
|
95
|
+
"""
|
|
96
|
+
Render plan bundle to YAML string without writing to file.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
plan_bundle: PlanBundle model to render
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Rendered YAML string
|
|
103
|
+
"""
|
|
104
|
+
plan_data = plan_bundle.model_dump(exclude_none=True)
|
|
105
|
+
return yaml_to_string(plan_data)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Protocol generator using Jinja2 templates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from beartype import beartype
|
|
8
|
+
from icontract import ensure, require
|
|
9
|
+
from jinja2 import Environment, FileSystemLoader
|
|
10
|
+
|
|
11
|
+
from specfact_cli.models.protocol import Protocol
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ProtocolGenerator:
|
|
15
|
+
"""
|
|
16
|
+
Generator for protocol YAML files.
|
|
17
|
+
|
|
18
|
+
Uses Jinja2 templates to render protocols from Protocol models.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
@beartype
|
|
22
|
+
def __init__(self, templates_dir: Path | None = None) -> None:
|
|
23
|
+
"""
|
|
24
|
+
Initialize protocol generator.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
templates_dir: Directory containing Jinja2 templates (default: resources/templates)
|
|
28
|
+
"""
|
|
29
|
+
if templates_dir is None:
|
|
30
|
+
# Default to resources/templates relative to project root
|
|
31
|
+
templates_dir = Path(__file__).parent.parent.parent.parent / "resources" / "templates"
|
|
32
|
+
|
|
33
|
+
self.templates_dir = Path(templates_dir)
|
|
34
|
+
self.env = Environment(
|
|
35
|
+
loader=FileSystemLoader(self.templates_dir),
|
|
36
|
+
trim_blocks=True,
|
|
37
|
+
lstrip_blocks=True,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
@beartype
|
|
41
|
+
@require(lambda protocol: isinstance(protocol, Protocol), "Must be Protocol instance")
|
|
42
|
+
@require(lambda output_path: output_path is not None, "Output path must not be None")
|
|
43
|
+
@require(lambda protocol: len(protocol.states) > 0, "Protocol must have at least one state")
|
|
44
|
+
@ensure(lambda output_path: output_path.exists(), "Output file must exist after generation")
|
|
45
|
+
def generate(self, protocol: Protocol, output_path: Path) -> None:
|
|
46
|
+
"""
|
|
47
|
+
Generate protocol YAML file from model.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
protocol: Protocol model to generate from
|
|
51
|
+
output_path: Path to write the generated YAML file
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
FileNotFoundError: If template file doesn't exist
|
|
55
|
+
IOError: If unable to write output file
|
|
56
|
+
"""
|
|
57
|
+
# Convert model to dict, excluding None values
|
|
58
|
+
protocol_data = protocol.model_dump(exclude_none=True, mode="json")
|
|
59
|
+
|
|
60
|
+
# Render template
|
|
61
|
+
template = self.env.get_template("protocol.yaml.j2")
|
|
62
|
+
rendered = template.render(**protocol_data)
|
|
63
|
+
|
|
64
|
+
# Write to file
|
|
65
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
output_path.write_text(rendered, encoding="utf-8")
|
|
67
|
+
|
|
68
|
+
@beartype
|
|
69
|
+
@require(
|
|
70
|
+
lambda template_name: isinstance(template_name, str) and len(template_name) > 0,
|
|
71
|
+
"Template name must be non-empty string",
|
|
72
|
+
)
|
|
73
|
+
@require(lambda context: isinstance(context, dict), "Context must be dictionary")
|
|
74
|
+
@require(lambda output_path: output_path is not None, "Output path must not be None")
|
|
75
|
+
@ensure(lambda output_path: output_path.exists(), "Output file must exist after generation")
|
|
76
|
+
def generate_from_template(self, template_name: str, context: dict, output_path: Path) -> None:
|
|
77
|
+
"""
|
|
78
|
+
Generate file from custom template.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
template_name: Name of the template file
|
|
82
|
+
context: Context dictionary for template rendering
|
|
83
|
+
output_path: Path to write the generated file
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
FileNotFoundError: If template file doesn't exist
|
|
87
|
+
IOError: If unable to write output file
|
|
88
|
+
"""
|
|
89
|
+
template = self.env.get_template(template_name)
|
|
90
|
+
rendered = template.render(**context)
|
|
91
|
+
|
|
92
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
output_path.write_text(rendered, encoding="utf-8")
|
|
94
|
+
|
|
95
|
+
@beartype
|
|
96
|
+
@require(lambda protocol: isinstance(protocol, Protocol), "Must be Protocol instance")
|
|
97
|
+
@require(lambda protocol: len(protocol.states) > 0, "Protocol must have at least one state")
|
|
98
|
+
@ensure(lambda result: isinstance(result, str), "Must return string")
|
|
99
|
+
@ensure(lambda result: len(result) > 0, "Result must be non-empty")
|
|
100
|
+
def render_string(self, protocol: Protocol) -> str:
|
|
101
|
+
"""
|
|
102
|
+
Render protocol to YAML string without writing to file.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
protocol: Protocol model to render
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Rendered YAML string
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
FileNotFoundError: If template file doesn't exist
|
|
112
|
+
"""
|
|
113
|
+
protocol_data = protocol.model_dump(exclude_none=True, mode="json")
|
|
114
|
+
template = self.env.get_template("protocol.yaml.j2")
|
|
115
|
+
return template.render(**protocol_data)
|