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

1"""Defines the ScenarioForest Class""" 

2 

3import re 

4import igraph as ig # type: ignore 

5from typing import Optional, TextIO, Literal, List, Tuple 

6 

7from .scenario import Scenario 

8from .step import Step, Prerequisite, Action, Assertion 

9from .data_table import DataTable, DataTableRow 

10from .exceptions import InvalidFeatureFileError 

11 

12 

13class ScenarioForest: 

14 """A collection of one or more directed trees 

15 the vertices of which represent BDD scenarios.""" 

16 

17 TAB_SIZE: int = 4 

18 """ 

19 int 

20 

21 The number of spaces per indentation level 

22 """ 

23 

24 graph: ig.Graph 

25 

26 def __init__(self) -> None: 

27 """Constructor method""" 

28 

29 self.graph = ig.Graph(directed=True) 

30 

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. 

34 

35 Parameters 

36 ---------- 

37 raw_line : str 

38 The raw feature file line including indentation and newline 

39 

40 Returns 

41 ------- 

42 tuple[int, str] 

43 The indentation part and the line part (without newline) as a tuple 

44 """ 

45 

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) 

50 

51 def parse_step_line(self, line: str) -> Optional[Step]: 

52 """Parses a feature file step line into the appropriate 

53 Step subclass instance. 

54 

55 If the line begins with "And" then the step type is determined 

56 by the type of the last step. 

57 

58 Parameters 

59 ---------- 

60 line : str 

61 The step line (without indentation and newline) 

62 

63 Returns 

64 ------- 

65 Prerequisite or Action or Assertion 

66 An instance of a Step subclass 

67 """ 

68 

69 match: Optional[re.Match] = Step.STEP_PATTERN.match(line) 

70 if match is None: 

71 return None 

72 

73 conjunction, name, comment = match.group("conjunction", "name", "comment") 

74 

75 if conjunction in ["And", "But"]: 

76 previous_step = self.scenarios()[-1].steps[-1] 

77 conjunction = previous_step.conjunction 

78 

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) 

85 

86 @classmethod 

87 def from_file(cls, file_path) -> "ScenarioForest": 

88 """Parses an indented feature file into a ScenarioForest instance. 

89 

90 Parameters 

91 ---------- 

92 file_path : str 

93 The path to the feature file 

94 

95 Returns 

96 ------- 

97 ScenarioForest 

98 A new ScenarioForest instance 

99 """ 

100 

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 

106 

107 indentation: int 

108 line: str 

109 indentation, line = cls.split_line(raw_line) 

110 

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 ) 

120 

121 # (2) Parse line: 

122 

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 

128 

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 

134 

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 

140 

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 ) 

147 

148 return forest 

149 

150 def append_scenario(self, scenario_name: str, at_level: int) -> Scenario: 

151 """Append a scenario to the scenario forest. 

152 

153 Parameters 

154 ---------- 

155 scenario : Scenario 

156 The scenario to append 

157 

158 at_level : int 

159 The indentation level of the scenario in the input file. 

160 Used for indentation validation. 

161 """ 

162 

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 ) 

183 

184 else: # Root scenario: 

185 return Scenario(scenario_name, self.graph) 

186 

187 def append_step(self, step: Step, at_level: int) -> None: 

188 """Appends a step to the scenario forest. 

189 

190 Parameters 

191 ---------- 

192 step : Prerequisite or Action or Assertion 

193 The Step subclass instance to append 

194 

195 at_level : int 

196 The level at which to add the step. 

197 Used for indentation validation. 

198 """ 

199 

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 ) 

209 

210 def append_data_row(self, data_row: DataTableRow, at_level: int) -> None: 

211 """Appends a data row to the scenario forest. 

212 

213 Adds a data table to the last step if necessary 

214 Otherwise adds row to data table. 

215 

216 Parameters 

217 ---------- 

218 data_row : DataTableRow 

219 The data row to append 

220 

221 at_level : int 

222 The level at which to add the data row. 

