ara-cli 0.1.9.73__py3-none-any.whl → 0.1.9.75__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.
Potentially problematic release.
This version of ara-cli might be problematic. Click here for more details.
- ara_cli/ara_command_action.py +15 -15
- ara_cli/ara_command_parser.py +2 -1
- ara_cli/ara_config.py +181 -73
- ara_cli/artefact_autofix.py +130 -68
- ara_cli/artefact_creator.py +1 -1
- ara_cli/artefact_models/artefact_model.py +26 -7
- ara_cli/artefact_models/artefact_templates.py +47 -31
- ara_cli/artefact_models/businessgoal_artefact_model.py +23 -25
- ara_cli/artefact_models/epic_artefact_model.py +23 -24
- ara_cli/artefact_models/feature_artefact_model.py +76 -46
- ara_cli/artefact_models/keyfeature_artefact_model.py +21 -24
- ara_cli/artefact_models/task_artefact_model.py +73 -13
- ara_cli/artefact_models/userstory_artefact_model.py +22 -24
- ara_cli/artefact_models/vision_artefact_model.py +23 -42
- ara_cli/artefact_scan.py +55 -17
- ara_cli/chat.py +23 -5
- ara_cli/prompt_handler.py +4 -4
- ara_cli/tag_extractor.py +43 -28
- ara_cli/template_manager.py +3 -8
- ara_cli/version.py +1 -1
- {ara_cli-0.1.9.73.dist-info → ara_cli-0.1.9.75.dist-info}/METADATA +1 -1
- {ara_cli-0.1.9.73.dist-info → ara_cli-0.1.9.75.dist-info}/RECORD +29 -39
- tests/test_ara_config.py +420 -36
- tests/test_artefact_autofix.py +289 -25
- tests/test_artefact_scan.py +296 -35
- tests/test_chat.py +35 -15
- ara_cli/templates/template.businessgoal +0 -10
- ara_cli/templates/template.capability +0 -10
- ara_cli/templates/template.epic +0 -15
- ara_cli/templates/template.example +0 -6
- ara_cli/templates/template.feature +0 -26
- ara_cli/templates/template.issue +0 -14
- ara_cli/templates/template.keyfeature +0 -15
- ara_cli/templates/template.task +0 -6
- ara_cli/templates/template.userstory +0 -17
- ara_cli/templates/template.vision +0 -14
- {ara_cli-0.1.9.73.dist-info → ara_cli-0.1.9.75.dist-info}/WHEEL +0 -0
- {ara_cli-0.1.9.73.dist-info → ara_cli-0.1.9.75.dist-info}/entry_points.txt +0 -0
- {ara_cli-0.1.9.73.dist-info → ara_cli-0.1.9.75.dist-info}/top_level.txt +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
2
|
-
from typing import Optional, List, Literal, Union, Dict
|
|
2
|
+
from typing import Optional, List, Literal, Union, Dict, ClassVar
|
|
3
3
|
from typing_extensions import Self
|
|
4
4
|
from enum import Enum
|
|
5
5
|
from abc import ABC, abstractmethod
|
|
@@ -47,13 +47,23 @@ class Contribution(BaseModel):
|
|
|
47
47
|
description="Rule the contribution is using. The classifier of the parent must be userstory or epic if this is used"
|
|
48
48
|
)
|
|
49
49
|
|
|
50
|
+
PLACEHOLDER_NAME: ClassVar[str] = "<filename or title of the artefact>"
|
|
51
|
+
PLACEHOLDER_CLASSIFIER: ClassVar[str] = "<agile requirement artefact category> <(optional in case the contribution is to an artefact that is detailed with rules)"
|
|
52
|
+
PLACEHOLDER_RULE: ClassVar[str] = "<rule as it is formulated>"
|
|
53
|
+
|
|
50
54
|
@model_validator(mode="after")
|
|
51
55
|
def validate_parent(self) -> Self:
|
|
52
|
-
|
|
53
56
|
artefact_name = self.artefact_name
|
|
54
57
|
classifier = self.classifier
|
|
55
58
|
rule = self.rule
|
|
56
59
|
|
|
60
|
+
if (
|
|
61
|
+
artefact_name == Contribution.PLACEHOLDER_NAME
|
|
62
|
+
or classifier == Contribution.PLACEHOLDER_CLASSIFIER
|
|
63
|
+
or rule == Contribution.PLACEHOLDER_RULE
|
|
64
|
+
):
|
|
65
|
+
return self
|
|
66
|
+
|
|
57
67
|
if artefact_name:
|
|
58
68
|
artefact_name = replace_space_with_underscore(artefact_name)
|
|
59
69
|
if not artefact_name or not classifier:
|
|
@@ -68,7 +78,7 @@ class Contribution(BaseModel):
|
|
|
68
78
|
|
|
69
79
|
@field_validator('artefact_name')
|
|
70
80
|
def validate_artefact_name(cls, value):
|
|
71
|
-
if not value:
|
|
81
|
+
if not value or value == Contribution.PLACEHOLDER_NAME:
|
|
72
82
|
return value
|
|
73
83
|
if ' ' in value:
|
|
74
84
|
warnings.warn(message="artefact_name can not contain spaces. Replacing spaces with '_'")
|
|
@@ -77,7 +87,7 @@ class Contribution(BaseModel):
|
|
|
77
87
|
|
|
78
88
|
@field_validator('classifier', mode='after')
|
|
79
89
|
def validate_classifier(cls, v):
|
|
80
|
-
if not v:
|
|
90
|
+
if not v or v == Contribution.PLACEHOLDER_CLASSIFIER:
|
|
81
91
|
return v
|
|
82
92
|
try:
|
|
83
93
|
return ArtefactType(v)
|
|
@@ -91,12 +101,21 @@ class Contribution(BaseModel):
|
|
|
91
101
|
if not line.startswith(contribution_line_start):
|
|
92
102
|
raise ValueError(f"Contribution line '{line}' does not start with '{contribution_line_start}'")
|
|
93
103
|
|
|
104
|
+
parent_text = line[len(contribution_line_start):].strip()
|
|
105
|
+
rule_specifier = " using rule "
|
|
106
|
+
|
|
107
|
+
placeholder_line = f"{cls.PLACEHOLDER_NAME} {cls.PLACEHOLDER_CLASSIFIER}{rule_specifier}{cls.PLACEHOLDER_RULE}"
|
|
108
|
+
if parent_text == placeholder_line:
|
|
109
|
+
return cls(
|
|
110
|
+
artefact_name=cls.PLACEHOLDER_NAME,
|
|
111
|
+
classifier=cls.PLACEHOLDER_CLASSIFIER,
|
|
112
|
+
rule=cls.PLACEHOLDER_RULE
|
|
113
|
+
)
|
|
114
|
+
|
|
94
115
|
artefact_name = None
|
|
95
116
|
classifier = None
|
|
96
117
|
rule = None
|
|
97
118
|
|
|
98
|
-
parent_text = line[len(contribution_line_start):].strip()
|
|
99
|
-
rule_specifier = " using rule "
|
|
100
119
|
if rule_specifier in parent_text:
|
|
101
120
|
parent_text, rule_text = parent_text.split(rule_specifier, 1)
|
|
102
121
|
rule = rule_text
|
|
@@ -113,7 +132,7 @@ class Contribution(BaseModel):
|
|
|
113
132
|
def serialize(self) -> str:
|
|
114
133
|
if not self.classifier or not self.artefact_name:
|
|
115
134
|
return ""
|
|
116
|
-
artefact_type = Classifier.get_artefact_title(self.classifier)
|
|
135
|
+
artefact_type = Classifier.get_artefact_title(self.classifier) or self.classifier
|
|
117
136
|
artefact_name = replace_underscore_with_space(self.artefact_name)
|
|
118
137
|
contribution = f"{artefact_name} {artefact_type}"
|
|
119
138
|
if self.rule:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from ara_cli.artefact_models.artefact_model import ArtefactType, Artefact
|
|
1
|
+
from ara_cli.artefact_models.artefact_model import ArtefactType, Artefact, Contribution
|
|
2
2
|
from ara_cli.artefact_models.vision_artefact_model import VisionArtefact, VisionIntent
|
|
3
3
|
from ara_cli.artefact_models.businessgoal_artefact_model import BusinessgoalArtefact, BusinessgoalIntent
|
|
4
4
|
from ara_cli.artefact_models.capability_artefact_model import CapabilityArtefact, CapabilityIntent
|
|
@@ -11,7 +11,15 @@ from ara_cli.artefact_models.task_artefact_model import TaskArtefact
|
|
|
11
11
|
from ara_cli.artefact_models.issue_artefact_model import IssueArtefact
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
def
|
|
14
|
+
def default_contribution() -> Contribution:
|
|
15
|
+
return Contribution(
|
|
16
|
+
artefact_name=Contribution.PLACEHOLDER_NAME,
|
|
17
|
+
classifier=Contribution.PLACEHOLDER_CLASSIFIER,
|
|
18
|
+
rule=Contribution.PLACEHOLDER_RULE
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _default_vision(title: str, use_default_contribution: bool) -> VisionArtefact:
|
|
15
23
|
intent = VisionIntent(
|
|
16
24
|
for_="<target customer>",
|
|
17
25
|
who="<needs something>",
|
|
@@ -24,11 +32,12 @@ def _default_vision(title: str) -> VisionArtefact:
|
|
|
24
32
|
tags=["sample_tag"],
|
|
25
33
|
title=title,
|
|
26
34
|
description="<further optional description to understand the vision, markdown capable text formatting>",
|
|
27
|
-
intent=intent
|
|
35
|
+
intent=intent,
|
|
36
|
+
contribution=default_contribution() if use_default_contribution else None
|
|
28
37
|
)
|
|
29
38
|
|
|
30
39
|
|
|
31
|
-
def _default_businessgoal(title: str) -> BusinessgoalArtefact:
|
|
40
|
+
def _default_businessgoal(title: str, use_default_contribution: bool) -> BusinessgoalArtefact:
|
|
32
41
|
intent = BusinessgoalIntent(
|
|
33
42
|
in_order_to="<reach primarily a monetary business goal>",
|
|
34
43
|
as_a="<business related role>",
|
|
@@ -37,26 +46,26 @@ def _default_businessgoal(title: str) -> BusinessgoalArtefact:
|
|
|
37
46
|
return BusinessgoalArtefact(
|
|
38
47
|
tags=["sample_tag"],
|
|
39
48
|
title=title,
|
|
40
|
-
description="<further optional description to understand the
|
|
41
|
-
intent=intent
|
|
49
|
+
description="<further optional description to understand the businessgoal, markdown capable text formatting>",
|
|
50
|
+
intent=intent,
|
|
51
|
+
contribution=default_contribution() if use_default_contribution else None
|
|
42
52
|
)
|
|
43
53
|
|
|
44
54
|
|
|
45
|
-
def _default_capability(title: str) -> CapabilityArtefact:
|
|
55
|
+
def _default_capability(title: str, use_default_contribution: bool) -> CapabilityArtefact:
|
|
46
56
|
intent = CapabilityIntent(
|
|
47
|
-
|
|
48
|
-
as_a="<business related role>",
|
|
49
|
-
to_be_able_to="<something that helps me to reach my monetary goal>"
|
|
57
|
+
to_be_able_to="<needed capability for stakeholders that are the enablers/relevant for reaching the business goal>"
|
|
50
58
|
)
|
|
51
59
|
return CapabilityArtefact(
|
|
52
60
|
tags=["sample_tag"],
|
|
53
61
|
title=title,
|
|
54
62
|
description="<further optional description to understand the capability, markdown capable text formatting>",
|
|
55
|
-
intent=intent
|
|
63
|
+
intent=intent,
|
|
64
|
+
contribution=default_contribution() if use_default_contribution else None
|
|
56
65
|
)
|
|
57
66
|
|
|
58
67
|
|
|
59
|
-
def _default_epic(title: str) -> EpicArtefact:
|
|
68
|
+
def _default_epic(title: str, use_default_contribution: bool) -> EpicArtefact:
|
|
60
69
|
intent = EpicIntent(
|
|
61
70
|
in_order_to="<achieve a benefit>",
|
|
62
71
|
as_a="<(user) role>",
|
|
@@ -70,13 +79,14 @@ def _default_epic(title: str) -> EpicArtefact:
|
|
|
70
79
|
return EpicArtefact(
|
|
71
80
|
tags=["sample_tag"],
|
|
72
81
|
title=title,
|
|
73
|
-
description="<further optional description to understand the
|
|
82
|
+
description="<further optional description to understand the epic, markdown capable text formatting>",
|
|
74
83
|
intent=intent,
|
|
75
|
-
rules=rules
|
|
84
|
+
rules=rules,
|
|
85
|
+
contribution=default_contribution() if use_default_contribution else None
|
|
76
86
|
)
|
|
77
87
|
|
|
78
88
|
|
|
79
|
-
def _default_userstory(title: str) -> UserstoryArtefact:
|
|
89
|
+
def _default_userstory(title: str, use_default_contribution: bool) -> UserstoryArtefact:
|
|
80
90
|
intent = UserstoryIntent(
|
|
81
91
|
in_order_to="<achieve a benefit>",
|
|
82
92
|
as_a="<(user) role>",
|
|
@@ -93,25 +103,27 @@ def _default_userstory(title: str) -> UserstoryArtefact:
|
|
|
93
103
|
description="<further optional description to understand the userstory, markdown capable text formatting>",
|
|
94
104
|
intent=intent,
|
|
95
105
|
rules=rules,
|
|
96
|
-
estimate="<story points, scale?>"
|
|
106
|
+
estimate="<story points, scale?>",
|
|
107
|
+
contribution=default_contribution() if use_default_contribution else None
|
|
97
108
|
)
|
|
98
109
|
|
|
99
110
|
|
|
100
|
-
def _default_example(title: str) -> ExampleArtefact:
|
|
111
|
+
def _default_example(title: str, use_default_contribution: bool) -> ExampleArtefact:
|
|
101
112
|
return ExampleArtefact(
|
|
102
113
|
tags=["sample_tag"],
|
|
103
114
|
title=title,
|
|
104
115
|
description="<further optional description to understand the example, markdown capable text formatting>",
|
|
116
|
+
contribution=default_contribution() if use_default_contribution else None
|
|
105
117
|
)
|
|
106
118
|
|
|
107
119
|
|
|
108
|
-
def _default_keyfeature(title: str) -> KeyfeatureArtefact:
|
|
120
|
+
def _default_keyfeature(title: str, use_default_contribution: bool) -> KeyfeatureArtefact:
|
|
109
121
|
intent = KeyfeatureIntent(
|
|
110
122
|
in_order_to="<support a capability or business goal>",
|
|
111
123
|
as_a="<main stakeholder who will benefit>",
|
|
112
124
|
i_want="<a product feature that helps me doing something so that I can achieve my named goal>"
|
|
113
125
|
)
|
|
114
|
-
description = """<further optional description to understand the
|
|
126
|
+
description = """<further optional description to understand the keyfeature, markdown capable text formatting, best practice is using
|
|
115
127
|
GIVEN any precondition
|
|
116
128
|
AND another precondition
|
|
117
129
|
WHEN some action takes place
|
|
@@ -121,11 +133,12 @@ def _default_keyfeature(title: str) -> KeyfeatureArtefact:
|
|
|
121
133
|
tags=["sample_tag"],
|
|
122
134
|
title=title,
|
|
123
135
|
description=description,
|
|
124
|
-
intent=intent
|
|
136
|
+
intent=intent,
|
|
137
|
+
contribution=default_contribution() if use_default_contribution else None
|
|
125
138
|
)
|
|
126
139
|
|
|
127
140
|
|
|
128
|
-
def _default_feature(title: str) -> FeatureArtefact:
|
|
141
|
+
def _default_feature(title: str, use_default_contribution: bool) -> FeatureArtefact:
|
|
129
142
|
intent = FeatureIntent(
|
|
130
143
|
as_a="<user>",
|
|
131
144
|
i_want_to="<do something | need something>",
|
|
@@ -169,27 +182,28 @@ def _default_feature(title: str) -> FeatureArtefact:
|
|
|
169
182
|
]
|
|
170
183
|
)
|
|
171
184
|
]
|
|
172
|
-
description = """<further optional description to understand
|
|
173
|
-
the rule, no format defined, the example artefact is only a placeholder>"""
|
|
185
|
+
description = """<further optional description to understand the feature, no format defined, the example artefact is only a placeholder>"""
|
|
174
186
|
|
|
175
187
|
return FeatureArtefact(
|
|
176
188
|
tags=["sample_tag"],
|
|
177
189
|
title=title,
|
|
178
190
|
description=description,
|
|
179
191
|
intent=intent,
|
|
180
|
-
scenarios=scenarios
|
|
192
|
+
scenarios=scenarios,
|
|
193
|
+
contribution=default_contribution() if use_default_contribution else None
|
|
181
194
|
)
|
|
182
195
|
|
|
183
196
|
|
|
184
|
-
def _default_task(title: str) -> TaskArtefact:
|
|
197
|
+
def _default_task(title: str, use_default_contribution: bool) -> TaskArtefact:
|
|
185
198
|
return TaskArtefact(
|
|
186
199
|
status="to-do",
|
|
187
200
|
title=title,
|
|
188
|
-
description="<further optional description to understand the task, no format defined>"
|
|
201
|
+
description="<further optional description to understand the task, no format defined>",
|
|
202
|
+
contribution=default_contribution() if use_default_contribution else None
|
|
189
203
|
)
|
|
190
204
|
|
|
191
205
|
|
|
192
|
-
def _default_issue(title: str) -> IssueArtefact:
|
|
206
|
+
def _default_issue(title: str, use_default_contribution: bool) -> IssueArtefact:
|
|
193
207
|
description = "<further free text description to understand the issue, no format defined>"
|
|
194
208
|
additional_description = """*Optional descriptions of the issue in Gherkin style*
|
|
195
209
|
|
|
@@ -203,11 +217,12 @@ def _default_issue(title: str) -> IssueArtefact:
|
|
|
203
217
|
tags=["sample_tag"],
|
|
204
218
|
title=title,
|
|
205
219
|
description=description,
|
|
206
|
-
additional_description=additional_description
|
|
220
|
+
additional_description=additional_description,
|
|
221
|
+
contribution=default_contribution() if use_default_contribution else None
|
|
207
222
|
)
|
|
208
223
|
|
|
209
224
|
|
|
210
|
-
def template_artefact_of_type(artefact_type: ArtefactType, title: str = "<descriptive_title>") -> Artefact:
|
|
225
|
+
def template_artefact_of_type(artefact_type: ArtefactType, title: str = "<descriptive_title>", use_default_contribution: bool = True) -> Artefact:
|
|
211
226
|
default_creation_functions = {
|
|
212
227
|
ArtefactType.vision: _default_vision,
|
|
213
228
|
ArtefactType.businessgoal: _default_businessgoal,
|
|
@@ -220,5 +235,6 @@ def template_artefact_of_type(artefact_type: ArtefactType, title: str = "<descri
|
|
|
220
235
|
ArtefactType.task: _default_task,
|
|
221
236
|
ArtefactType.issue: _default_issue
|
|
222
237
|
}
|
|
223
|
-
|
|
224
|
-
|
|
238
|
+
if artefact_type not in default_creation_functions.keys():
|
|
239
|
+
return None
|
|
240
|
+
return default_creation_functions[artefact_type](title, use_default_contribution)
|
|
@@ -38,7 +38,7 @@ class BusinessgoalIntent(Intent):
|
|
|
38
38
|
lines = []
|
|
39
39
|
|
|
40
40
|
as_a_line = as_a_serializer(self.as_a)
|
|
41
|
-
|
|
41
|
+
|
|
42
42
|
lines.append(f"In order to {self.in_order_to}")
|
|
43
43
|
lines.append(as_a_line)
|
|
44
44
|
lines.append(f"I want {self.i_want}")
|
|
@@ -47,39 +47,37 @@ class BusinessgoalIntent(Intent):
|
|
|
47
47
|
|
|
48
48
|
@classmethod
|
|
49
49
|
def deserialize_from_lines(cls, lines: List[str], start_index: int = 0) -> 'BusinessgoalIntent':
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
50
|
+
prefixes = [
|
|
51
|
+
("In order to ", "in_order_to"),
|
|
52
|
+
("As a ", "as_a"),
|
|
53
|
+
("As an ", "as_a"),
|
|
54
|
+
("I want ", "i_want"),
|
|
55
|
+
]
|
|
56
|
+
found = {"in_order_to": None, "as_a": None, "i_want": None}
|
|
57
|
+
|
|
58
|
+
def match_and_store(line):
|
|
59
|
+
for prefix, field in prefixes:
|
|
60
|
+
if line.startswith(prefix) and found[field] is None:
|
|
61
|
+
found[field] = line[len(prefix):].strip()
|
|
62
|
+
return True
|
|
63
|
+
return False
|
|
58
64
|
|
|
59
65
|
index = start_index
|
|
60
|
-
while index < len(lines) and (
|
|
61
|
-
|
|
62
|
-
if line.startswith(in_order_to_prefix) and not in_order_to:
|
|
63
|
-
in_order_to = line[len(in_order_to_prefix):].strip()
|
|
64
|
-
elif line.startswith(as_a_prefix) and not as_a:
|
|
65
|
-
as_a = line[len(as_a_prefix):].strip()
|
|
66
|
-
elif line.startswith(as_a_prefix_alt) and not as_a:
|
|
67
|
-
as_a = line[len(as_a_prefix_alt):].strip()
|
|
68
|
-
elif line.startswith(i_want_prefix) and not i_want:
|
|
69
|
-
i_want = line[len(i_want_prefix):].strip()
|
|
66
|
+
while index < len(lines) and any(v is None for v in found.values()):
|
|
67
|
+
match_and_store(lines[index])
|
|
70
68
|
index += 1
|
|
71
69
|
|
|
72
|
-
if not in_order_to:
|
|
70
|
+
if not found["in_order_to"]:
|
|
73
71
|
raise ValueError("Could not find 'In order to' line")
|
|
74
|
-
if not as_a:
|
|
72
|
+
if not found["as_a"]:
|
|
75
73
|
raise ValueError("Could not find 'As a' line")
|
|
76
|
-
if not i_want:
|
|
74
|
+
if not found["i_want"]:
|
|
77
75
|
raise ValueError("Could not find 'I want' line")
|
|
78
76
|
|
|
79
77
|
return cls(
|
|
80
|
-
in_order_to=in_order_to,
|
|
81
|
-
as_a=as_a,
|
|
82
|
-
i_want=i_want
|
|
78
|
+
in_order_to=found["in_order_to"],
|
|
79
|
+
as_a=found["as_a"],
|
|
80
|
+
i_want=found["i_want"]
|
|
83
81
|
)
|
|
84
82
|
|
|
85
83
|
|
|
@@ -48,39 +48,38 @@ class EpicIntent(Intent):
|
|
|
48
48
|
|
|
49
49
|
@classmethod
|
|
50
50
|
def deserialize_from_lines(cls, lines: List[str], start_index: int = 0) -> 'EpicIntent':
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
51
|
+
prefixes = [
|
|
52
|
+
("In order to ", "in_order_to"),
|
|
53
|
+
("As a ", "as_a"),
|
|
54
|
+
("As an ", "as_a"),
|
|
55
|
+
("I want ", "i_want"),
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
found = {"in_order_to": None, "as_a": None, "i_want": None}
|
|
59
|
+
|
|
60
|
+
def match_and_store(line):
|
|
61
|
+
for prefix, field in prefixes:
|
|
62
|
+
if line.startswith(prefix) and found[field] is None:
|
|
63
|
+
found[field] = line[len(prefix):].strip()
|
|
64
|
+
return True
|
|
65
|
+
return False
|
|
59
66
|
|
|
60
67
|
index = start_index
|
|
61
|
-
while index < len(lines) and (
|
|
62
|
-
|
|
63
|
-
if line.startswith(in_order_to_prefix) and not in_order_to:
|
|
64
|
-
in_order_to = line[len(in_order_to_prefix):].strip()
|
|
65
|
-
elif line.startswith(as_a_prefix) and not as_a:
|
|
66
|
-
as_a = line[len(as_a_prefix):].strip()
|
|
67
|
-
elif line.startswith(as_a_prefix_alt) and not as_a:
|
|
68
|
-
as_a = line[len(as_a_prefix_alt):].strip()
|
|
69
|
-
elif line.startswith(i_want_prefix) and not i_want:
|
|
70
|
-
i_want = line[len(i_want_prefix):].strip()
|
|
68
|
+
while index < len(lines) and any(v is None for v in found.values()):
|
|
69
|
+
match_and_store(lines[index])
|
|
71
70
|
index += 1
|
|
72
71
|
|
|
73
|
-
if not in_order_to:
|
|
72
|
+
if not found["in_order_to"]:
|
|
74
73
|
raise ValueError("Could not find 'In order to' line")
|
|
75
|
-
if not as_a:
|
|
74
|
+
if not found["as_a"]:
|
|
76
75
|
raise ValueError("Could not find 'As a' line")
|
|
77
|
-
if not i_want:
|
|
76
|
+
if not found["i_want"]:
|
|
78
77
|
raise ValueError("Could not find 'I want' line")
|
|
79
78
|
|
|
80
79
|
return cls(
|
|
81
|
-
in_order_to=in_order_to,
|
|
82
|
-
as_a=as_a,
|
|
83
|
-
i_want=i_want
|
|
80
|
+
in_order_to=found["in_order_to"],
|
|
81
|
+
as_a=found["as_a"],
|
|
82
|
+
i_want=found["i_want"]
|
|
84
83
|
)
|
|
85
84
|
|
|
86
85
|
|
|
@@ -48,39 +48,36 @@ class FeatureIntent(Intent):
|
|
|
48
48
|
|
|
49
49
|
@classmethod
|
|
50
50
|
def deserialize_from_lines(cls, lines: List[str], start_index: int = 0) -> 'FeatureIntent':
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
51
|
+
prefixes = [
|
|
52
|
+
("As a ", "as_a"),
|
|
53
|
+
("As an ", "as_a"),
|
|
54
|
+
("I want to ", "i_want_to"),
|
|
55
|
+
("So that ", "so_that"),
|
|
56
|
+
]
|
|
57
|
+
found = {"as_a": None, "i_want_to": None, "so_that": None}
|
|
58
|
+
|
|
59
|
+
def match_and_store(line):
|
|
60
|
+
for prefix, field in prefixes:
|
|
61
|
+
if line.startswith(prefix) and found[field] is None:
|
|
62
|
+
found[field] = line[len(prefix):].strip()
|
|
63
|
+
return
|
|
59
64
|
|
|
60
65
|
index = start_index
|
|
61
|
-
while index < len(lines) and (
|
|
62
|
-
|
|
63
|
-
if line.startswith(as_a_prefix) and not as_a:
|
|
64
|
-
as_a = line[len(as_a_prefix):].strip()
|
|
65
|
-
if line.startswith(as_a_prefix_alt) and not as_a:
|
|
66
|
-
as_a = line[len(as_a_prefix_alt):].strip()
|
|
67
|
-
if line.startswith(i_want_to_prefix) and not i_want_to:
|
|
68
|
-
i_want_to = line[len(i_want_to_prefix):].strip()
|
|
69
|
-
if line.startswith(so_that_prefix) and not so_that:
|
|
70
|
-
so_that = line[len(so_that_prefix):].strip()
|
|
66
|
+
while index < len(lines) and any(v is None for v in found.values()):
|
|
67
|
+
match_and_store(lines[index].strip())
|
|
71
68
|
index += 1
|
|
72
69
|
|
|
73
|
-
if not as_a:
|
|
70
|
+
if not found["as_a"]:
|
|
74
71
|
raise ValueError("Could not find 'As a' line")
|
|
75
|
-
if not i_want_to:
|
|
72
|
+
if not found["i_want_to"]:
|
|
76
73
|
raise ValueError("Could not find 'I want to' line")
|
|
77
|
-
if not so_that:
|
|
74
|
+
if not found["so_that"]:
|
|
78
75
|
raise ValueError("Could not find 'So that' line")
|
|
79
76
|
|
|
80
77
|
return cls(
|
|
81
|
-
as_a=as_a,
|
|
82
|
-
i_want_to=i_want_to,
|
|
83
|
-
so_that=so_that
|
|
78
|
+
as_a=found["as_a"],
|
|
79
|
+
i_want_to=found["i_want_to"],
|
|
80
|
+
so_that=found["so_that"]
|
|
84
81
|
)
|
|
85
82
|
|
|
86
83
|
|
|
@@ -138,6 +135,7 @@ class Scenario(BaseModel):
|
|
|
138
135
|
@field_validator('title')
|
|
139
136
|
def validate_title(cls, v: str) -> str:
|
|
140
137
|
v = v.strip()
|
|
138
|
+
v = v.replace('_', ' ')
|
|
141
139
|
if not v:
|
|
142
140
|
raise ValueError("title must not be empty")
|
|
143
141
|
return v
|
|
@@ -181,6 +179,7 @@ class ScenarioOutline(BaseModel):
|
|
|
181
179
|
def validate_title(cls, v: str) -> str:
|
|
182
180
|
if not v:
|
|
183
181
|
raise ValueError("title must not be empty in a ScenarioOutline")
|
|
182
|
+
v = v.replace('_', ' ')
|
|
184
183
|
return v
|
|
185
184
|
|
|
186
185
|
@field_validator('steps', mode='before')
|
|
@@ -213,28 +212,54 @@ class ScenarioOutline(BaseModel):
|
|
|
213
212
|
def from_lines(cls, lines: List[str], start_idx: int) -> Tuple['ScenarioOutline', int]:
|
|
214
213
|
"""Parse a ScenarioOutline from a list of lines starting at start_idx."""
|
|
215
214
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
215
|
+
def extract_title(line: str) -> str:
|
|
216
|
+
if not line.startswith('Scenario Outline:'):
|
|
217
|
+
raise ValueError("Expected 'Scenario Outline:' at start index")
|
|
218
|
+
return line[len('Scenario Outline:'):].strip()
|
|
219
|
+
|
|
220
|
+
def extract_steps(lines: List[str], idx: int) -> Tuple[List[str], int]:
|
|
221
|
+
steps = []
|
|
222
|
+
while idx < len(lines) and not lines[idx].strip().startswith('Examples:'):
|
|
223
|
+
if lines[idx].strip():
|
|
224
|
+
steps.append(lines[idx].strip())
|
|
225
|
+
idx += 1
|
|
226
|
+
return steps, idx
|
|
227
|
+
|
|
228
|
+
def extract_headers(line: str) -> List[str]:
|
|
229
|
+
return [h.strip() for h in line.split('|') if h.strip()]
|
|
230
|
+
|
|
231
|
+
def extract_row(line: str) -> List[str]:
|
|
232
|
+
return [cell.strip() for cell in line.split('|') if cell.strip()]
|
|
233
|
+
|
|
234
|
+
def is_scenario_line(line: str) -> bool:
|
|
235
|
+
return line.startswith("Scenario:") or line.startswith("Scenario Outline:")
|
|
236
|
+
|
|
237
|
+
def extract_examples(lines: List[str], idx: int) -> Tuple[List['Example'], int]:
|
|
238
|
+
examples = []
|
|
239
|
+
|
|
240
|
+
if idx >= len(lines) or lines[idx].strip() != 'Examples:':
|
|
241
|
+
return examples, idx
|
|
242
|
+
|
|
227
243
|
idx += 1
|
|
228
|
-
headers =
|
|
244
|
+
headers = extract_headers(lines[idx])
|
|
229
245
|
idx += 1
|
|
230
|
-
|
|
231
|
-
|
|
246
|
+
|
|
247
|
+
while idx < len(lines):
|
|
248
|
+
current_line = lines[idx].strip()
|
|
249
|
+
if not current_line or is_scenario_line(current_line):
|
|
232
250
|
break
|
|
233
|
-
|
|
234
|
-
|
|
251
|
+
|
|
252
|
+
row = extract_row(lines[idx])
|
|
235
253
|
example = Example.from_row(headers, row)
|
|
236
254
|
examples.append(example)
|
|
237
255
|
idx += 1
|
|
256
|
+
|
|
257
|
+
return examples, idx
|
|
258
|
+
|
|
259
|
+
title = extract_title(lines[start_idx])
|
|
260
|
+
steps, idx = extract_steps(lines, start_idx + 1)
|
|
261
|
+
examples, idx = extract_examples(lines, idx)
|
|
262
|
+
|
|
238
263
|
return cls(title=title, steps=steps, examples=examples), idx
|
|
239
264
|
|
|
240
265
|
|
|
@@ -289,12 +314,10 @@ class FeatureArtefact(Artefact):
|
|
|
289
314
|
|
|
290
315
|
def _serialize_scenario_outline(self, scenario: ScenarioOutline) -> str:
|
|
291
316
|
"""Serialize a ScenarioOutline with aligned examples."""
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
if scenario.examples:
|
|
317
|
+
def serialize_scenario_examples():
|
|
318
|
+
nonlocal lines, scenario
|
|
319
|
+
if not scenario:
|
|
320
|
+
return
|
|
298
321
|
headers = self._extract_placeholders(scenario.steps)
|
|
299
322
|
|
|
300
323
|
rows = [headers]
|
|
@@ -320,6 +343,13 @@ class FeatureArtefact(Artefact):
|
|
|
320
343
|
for formatted_row in formatted_rows:
|
|
321
344
|
lines.append(f" {formatted_row}")
|
|
322
345
|
|
|
346
|
+
lines = []
|
|
347
|
+
lines.append(f" Scenario Outline: {scenario.title}")
|
|
348
|
+
for step in scenario.steps:
|
|
349
|
+
lines.append(f" {step}")
|
|
350
|
+
|
|
351
|
+
serialize_scenario_examples()
|
|
352
|
+
|
|
323
353
|
return "\n".join(lines)
|
|
324
354
|
|
|
325
355
|
def _extract_placeholders(self, steps):
|
|
@@ -47,39 +47,36 @@ class KeyfeatureIntent(Intent):
|
|
|
47
47
|
|
|
48
48
|
@classmethod
|
|
49
49
|
def deserialize_from_lines(cls, lines: List[str], start_index: int = 0) -> 'KeyfeatureIntent':
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
50
|
+
prefixes = [
|
|
51
|
+
("In order to ", "in_order_to"),
|
|
52
|
+
("As a ", "as_a"),
|
|
53
|
+
("As an ", "as_a"),
|
|
54
|
+
("I want ", "i_want"),
|
|
55
|
+
]
|
|
56
|
+
found = {"in_order_to": None, "as_a": None, "i_want": None}
|
|
57
|
+
|
|
58
|
+
def match_and_store(line):
|
|
59
|
+
for prefix, field in prefixes:
|
|
60
|
+
if line.startswith(prefix) and found[field] is None:
|
|
61
|
+
found[field] = line[len(prefix):].strip()
|
|
62
|
+
return
|
|
58
63
|
|
|
59
64
|
index = start_index
|
|
60
|
-
while index < len(lines) and (
|
|
61
|
-
|
|
62
|
-
if line.startswith(in_order_to_prefix) and not in_order_to:
|
|
63
|
-
in_order_to = line[len(in_order_to_prefix):].strip()
|
|
64
|
-
elif line.startswith(as_a_prefix) and not as_a:
|
|
65
|
-
as_a = line[len(as_a_prefix):].strip()
|
|
66
|
-
elif line.startswith(as_a_prefix_alt) and not as_a:
|
|
67
|
-
as_a = line[len(as_a_prefix_alt):].strip()
|
|
68
|
-
elif line.startswith(i_want_prefix) and not i_want:
|
|
69
|
-
i_want = line[len(i_want_prefix):].strip()
|
|
65
|
+
while index < len(lines) and any(v is None for v in found.values()):
|
|
66
|
+
match_and_store(lines[index])
|
|
70
67
|
index += 1
|
|
71
68
|
|
|
72
|
-
if not in_order_to:
|
|
69
|
+
if not found["in_order_to"]:
|
|
73
70
|
raise ValueError("Could not find 'In order to' line")
|
|
74
|
-
if not as_a:
|
|
71
|
+
if not found["as_a"]:
|
|
75
72
|
raise ValueError("Could not find 'As a' line")
|
|
76
|
-
if not i_want:
|
|
73
|
+
if not found["i_want"]:
|
|
77
74
|
raise ValueError("Could not find 'I want' line")
|
|
78
75
|
|
|
79
76
|
return cls(
|
|
80
|
-
in_order_to=in_order_to,
|
|
81
|
-
as_a=as_a,
|
|
82
|
-
i_want=i_want
|
|
77
|
+
in_order_to=found["in_order_to"],
|
|
78
|
+
as_a=found["as_a"],
|
|
79
|
+
i_want=found["i_want"]
|
|
83
80
|
)
|
|
84
81
|
|
|
85
82
|
|