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.
Files changed (97) hide show
  1. specfact_cli/__init__.py +14 -0
  2. specfact_cli/agents/__init__.py +24 -0
  3. specfact_cli/agents/analyze_agent.py +391 -0
  4. specfact_cli/agents/base.py +95 -0
  5. specfact_cli/agents/plan_agent.py +202 -0
  6. specfact_cli/agents/registry.py +176 -0
  7. specfact_cli/agents/sync_agent.py +133 -0
  8. specfact_cli/analyzers/__init__.py +12 -0
  9. specfact_cli/analyzers/ambiguity_scanner.py +592 -0
  10. specfact_cli/analyzers/code_analyzer.py +1228 -0
  11. specfact_cli/analyzers/contract_extractor.py +419 -0
  12. specfact_cli/analyzers/control_flow_analyzer.py +281 -0
  13. specfact_cli/analyzers/requirement_extractor.py +337 -0
  14. specfact_cli/analyzers/test_pattern_extractor.py +330 -0
  15. specfact_cli/cli.py +264 -0
  16. specfact_cli/commands/__init__.py +7 -0
  17. specfact_cli/commands/constitution.py +261 -0
  18. specfact_cli/commands/enforce.py +96 -0
  19. specfact_cli/commands/import_cmd.py +694 -0
  20. specfact_cli/commands/init.py +143 -0
  21. specfact_cli/commands/plan.py +2398 -0
  22. specfact_cli/commands/repro.py +214 -0
  23. specfact_cli/commands/sync.py +744 -0
  24. specfact_cli/common/__init__.py +25 -0
  25. specfact_cli/common/logger_setup.py +654 -0
  26. specfact_cli/common/logging_utils.py +41 -0
  27. specfact_cli/common/text_utils.py +52 -0
  28. specfact_cli/common/utils.py +48 -0
  29. specfact_cli/comparators/__init__.py +11 -0
  30. specfact_cli/comparators/plan_comparator.py +391 -0
  31. specfact_cli/enrichers/constitution_enricher.py +765 -0
  32. specfact_cli/enrichers/plan_enricher.py +268 -0
  33. specfact_cli/generators/__init__.py +14 -0
  34. specfact_cli/generators/plan_generator.py +105 -0
  35. specfact_cli/generators/protocol_generator.py +115 -0
  36. specfact_cli/generators/report_generator.py +200 -0
  37. specfact_cli/generators/workflow_generator.py +120 -0
  38. specfact_cli/importers/__init__.py +7 -0
  39. specfact_cli/importers/speckit_converter.py +1051 -0
  40. specfact_cli/importers/speckit_scanner.py +776 -0
  41. specfact_cli/models/__init__.py +33 -0
  42. specfact_cli/models/deviation.py +105 -0
  43. specfact_cli/models/enforcement.py +150 -0
  44. specfact_cli/models/plan.py +139 -0
  45. specfact_cli/models/protocol.py +28 -0
  46. specfact_cli/modes/__init__.py +19 -0
  47. specfact_cli/modes/detector.py +126 -0
  48. specfact_cli/modes/router.py +153 -0
  49. specfact_cli/resources/mappings/node-async.yaml +49 -0
  50. specfact_cli/resources/mappings/python-async.yaml +47 -0
  51. specfact_cli/resources/mappings/speckit-default.yaml +82 -0
  52. specfact_cli/resources/prompts/specfact-enforce.md +185 -0
  53. specfact_cli/resources/prompts/specfact-import-from-code.md +597 -0
  54. specfact_cli/resources/prompts/specfact-plan-add-feature.md +188 -0
  55. specfact_cli/resources/prompts/specfact-plan-add-story.md +212 -0
  56. specfact_cli/resources/prompts/specfact-plan-compare.md +571 -0
  57. specfact_cli/resources/prompts/specfact-plan-init.md +531 -0
  58. specfact_cli/resources/prompts/specfact-plan-promote.md +352 -0
  59. specfact_cli/resources/prompts/specfact-plan-review.md +869 -0
  60. specfact_cli/resources/prompts/specfact-plan-select.md +401 -0
  61. specfact_cli/resources/prompts/specfact-plan-update-feature.md +234 -0
  62. specfact_cli/resources/prompts/specfact-plan-update-idea.md +211 -0
  63. specfact_cli/resources/prompts/specfact-repro.md +268 -0
  64. specfact_cli/resources/prompts/specfact-sync.md +457 -0
  65. specfact_cli/resources/schemas/deviation.schema.json +61 -0
  66. specfact_cli/resources/schemas/plan.schema.json +204 -0
  67. specfact_cli/resources/schemas/protocol.schema.json +53 -0
  68. specfact_cli/resources/semgrep/async.yml +285 -0
  69. specfact_cli/resources/templates/github-action.yml.j2 +140 -0
  70. specfact_cli/resources/templates/plan.bundle.yaml.j2 +141 -0
  71. specfact_cli/resources/templates/pr-template.md.j2 +58 -0
  72. specfact_cli/resources/templates/protocol.yaml.j2 +24 -0
  73. specfact_cli/resources/templates/telemetry.yaml.example +35 -0
  74. specfact_cli/sync/__init__.py +21 -0
  75. specfact_cli/sync/repository_sync.py +279 -0
  76. specfact_cli/sync/speckit_sync.py +388 -0
  77. specfact_cli/sync/watcher.py +268 -0
  78. specfact_cli/telemetry.py +440 -0
  79. specfact_cli/utils/__init__.py +58 -0
  80. specfact_cli/utils/console.py +70 -0
  81. specfact_cli/utils/enrichment_parser.py +445 -0
  82. specfact_cli/utils/feature_keys.py +212 -0
  83. specfact_cli/utils/git.py +241 -0
  84. specfact_cli/utils/github_annotations.py +399 -0
  85. specfact_cli/utils/ide_setup.py +389 -0
  86. specfact_cli/utils/prompts.py +180 -0
  87. specfact_cli/utils/structure.py +674 -0
  88. specfact_cli/utils/yaml_utils.py +200 -0
  89. specfact_cli/validators/__init__.py +20 -0
  90. specfact_cli/validators/fsm.py +262 -0
  91. specfact_cli/validators/repro_checker.py +780 -0
  92. specfact_cli/validators/schema.py +196 -0
  93. specfact_cli-0.6.3.dist-info/METADATA +456 -0
  94. specfact_cli-0.6.3.dist-info/RECORD +97 -0
  95. specfact_cli-0.6.3.dist-info/WHEEL +4 -0
  96. specfact_cli-0.6.3.dist-info/entry_points.txt +2 -0
  97. 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)