223 Used for indentation validation. 

224 """ 

225 

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) 

233 

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. 

239 

240 Parameters 

241 ---------- 

242 file_handle : TextIO 

243 The file to which to append the scenario name 

244 

245 scenarios : List[Scenario] 

246 Organizational and validated scenarios along the path 

247 """ 

248 

249 # (1) Group consecutive regular or organizational scenarios: 

250 groups: List[List[Scenario]] = [] 

251 

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 ) 

259 

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 

265 

266 # (2) Format each group to strings: 

267 group_strings: List[str] = [] 

268 

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])) 

276 

277 # (3) Assemble and write name: 

278 file_handle.write( 

279 "Scenario: {scenario_name}\n".format(scenario_name=" ".join(group_strings)) 

280 ) 

281 

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. 

287 

288 Parameters 

289 ---------- 

290 file_handle : io.TextIOWrapper 

291 The file to which to append the steps 

292 

293 steps : List[Step] 

294 Steps to append to file_handle 

295 

296 comments: bool 

297 Whether or not to write comments 

298 """ 

299 

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 

311 

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. 

317 

318 Parameters 

319 ---------- 

320 file_handle : io.TextIOWrapper 

321 The file to which to append the data table 

322 

323 data_table : DataTable 

324 A data table 

325 

326 comments : bool 

327 Whether or not to write comments 

328 """ 

329 

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 ] 

335 

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 ] 

342 

343 # add column enclosing pipes: 

344 table_row_string: str = " | {columns} |".format( 

345 columns=" | ".join(padded_row) 

346 ) 

347 

348 # add comments: 

349 if comments and row.comment: 

350 table_row_string += " # {comment}".format(comment=row.comment) 

351 

352 # write line: 

353 file_handle.write(table_row_string + "\n") 

354 

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. 

362 

363 Parameters 

364 ---------- 

365 file_path : str 

366 Path to flat feature file to be written 

367 

368 mode : {"strict", "relaxed"}, default="strict" 

369 Flattening mode. Either "strict" or "relaxed" 

370 

371 comments : bool, default = False 

372 Whether or not to write comments 

373 """ 

374 

375 if mode == "strict": 

376 self.flatten_strict(file_path, comments=comments) 

377 elif mode == "relaxed": 

378 self.flatten_relaxed(file_path, comments=comments) 

379 

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. 

383 

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). 

387 

388 Parameters 

389 ---------- 

390 file_path : str 

391 Path to flat feature file 

392 

393 comments : bool, default = False 

394 Whether or not to write comments 

395 """ 

396 

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) 

408 

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 

417 

418 # Write steps: 

419 ScenarioForest.write_scenario_steps(flat_file, steps, comments=comments) 

420 flat_file.write("\n") # Empty line to separate scenarios 

421 

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. 

425 

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). 

429 

430 Parameters 

431 ---------- 

432 file_path : str 

433 Path to flat feature file 

434 

435 comments : bool, default = False 

436 Whether or not to write comments 

437 """ 

438 

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) 

453 

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 

458 

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. 

462 

463 Used in tests only 

464 

465 Parameters 

466 ---------- 

467 scenario_names : List[str] 

468 List of scenario names 

469 

470 Returns 

471 ------- 

472 Scenario or None 

473 The found scenario, or None if none found 

474 """ 

475 

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 

482 

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 

495 

496 return scenario 

497 

498 def scenarios(self) -> List[Scenario]: 

499 """Returns all scenarios 

500 

501 Returns 

502 ------- 

503 List[Scenario] 

504 All scenarios in index order 

505 """ 

506 

507 return [vx["scenario"] for vx in self.graph.vs] 

508 

509 def root_scenarios(self) -> List[Scenario]: 

510 """Returns the root scenarios (scenarios with vertices without incoming edges). 

511 

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] 

518 

519 def leaf_scenarios(self) -> List[Scenario]: 

520 """Returns the leaf scenarios (scenarios with vertices without outgoing edges). 

521 

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]