Coverage for manyworlds/scenario_forest.py: 100%
158 statements
« prev ^ index » next coverage.py v7.3.0, created at 2023-08-25 16:44 +0200
« prev ^ index » next coverage.py v7.3.0, created at 2023-08-25 16:44 +0200
1"""Defines the ScenarioForest Class"""
3import re
4import igraph as ig # type: ignore
5from typing import Optional, TextIO, Literal, List, Tuple
7from .scenario import Scenario
8from .step import Step, Prerequisite, Action, Assertion
9from .data_table import DataTable, DataTableRow
10from .exceptions import InvalidFeatureFileError
13class ScenarioForest:
14 """A collection of one or more directed trees
15 the vertices of which represent BDD scenarios."""
17 TAB_SIZE: int = 4
18 """
19 int
21 The number of spaces per indentation level
22 """
24 graph: ig.Graph
26 def __init__(self) -> None:
27 """Constructor method"""
29 self.graph = ig.Graph(directed=True)
31 @classmethod
32 def split_line(cls, raw_line: str) -> Tuple[int, str]:
33 """Splits a raw feature file line into the indentation part and the line part.
35 Parameters
36 ----------
37 raw_line : str
38 The raw feature file line including indentation and newline
40 Returns
41 -------
42 tuple[int, str]
43 The indentation part and the line part (without newline) as a tuple
44 """
46 line: str = raw_line.rstrip()
47 line_wo_indentation: str = line.lstrip()
48 indentation: int = len(line) - len(line_wo_indentation)
49 return (indentation, line_wo_indentation)
51 def parse_step_line(self, line: str) -> Optional[Step]:
52 """Parses a feature file step line into the appropriate
53 Step subclass instance.
55 If the line begins with "And" then the step type is determined
56 by the type of the last step.
58 Parameters
59 ----------
60 line : str
61 The step line (without indentation and newline)
63 Returns
64 -------
65 Prerequisite or Action or Assertion
66 An instance of a Step subclass
67 """
69 match: Optional[re.Match] = Step.STEP_PATTERN.match(line)
70 if match is None:
71 return None
73 conjunction, name, comment = match.group("conjunction", "name", "comment")
75 if conjunction in ["And", "But"]:
76 previous_step = self.scenarios()[-1].steps[-1]
77 conjunction = previous_step.conjunction
79 if conjunction == "Given":
80 return Prerequisite(name, comment=comment)
81 elif conjunction == "When":
82 return Action(name, comment=comment)
83 else: # conjunction == "Then"
84 return Assertion(name, comment=comment)
86 @classmethod
87 def from_file(cls, file_path) -> "ScenarioForest":
88 """Parses an indented feature file into a ScenarioForest instance.
90 Parameters
91 ----------
92 file_path : str
93 The path to the feature file
95 Returns
96 -------
97 ScenarioForest
98 A new ScenarioForest instance
99 """
101 forest = ScenarioForest()
102 with open(file_path) as indented_file:
103 for line_no, raw_line in enumerate(indented_file.readlines()):
104 if raw_line.strip() == "":
105 continue # Skip empty lines
107 indentation: int
108 line: str
109 indentation, line = cls.split_line(raw_line)
111 # (1) Determine and validate indentation level:
112 if indentation % cls.TAB_SIZE == 0:
113 level: int = int(indentation / cls.TAB_SIZE) + 1
114 else:
115 raise InvalidFeatureFileError(
116 "Invalid indentation at line {line_no}: {line}".format(
117 line_no=line_no + 1, line=line
118 )
119 )
121 # (2) Parse line:
123 # Scenario line?
124 match: Optional[re.Match] = Scenario.SCENARIO_PATTERN.match(line)
125 if match is not None:
126 forest.append_scenario(match.group("scenario_name"), at_level=level)
127 continue
129 # Step line?
130 new_step: Optional[Step] = forest.parse_step_line(line)
131 if new_step:
132 forest.append_step(new_step, at_level=level)
133 continue
135 # Data table line?
136 new_data_row: Optional[DataTableRow] = DataTable.parse_line(line)
137 if new_data_row:
138 forest.append_data_row(new_data_row, at_level=level)
139 continue
141 # Not a valid line
142 raise InvalidFeatureFileError(
143 "Unable to parse line {line_no}: {line}".format(
144 line_no=line_no + 1, line=line
145 )
146 )
148 return forest
150 def append_scenario(self, scenario_name: str, at_level: int) -> Scenario:
151 """Append a scenario to the scenario forest.
153 Parameters
154 ----------
155 scenario : Scenario
156 The scenario to append
158 at_level : int
159 The indentation level of the scenario in the input file.
160 Used for indentation validation.
161 """
163 if at_level > 1: # Non-root scenario:
164 # Find the parent to connect scenario to:
165 parent_level: int = at_level - 1
166 parent_level_scenarios: List[Scenario] = [
167 sc
168 for sc in self.scenarios()
169 if sc.level() == parent_level and not sc.is_closed()
170 ]
171 if len(parent_level_scenarios) > 0:
172 return Scenario(
173 scenario_name,
174 self.graph,
175 parent_scenario=parent_level_scenarios[-1],
176 )
177 else:
178 raise InvalidFeatureFileError(
179 "Excessive indentation at line: Scenario: {name}".format(
180 name=scenario_name
181 )
182 )
184 else: # Root scenario:
185 return Scenario(scenario_name, self.graph)
187 def append_step(self, step: Step, at_level: int) -> None:
188 """Appends a step to the scenario forest.
190 Parameters
191 ----------
192 step : Prerequisite or Action or Assertion
193 The Step subclass instance to append
195 at_level : int
196 The level at which to add the step.
197 Used for indentation validation.
198 """
200 # Ensure the indentation level of the step matches
201 # the last scenario indentation level
202 last_scenario: Scenario = self.scenarios()[-1]
203 if at_level == last_scenario.level():
204 last_scenario.steps.append(step)
205 else:
206 raise InvalidFeatureFileError(
207 "Invalid indentation at line: {name}".format(name=step.name)
208 )
210 def append_data_row(self, data_row: DataTableRow, at_level: int) -> None:
211 """Appends a data row to the scenario forest.
213 Adds a data table to the last step if necessary
214 Otherwise adds row to data table.
216 Parameters
217 ----------
218 data_row : DataTableRow
219 The data row to append
221 at_level : int
222 The level at which to add the data row.
223 Used for indentation validation.
224 """
226 last_step: Step = self.scenarios()[-1].steps[-1]
227 if last_step.data:
228 # Row is an additional row for an existing table
229 last_step.data.rows.append(data_row)
230 else:
231 # Row is the header row of a new table
232 last_step.data = DataTable(data_row)
234 @classmethod
235 def write_scenario_name(
236 cls, file_handle: TextIO, scenarios: List[Scenario]
237 ) -> None:
238 """Writes formatted scenario name to the end of a "relaxed" flat feature file.
240 Parameters
241 ----------
242 file_handle : TextIO
243 The file to which to append the scenario name
245 scenarios : List[Scenario]
246 Organizational and validated scenarios along the path
247 """
249 # (1) Group consecutive regular or organizational scenarios:
250 groups: List[List[Scenario]] = []
252 # Function for determining whether a scenario can be added to a current group:
253 def group_available_for_scenario(gr, sc):
254 return (
255 len(gr) > 0
256 and len(gr[-1]) > 0
257 and gr[-1][-1].is_organizational() == sc.is_organizational()
258 )
260 for sc in scenarios:
261 if group_available_for_scenario(groups, sc):
262 groups[-1].append(sc) # add to current group
263 else:
264 groups.append([sc]) # start new group
266 # (2) Format each group to strings:
267 group_strings: List[str] = []
269 for group in groups:
270 if group[-1].is_organizational():
271 group_strings.append(
272 "[{}]".format(" / ".join([sc.name for sc in group]))
273 )
274 else:
275 group_strings.append(" > ".join([sc.name for sc in group]))
277 # (3) Assemble and write name:
278 file_handle.write(
279 "Scenario: {scenario_name}\n".format(scenario_name=" ".join(group_strings))
280 )
282 @classmethod
283 def write_scenario_steps(
284 cls, file_handle: TextIO, steps: List[Step], comments: bool = False
285 ) -> None:
286 """Writes formatted scenario steps to the end of the flat feature file.
288 Parameters
289 ----------
290 file_handle : io.TextIOWrapper
291 The file to which to append the steps
293 steps : List[Step]
294 Steps to append to file_handle
296 comments: bool
297 Whether or not to write comments
298 """
300 last_step: Optional[Step] = None
301 for step_num, step in enumerate(steps):
302 first_of_type = (
303 last_step is None or last_step.conjunction != step.conjunction
304 )
305 file_handle.write(step.format(first_of_type=first_of_type) + "\n")
306 if comments and step.comment:
307 file_handle.write("# {comment}\n".format(comment=step.comment))
308 if step.data:
309 ScenarioForest.write_data_table(file_handle, step.data, comments)
310 last_step = step
312 @classmethod
313 def write_data_table(
314 cls, file_handle: TextIO, data_table: DataTable, comments: bool = False
315 ) -> None:
316 """Writes formatted data table to the end of the flat feature file.
318 Parameters
319 ----------
320 file_handle : io.TextIOWrapper
321 The file to which to append the data table
323 data_table : DataTable
324 A data table
326 comments : bool
327 Whether or not to write comments
328 """
330 # Determine column widths to accommodate all values:
331 col_widths: List[int] = [
332 max([len(cell) for cell in col])
333 for col in list(zip(*data_table.to_list_of_list()))
334 ]
336 for row in data_table.to_list():
337 # pad values with spaces to column width:
338 padded_row: List[str] = [
339 row.values[col_num].ljust(col_width)
340 for col_num, col_width in enumerate(col_widths)
341 ]
343 # add column enclosing pipes:
344 table_row_string: str = " | {columns} |".format(
345 columns=" | ".join(padded_row)
346 )
348 # add comments:
349 if comments and row.comment:
350 table_row_string += " # {comment}".format(comment=row.comment)
352 # write line:
353 file_handle.write(table_row_string + "\n")
355 def flatten(
356 self,
357 file_path: str,
358 mode: Literal["strict", "relaxed"] = "strict",
359 comments: bool = False,
360 ) -> None:
361 """Writes a flat (no indentation) feature file representing the scenario forest.
363 Parameters
364 ----------
365 file_path : str
366 Path to flat feature file to be written
368 mode : {"strict", "relaxed"}, default="strict"
369 Flattening mode. Either "strict" or "relaxed"
371 comments : bool, default = False
372 Whether or not to write comments
373 """
375 if mode == "strict":
376 self.flatten_strict(file_path, comments=comments)
377 elif mode == "relaxed":
378 self.flatten_relaxed(file_path, comments=comments)
380 def flatten_strict(self, file_path: str, comments: bool = False) -> None:
381 """Write. a flat (no indentation) feature file representing the forest
382 using the "strict" flattening mode.
384 The "strict" flattening mode writes one scenario per vertex in the tree,
385 resulting in a feature file with one set of "When" steps followed by one
386 set of "Then" steps (generally recommended).
388 Parameters
389 ----------
390 file_path : str
391 Path to flat feature file
393 comments : bool, default = False
394 Whether or not to write comments
395 """
397 with open(file_path, "w") as flat_file:
398 for scenario in [
399 sc for sc in self.scenarios() if not sc.is_organizational()
400 ]:
401 # Scenario name:
402 scenarios_for_naming: List[Scenario] = [
403 sc
404 for sc in scenario.path_scenarios()
405 if sc.is_organizational() or sc == scenario
406 ]
407 ScenarioForest.write_scenario_name(flat_file, scenarios_for_naming)
409 ancestor_scenarios = scenario.ancestors()
410 steps: List[Step] = []
411 # collect prerequisites from all scenarios along the path
412 steps += [st for sc in ancestor_scenarios for st in sc.prerequisites()]
413 # collect actions from all scenarios along the path
414 steps += [st for sc in ancestor_scenarios for st in sc.actions()]
415 # add all steps from the destination scenario only
416 steps += scenario.steps
418 # Write steps:
419 ScenarioForest.write_scenario_steps(flat_file, steps, comments=comments)
420 flat_file.write("\n") # Empty line to separate scenarios
422 def flatten_relaxed(self, file_path: str, comments: bool = False) -> None:
423 """Writes a flat (no indentation) feature file representing the forest
424 using the "relaxed" flattening mode.
426 The "relaxed" flattening mode writes one scenario per leaf vertex in the tree,
427 resulting in a feature file with multiple consecutive sets of "When" and "Then"
428 steps per scenario (generally considered an anti-pattern).
430 Parameters
431 ----------
432 file_path : str
433 Path to flat feature file
435 comments : bool, default = False
436 Whether or not to write comments
437 """
439 with open(file_path, "w") as flat_file:
440 for scenario in self.leaf_scenarios():
441 steps: List[Step] = []
442 # organizational and validated scenarios used for naming:
443 scenarios_for_naming: List[Scenario] = []
444 for path_scenario in scenario.path_scenarios():
445 steps += path_scenario.prerequisites()
446 steps += path_scenario.actions()
447 if path_scenario.is_organizational():
448 scenarios_for_naming.append(path_scenario)
449 elif not path_scenario.validated:
450 steps += path_scenario.assertions()
451 path_scenario.validated = True
452 scenarios_for_naming.append(path_scenario)
454 ScenarioForest.write_scenario_name(flat_file, scenarios_for_naming)
455 # Write steps:
456 ScenarioForest.write_scenario_steps(flat_file, steps, comments=comments)
457 flat_file.write("\n") # Empty line to separate scenarios
459 def find(self, *scenario_names: List[str]) -> Optional[Scenario]:
460 """Finds and returns a scenario by the names of all scenarios along the path
461 from a root scenario to the destination scenario.
463 Used in tests only
465 Parameters
466 ----------
467 scenario_names : List[str]
468 List of scenario names
470 Returns
471 -------
472 Scenario or None
473 The found scenario, or None if none found
474 """
476 # Root scenario:
477 scenario: Optional[Scenario] = next(
478 (sc for sc in self.root_scenarios() if sc.name == scenario_names[0]), None
479 )
480 if scenario is None:
481 return None
483 # Root descendant scenarios:
484 for scenario_name in scenario_names[1:]:
485 scenario = next(
486 (
487 vt["scenario"]
488 for vt in scenario.vertex.successors()
489 if vt["scenario"].name == scenario_name
490 ),
491 None,
492 )
493 if scenario is None:
494 return None
496 return scenario
498 def scenarios(self) -> List[Scenario]:
499 """Returns all scenarios
501 Returns
502 -------
503 List[Scenario]
504 All scenarios in index order
505 """
507 return [vx["scenario"] for vx in self.graph.vs]
509 def root_scenarios(self) -> List[Scenario]:
510 """Returns the root scenarios (scenarios with vertices without incoming edges).
512 Returns
513 -------
514 List[Scenario]
515 All root scenarios in index order
516 """
517 return [vx["scenario"] for vx in self.graph.vs if vx.indegree() == 0]
519 def leaf_scenarios(self) -> List[Scenario]:
520 """Returns the leaf scenarios (scenarios with vertices without outgoing edges).
522 Returns
523 -------
524 List[Scenario]
525 All leaf scenarios in index order
526 """
527 return [vx["scenario"] for vx in self.graph.vs if vx.outdegree() == 0]