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.

Files changed (39) hide show
  1. ara_cli/ara_command_action.py +15 -15
  2. ara_cli/ara_command_parser.py +2 -1
  3. ara_cli/ara_config.py +181 -73
  4. ara_cli/artefact_autofix.py +130 -68
  5. ara_cli/artefact_creator.py +1 -1
  6. ara_cli/artefact_models/artefact_model.py +26 -7
  7. ara_cli/artefact_models/artefact_templates.py +47 -31
  8. ara_cli/artefact_models/businessgoal_artefact_model.py +23 -25
  9. ara_cli/artefact_models/epic_artefact_model.py +23 -24
  10. ara_cli/artefact_models/feature_artefact_model.py +76 -46
  11. ara_cli/artefact_models/keyfeature_artefact_model.py +21 -24
  12. ara_cli/artefact_models/task_artefact_model.py +73 -13
  13. ara_cli/artefact_models/userstory_artefact_model.py +22 -24
  14. ara_cli/artefact_models/vision_artefact_model.py +23 -42
  15. ara_cli/artefact_scan.py +55 -17
  16. ara_cli/chat.py +23 -5
  17. ara_cli/prompt_handler.py +4 -4
  18. ara_cli/tag_extractor.py +43 -28
  19. ara_cli/template_manager.py +3 -8
  20. ara_cli/version.py +1 -1
  21. {ara_cli-0.1.9.73.dist-info → ara_cli-0.1.9.75.dist-info}/METADATA +1 -1
  22. {ara_cli-0.1.9.73.dist-info → ara_cli-0.1.9.75.dist-info}/RECORD +29 -39
  23. tests/test_ara_config.py +420 -36
  24. tests/test_artefact_autofix.py +289 -25
  25. tests/test_artefact_scan.py +296 -35
  26. tests/test_chat.py +35 -15
  27. ara_cli/templates/template.businessgoal +0 -10
  28. ara_cli/templates/template.capability +0 -10
  29. ara_cli/templates/template.epic +0 -15
  30. ara_cli/templates/template.example +0 -6
  31. ara_cli/templates/template.feature +0 -26
  32. ara_cli/templates/template.issue +0 -14
  33. ara_cli/templates/template.keyfeature +0 -15
  34. ara_cli/templates/template.task +0 -6
  35. ara_cli/templates/template.userstory +0 -17
  36. ara_cli/templates/template.vision +0 -14
  37. {ara_cli-0.1.9.73.dist-info → ara_cli-0.1.9.75.dist-info}/WHEEL +0 -0
  38. {ara_cli-0.1.9.73.dist-info → ara_cli-0.1.9.75.dist-info}/entry_points.txt +0 -0
  39. {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 _default_vision(title: str) -> VisionArtefact:
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 vision, markdown capable text formatting>",
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
- in_order_to="<reach primarily a monetary business goal>",
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 vision, markdown capable text formatting>",
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 capability, markdown capable text formatting, best practice is using
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
- return default_creation_functions[artefact_type](title)
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
- in_order_to = None
51
- as_a = None
52
- i_want = None
53
-
54
- in_order_to_prefix = "In order to "
55
- as_a_prefix = "As a "
56
- as_a_prefix_alt = "As an "
57
- i_want_prefix = "I want "
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 (not in_order_to or not as_a or not i_want):
61
- line = lines[index]
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
- in_order_to = None
52
- as_a = None
53
- i_want = None
54
-
55
- in_order_to_prefix = "In order to "
56
- as_a_prefix = "As a "
57
- as_a_prefix_alt = "As an "
58
- i_want_prefix = "I want "
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 (not in_order_to or not as_a or not i_want):
62
- line = lines[index]
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
- as_a = None
52
- i_want_to = None
53
- so_that = None
54
-
55
- as_a_prefix = "As a "
56
- as_a_prefix_alt = "As an "
57
- i_want_to_prefix = "I want to "
58
- so_that_prefix = "So that "
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 (not as_a or not i_want_to or not so_that):
62
- line = lines[index]
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
- if not lines[start_idx].startswith('Scenario Outline:'):
217
- raise ValueError("Expected 'Scenario Outline:' at start index")
218
- title = lines[start_idx][len('Scenario Outline:'):].strip()
219
- steps = []
220
- idx = start_idx + 1
221
- while idx < len(lines) and not lines[idx].strip().startswith('Examples:'):
222
- if lines[idx].strip():
223
- steps.append(lines[idx].strip())
224
- idx += 1
225
- examples = []
226
- if idx < len(lines) and lines[idx].strip() == 'Examples:':
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 = [h.strip() for h in lines[idx].split('|') if h.strip()]
244
+ headers = extract_headers(lines[idx])
229
245
  idx += 1
230
- while idx < len(lines) and lines[idx].strip():
231
- if lines[idx].strip().startswith("Scenario:") or lines[idx].strip().startswith("Scenario Outline:"):
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
- row = [cell.strip()
234
- for cell in lines[idx].split('|') if cell.strip()]
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
- lines = []
293
- lines.append(f" Scenario Outline: {scenario.title}")
294
- for step in scenario.steps:
295
- lines.append(f" {step}")
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
- in_order_to = None
51
- as_a = None
52
- i_want = None
53
-
54
- in_order_to_prefix = "In order to "
55
- as_a_prefix = "As a "
56
- as_a_prefix_alt = "As an "
57
- i_want_prefix = "I want "
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 (not in_order_to or not as_a or not i_want):
61
- line = lines[index]
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