Coverage for manyworlds/scenario_forest.py: 99%
120 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-08-06 10:28 -0400
« prev ^ index » next coverage.py v7.2.7, created at 2023-08-06 10:28 -0400
1"""Defines the ScenarioForest Class"""
2import re
3from igraph import Graph
4import pdb
6from .scenario import Scenario
7from .step import Step, Prerequisite, Action, Assertion
9class ScenarioForest:
10 """A collection of one or more directed trees the vertices of which represent BDD scenarios
12 :param graph: A graph
13 :type graph: class:`igraph.Graph`
14 """
16 TAB_SIZE = 4
17 indentation_pattern = rf'(?P<indentation>( {{{TAB_SIZE}}})*)'
18 scenario_pattern = r'Scenario: (?P<scenario_name>.*)'
20 SCENARIO_LINE_PATTERN = re.compile("^{}{}$".format(indentation_pattern, scenario_pattern))
21 STEP_LINE_PATTERN = re.compile("^{}{}$".format(indentation_pattern, Step.step_pattern))
22 TABLE_LINE_PATTERN = re.compile("^{}{}$".format(indentation_pattern, Step.table_pattern))
24 def __init__(self, graph):
25 """Constructor method
26 """
27 self.graph = graph
29 @classmethod
30 def data_table_list_to_dict(cls, data_table):
31 header_row = data_table[0]
32 return [dict(zip(header_row, row)) for row in data_table[1:]]
34 @classmethod
35 def data_table_dict_to_list(cls, data_table):
36 return [list(data_table[0].keys())] + [list(row.values()) for row in data_table]
38 @classmethod
39 def from_file(cls, file_path):
40 """Create a scenario tree instance from an indented feature file
42 Scan the indented file line by line and:
43 1. Keep track of the last scenario encountered at each indentation level
44 2. Any scenario encountered is added as a child to the last scenario encounterd
45 at the parent level
46 3. Any action or assertion encountered is added to the last scenarion encountered
47 at that level
49 :param file_path: Fath to indented feature file
50 :type file_path: str
51 :return: A new instance of manyworlds.ScenarioForest
52 :rtype: class:'manyworlds.ScenarioForest'
53 """
54 graph = Graph(directed=True)
55 with open(file_path) as indented_file:
56 raw_lines = [l.rstrip('\n') for l in indented_file.readlines() if not l.strip() == ""]
57 current_scenarios = {} # used to keep track of last scenario encountered at each level
58 current_table = None
59 current_step = None
61 # Scan the file line by line
62 for line in raw_lines:
64 # Determine whether line is scenario, step or table row
65 scenario_match = cls.SCENARIO_LINE_PATTERN.match(line)
66 step_match = cls.STEP_LINE_PATTERN.match(line)
67 table_match = cls.TABLE_LINE_PATTERN.match(line)
68 if not (scenario_match or step_match or table_match):
69 raise ValueError('Unable to parse line: ' + line.strip())
71 # close and record any open data table
72 if (scenario_match or step_match) and current_table:
73 current_step.data = ScenarioForest.data_table_list_to_dict(current_table)
74 current_table = None
76 if scenario_match: # Line is scenario
77 current_level = int(len((scenario_match)['indentation']) / cls.TAB_SIZE)
79 current_scenario_vertex = graph.add_vertex()
80 current_scenario = Scenario(scenario_match['scenario_name'], current_scenario_vertex)
81 current_scenario_vertex['scenario'] = current_scenario
82 current_scenarios[current_level] = current_scenario
83 if current_level > 0:
84 # Connect to parent scenario
85 current_scenario_parent = current_scenarios[current_level-1]
86 graph.add_edge(current_scenario_parent.vertex, current_scenario.vertex)
88 elif step_match: # Line is action or assertion
89 current_scenario = current_scenarios[current_level]
90 new_step = Step.parse(step_match[0].strip(), previous_step=current_step)
91 current_scenario.steps.append(new_step)
92 current_step = new_step
94 elif table_match: # Line is table row
95 if current_table == None:
96 current_table = []
97 row = [s.strip() for s in line.split('|')[1:-1]]
98 current_table.append(row)
100 # In case the file ends with a data table:
101 if current_table:
102 current_step.data = ScenarioForest.data_table_list_to_dict(current_table)
104 return ScenarioForest(graph)
106 @classmethod
107 def write_scenario_steps(cls, file_handle, steps, comments=False):
108 """Write formatted scenario steps to file
110 :param file_handle: The file to which to write the steps
111 :type file_handle: class:'io.TextIOWrapper'
112 :param steps: The steps to write
113 :type steps: list of Step
114 """
115 last_step = None
116 for step_num, step in enumerate(steps):
117 first_of_type = (last_step == None or last_step.conjunction != step.conjunction)
118 file_handle.write(step.format(first_of_type=first_of_type) + "\n")
119 if comments and step.comment:
120 file_handle.write("# " + step.comment + "\n")
121 if step.data:
122 ScenarioForest.write_data_table(file_handle, step.data)
123 last_step = step
125 @classmethod
126 def write_data_table(cls, file_handle, data_table):
127 data = ScenarioForest.data_table_dict_to_list(data_table)
128 col_widths = [max([len(cell) for cell in col]) for col in list(zip(*data))]
129 for row in data:
130 padded_row = [row[col_num].ljust(col_width) for col_num, col_width in enumerate(col_widths)]
131 file_handle.write(" | {} |\n".format(" | ".join(padded_row)))
133 def flatten(self, file, mode='strict', comments=False):
134 """Write a flat (no indentation) feature file representing the scenario forest
136 :param file: Path to flat feature file to be written
137 :type file: str
138 :param mode: Flattening mode. Either 'strict' or 'relaxed'
139 :type mode: str
140 """
141 if mode == 'strict':
142 self.flatten_strict(file, comments=comments)
143 elif mode == 'relaxed':
144 self.flatten_relaxed(file, comments=comments)
146 def flatten_strict(self, file_path, comments=False):
147 """Write a flat (no indentation) feature file representing the forest using the 'strict' flattening mode
149 The 'strict' flattening mode writes one scenario per vertex in the tree, resulting in
150 a feature file with one set of 'When' steps followed by one set of 'Then' steps (generally recommended)
152 :param file_path: Path to flat feature file
153 :type file_path: str
154 """
155 with open(file_path, 'w') as flat_file:
156 for scenario in [sc for sc in self.scenarios() if not sc.is_breadcrumb()]:
157 flat_file.write(scenario.format() + "\n")
159 ancestor_scenarios = scenario.ancestors()
160 steps=[]
161 # collect prerequisites from all scenarios along the path
162 steps += [st
163 for sc in ancestor_scenarios
164 for st in sc.prerequisites()]
165 # collect actions from all scenarios along the path
166 steps += [st
167 for sc in ancestor_scenarios
168 for st in sc.actions()]
169 # add all steps from the destination scenario only
170 steps += scenario.steps
171 ScenarioForest.write_scenario_steps(flat_file, steps, comments=comments)
172 flat_file.write("\n")
174 def flatten_relaxed(self, file_path, comments=False):
175 """Write a flat (no indentation) feature file representing the tree using the 'relaxed' flattening mode
177 The 'relaxed' flattening mode writes one scenario per leaf vertex in the tree, resulting in
178 a feature file with multiple alternating sets of "When" and "Then" steps per (generally considered an anti-pattern)
180 :param file_path: Path to flat feature file
181 :type file_path: str
182 """
183 with open(file_path, 'w') as flat_file:
184 tested_scenarios = []
185 for scenario in self.leaf_scenarios():
186 flat_file.write(scenario.format() + "\n")
188 path_scenarios = scenario.ancestors() + [scenario]
189 steps=[]
190 for path_scenario in path_scenarios:
191 steps += path_scenario.prerequisites()
192 steps += path_scenario.actions()
193 if path_scenario not in tested_scenarios:
194 steps += path_scenario.assertions()
195 tested_scenarios.append(path_scenario)
197 ScenarioForest.write_scenario_steps(flat_file, steps, comments=comments)
198 flat_file.write("\n")
200 def find(self, *scenario_names):
202 scenario = next(sc for sc in self.root_scenarios() if sc.name == scenario_names[0])
203 for scenario_name in scenario_names[1:]:
204 scenario = next(vt['scenario'] for vt in scenario.vertex.successors() if vt['scenario'].name == scenario_name)
206 return scenario
208 def scenarios(self):
209 """Return the scenarios of the scenario forest
211 :return: A list of manyworlds.Scenario
212 :rtype: list
213 """
214 return [vx['scenario'] for vx in self.graph.vs]
216 def root_scenarios(self):
217 """Return the root scenarios of the scenario forest (the vertices with no incoming edges)
219 :return: A list of manyworlds.Scenario
220 :rtype: list
221 """
222 return [vx['scenario'] for vx in self.graph.vs if vx.indegree() == 0]
224 def leaf_scenarios(self):
225 """Return the leaf scenarios of the scenario forest (the vertices with no outgoing edges)
227 :return: A list of manyworlds.Scenario
228 :rtype: list
229 """
230 return [vx['scenario'] for vx in self.graph.vs if vx.outdegree() == 0]