Source code for manyworlds.feature

"""Defines the Feature Class"""

import re
import igraph as ig  # type: ignore
from typing import Optional, TextIO, Literal, List, Tuple

from .scenario import Scenario
from .step import Step, Prerequisite, Action, Assertion
from .data_table import DataTable, DataTableRow
from .exceptions import InvalidFeatureFileError


[docs]class Feature: """A collection of one or more directed trees the vertices of which represent BDD scenarios.""" TAB_SIZE: int = 4 """ int The number of spaces per indentation level """ FEATURE_PATTERN: re.Pattern = re.compile( r""" ^ # start of line Feature: # "Feature:" keyword [ ] # space (?P<feature_name>.*) # feature name $ # end of line """, re.VERBOSE, ) """ re.Pattern Pattern describing a BDD feature line ("Feature: …") """ COMMENT_PATTERN: re.Pattern = re.compile( r""" ^ # start of line \# # "#" character [ ] # space (?P<comment>.*) # comment $ # end of line """, re.VERBOSE, ) """ re.Pattern Pattern describing a comment line ("# …") """ graph: ig.Graph """The graph representing the scenario tree(s)""" name: Optional[str] """The name of the feature""" description: List[str] """The description lines for the feature""" def __init__(self) -> None: """Constructor method""" self.graph = ig.Graph(directed=True) self.name = None self.description = []
[docs] @classmethod def split_line(cls, raw_line: str) -> Tuple[int, str]: """Splits a raw feature file line into the indentation part and the line part. Parameters ---------- raw_line : str The raw feature file line including indentation and newline Returns ------- tuple[int, str] The indentation part and the line part (without newline) as a tuple """ line: str = raw_line.rstrip() line_wo_indentation: str = line.lstrip() indentation: int = len(line) - len(line_wo_indentation) return (indentation, line_wo_indentation)
[docs] def parse_step_line(self, line: str) -> Optional[Step]: """Parses a feature file step line into the appropriate Step subclass instance. If the line begins with "And" then the step type is determined by the type of the last step. Parameters ---------- line : str The step line (without indentation and newline) Returns ------- Prerequisite or Action or Assertion An instance of a Step subclass """ match: Optional[re.Match] = Step.STEP_PATTERN.match(line) if match is None: return None conjunction, name, comment = match.group("conjunction", "name", "comment") if conjunction in ["And", "But"]: previous_step = self.scenarios()[-1].steps[-1] conjunction = previous_step.conjunction if conjunction == "Given": return Prerequisite(name, comment=comment) elif conjunction == "When": return Action(name, comment=comment) else: # conjunction == "Then" return Assertion(name, comment=comment)
[docs] @classmethod def from_file(cls, file_path) -> "Feature": """Parses an indented feature file into a Feature instance. Parameters ---------- file_path : str The path to the feature file Returns ------- Feature A new Feature instance """ feature = Feature() with open(file_path) as indented_file: for line_no, raw_line in enumerate(indented_file.readlines()): if raw_line.strip() == "": continue # Skip empty lines indentation: int line: str indentation, line = cls.split_line(raw_line) # (1) Determine and validate indentation level: if indentation % cls.TAB_SIZE == 0: level: int = int(indentation / cls.TAB_SIZE) + 1 else: raise InvalidFeatureFileError( "Invalid indentation at line {line_no}: {line}".format( line_no=line_no + 1, line=line ) ) # (2) Parse line: # Feature line? feature_match: Optional[re.Match] = cls.FEATURE_PATTERN.match(line) if feature_match is not None: if len(feature.scenarios()) == 0: feature.name = feature_match["feature_name"] continue else: raise InvalidFeatureFileError( "Feature line is allowed only at beginning of file " "but was encountered at line {line_no}: {line}".format( line_no=line_no + 1, line=line ) ) # Scenario line? scenario_match: Optional[re.Match] = Scenario.SCENARIO_PATTERN.match( line ) if scenario_match is not None: feature.append_scenario( scenario_match.group("scenario_name"), comment=scenario_match.group("comment"), at_level=level, line_no=line_no, ) continue # Step line? new_step: Optional[Step] = feature.parse_step_line(line) if new_step: feature.append_step(new_step, at_level=level, line_no=line_no) continue # Data table line? new_data_row: Optional[DataTableRow] = DataTable.parse_line(line) if new_data_row: feature.append_data_row( new_data_row, at_level=level, line_no=line_no ) continue # Comment line? comment_match: Optional[re.Match] = cls.COMMENT_PATTERN.match(line) if comment_match is not None: continue # skip comment lines # Feature description line? if feature.name is not None and len(feature.scenarios()) == 0: feature.description.append(line) continue # Not a valid line! raise InvalidFeatureFileError( "Unable to parse line {line_no}: {line}".format( line_no=line_no + 1, line=line ) ) return feature
[docs] def append_scenario( self, scenario_name: str, comment: Optional[str], at_level: int, line_no: int ) -> Scenario: """Append a scenario to the feature. Parameters ---------- scenario : Scenario The scenario to append comment : str, optional A comment at_level : int The indentation level of the scenario in the input file. Used for indentation validation. line_no : int The line number of the scenario in the input file. Used in InvalidFeatureFile error message. """ if at_level > 1: # Non-root scenario: # Find the parent to connect scenario to: parent_level: int = at_level - 1 parent_level_scenarios: List[Scenario] = [ sc for sc in self.scenarios() if sc.level() == parent_level and not sc.is_closed() ] if len(parent_level_scenarios) > 0: return Scenario( scenario_name, self.graph, parent_scenario=parent_level_scenarios[-1], comment=comment, ) else: raise InvalidFeatureFileError( "Excessive indentation at line {line_no}: Scenario: {name}".format( line_no=line_no + 1, name=scenario_name ) ) else: # Root scenario: return Scenario(scenario_name, self.graph, comment=comment)
[docs] def append_step(self, step: Step, at_level: int, line_no: int) -> None: """Appends a step to the feature. Parameters ---------- step : Prerequisite or Action or Assertion The Step subclass instance to append at_level : int The level at which to add the step. Used for indentation validation. line_no : int The line number of the step in the input file. Used in InvalidFeatureFile error message. """ # Ensure the indentation level of the step matches # the last scenario indentation level last_scenario: Scenario = self.scenarios()[-1] if at_level == last_scenario.level(): last_scenario.steps.append(step) else: raise InvalidFeatureFileError( "Invalid indentation at line {line_no}: {name}".format( line_no=line_no + 1, name=step.name ) )
[docs] def append_data_row( self, data_row: DataTableRow, at_level: int, line_no: int ) -> None: """Appends a data row to the feature. Adds a data table to the last step if necessary Otherwise adds row to data table. Parameters ---------- data_row : DataTableRow The data row to append at_level : int The level at which to add the data row. Used for indentation validation. line_no : int The line number of the data row in the input file. Used in InvalidFeatureFile error message. """ last_step: Step = self.scenarios()[-1].steps[-1] if last_step.data: # Row is an additional row for an existing table last_step.data.rows.append(data_row) else: # Row is the header row of a new table last_step.data = DataTable(data_row)
[docs] @classmethod def write_feature_declaration(cls, file_handle: TextIO, feature: "Feature") -> None: """Writes feature name and (optional) description to the end of a flat feature file. Parameters ---------- file_handle : TextIO The file to which to append the feature declaration """ if feature.name is not None: file_handle.write( "Feature: {feature_name}\n\n".format(feature_name=feature.name) ) if len(feature.description) > 0: for line in feature.description: file_handle.write(" {line}\n".format(line=line)) file_handle.write("\n")
[docs] @classmethod def write_scenario_name( cls, file_handle: TextIO, scenarios: List[Scenario], write_comment: bool = False ) -> None: """Writes formatted scenario name to the end of a "relaxed" flat feature file. Parameters ---------- file_handle : TextIO The file to which to append the scenario name scenarios : List[Scenario] Organizational and validated scenarios along the path write_comment : bool, default = False Whether or not to write comment if present """ # (1) Group consecutive regular or organizational scenarios: groups: List[List[Scenario]] = [] # Function for determining whether a scenario can be added to a current group: def group_available_for_scenario( gr: List[List[Scenario]], sc: Scenario ) -> bool: return ( len(gr) > 0 and len(gr[-1]) > 0 and gr[-1][-1].is_organizational() == sc.is_organizational() ) for sc in scenarios: if group_available_for_scenario(groups, sc): groups[-1].append(sc) # add to current group else: groups.append([sc]) # start new group # (2) Format each group to strings: group_strings: List[str] = [] for group in groups: if group[-1].is_organizational(): group_strings.append( "[{}]".format(" / ".join([sc.name for sc in group])) ) else: group_strings.append(" > ".join([sc.name for sc in group])) # (3) Assemble name: scenario_string: str = "Scenario: {}".format(" ".join(group_strings)) # (4) Optional comment: destination_scenario: Scenario = scenarios[-1] if write_comment is True and destination_scenario.comment is not None: scenario_string += " # {comment}".format( comment=destination_scenario.comment ) # (4) Write name: file_handle.write(scenario_string + "\n")
[docs] @classmethod def write_scenario_steps( cls, file_handle: TextIO, steps: List[Step], write_comments: bool = False ) -> None: """Writes formatted scenario steps to the end of the flat feature file. Parameters ---------- file_handle : io.TextIOWrapper The file to which to append the steps steps : List[Step] Steps to append to file_handle write_comments: bool, default = False Whether or not to write comments if present """ last_step: Optional[Step] = None for step in steps: first_of_type: bool = ( last_step is None or last_step.conjunction != step.conjunction ) step_string: str = step.format(first_of_type=first_of_type) if write_comments is True and step.comment is not None: step_string += " # {comment}".format(comment=step.comment) file_handle.write(step_string + "\n") if step.data: Feature.write_data_table( file_handle, step.data, write_comment=write_comments ) last_step = step
[docs] @classmethod def write_data_table( cls, file_handle: TextIO, data_table: DataTable, write_comment: bool = False ) -> None: """Writes formatted data table to the end of the flat feature file. Parameters ---------- file_handle : io.TextIOWrapper The file to which to append the data table data_table : DataTable A data table write_comment : bool Whether or not to write comment if present """ # Determine column widths to accommodate all values: col_widths: List[int] = [ max([len(cell) for cell in col]) for col in list(zip(*data_table.to_list_of_list())) ] for row in data_table.to_list(): # pad values with spaces to column width: padded_row: List[str] = [ row.values[col_num].ljust(col_width) for col_num, col_width in enumerate(col_widths) ] # add column enclosing pipes: table_row_string: str = " | {columns} |".format( columns=" | ".join(padded_row) ) # add comments: if write_comment is True and row.comment is not None: table_row_string += " # {comment}".format(comment=row.comment) # write line: file_handle.write(table_row_string + "\n")
[docs] def flatten( self, file_path: str, mode: Literal["strict", "relaxed"] = "strict", write_comments: bool = False, ) -> None: """Writes a flat (no indentation) feature file representing the feature. Parameters ---------- file_path : str Path to flat feature file to be written mode : {"strict", "relaxed"}, default="strict" Flattening mode. Either "strict" or "relaxed" comments : bool, default = False Whether or not to write comments """ with open(file_path, "w") as flat_file: # Feature declaration: if self.name is not None: Feature.write_feature_declaration(flat_file, self) # Scenarios: if mode == "strict": self.flatten_strict(flat_file, write_comments=write_comments) elif mode == "relaxed": self.flatten_relaxed(flat_file, write_comments=write_comments)
[docs] def flatten_strict(self, flat_file: TextIO, write_comments: bool = False) -> None: """Write. a flat (no indentation) feature file representing the feature using the "strict" flattening mode. The "strict" flattening mode writes one scenario per vertex in the tree, resulting in a feature file with one set of "When" steps followed by one set of "Then" steps (generally recommended). Parameters ---------- flat_file : io.TextIOWrapper The flat feature file write_comments : bool, default = False Whether or not to write comments """ for scenario in [sc for sc in self.scenarios() if not sc.is_organizational()]: # Scenario name: scenarios_for_naming: List[Scenario] = [ sc for sc in scenario.path_scenarios() if sc.is_organizational() or sc == scenario ] Feature.write_scenario_name( flat_file, scenarios_for_naming, write_comment=write_comments ) ancestor_scenarios = scenario.ancestors() steps: List[Step] = [] # collect prerequisites from all scenarios along the path steps += [st for sc in ancestor_scenarios for st in sc.prerequisites()] # collect actions from all scenarios along the path steps += [st for sc in ancestor_scenarios for st in sc.actions()] # add all steps from the destination scenario only steps += scenario.steps # Write steps: Feature.write_scenario_steps( flat_file, steps, write_comments=write_comments ) flat_file.write("\n") # Empty line to separate scenarios
[docs] def flatten_relaxed(self, flat_file: TextIO, write_comments: bool = False) -> None: """Writes a flat (no indentation) feature file representing the feature using the "relaxed" flattening mode. The "relaxed" flattening mode writes one scenario per leaf vertex in the tree, resulting in a feature file with multiple consecutive sets of "When" and "Then" steps per scenario (generally considered an anti-pattern). Parameters ---------- flat_file : io.TextIOWrapper The flat feature file write_comments : bool, default = False Whether or not to write comments if present """ for scenario in self.leaf_scenarios(): steps: List[Step] = [] # organizational and validated scenarios used for naming: scenarios_for_naming: List[Scenario] = [] for path_scenario in scenario.path_scenarios(): steps += path_scenario.prerequisites() steps += path_scenario.actions() if path_scenario.is_organizational(): scenarios_for_naming.append(path_scenario) elif not path_scenario.validated: steps += path_scenario.assertions() path_scenario.validated = True scenarios_for_naming.append(path_scenario) Feature.write_scenario_name( flat_file, scenarios_for_naming, write_comment=write_comments ) # Write steps: Feature.write_scenario_steps( flat_file, steps, write_comments=write_comments ) flat_file.write("\n") # Empty line to separate scenarios
[docs] def find(self, *scenario_names: List[str]) -> Optional[Scenario]: """Finds and returns a scenario by the names of all scenarios along the path from a root scenario to the destination scenario. Used in tests only Parameters ---------- scenario_names : List[str] List of scenario names Returns ------- Scenario or None The found scenario, or None if none found """ # Root scenario: scenario: Optional[Scenario] = next( (sc for sc in self.root_scenarios() if sc.name == scenario_names[0]), None ) if scenario is None: return None # Root descendant scenarios: for scenario_name in scenario_names[1:]: scenario = next( ( vt["scenario"] for vt in scenario.vertex.successors() if vt["scenario"].name == scenario_name ), None, ) if scenario is None: return None return scenario
[docs] def scenarios(self) -> List[Scenario]: """Returns all scenarios Returns ------- List[Scenario] All scenarios in index order """ return [vx["scenario"] for vx in self.graph.vs]
[docs] def root_scenarios(self) -> List[Scenario]: """Returns the root scenarios (scenarios with vertices without incoming edges). Returns ------- List[Scenario] All root scenarios in index order """ return [vx["scenario"] for vx in self.graph.vs if vx.indegree() == 0]
[docs] def leaf_scenarios(self) -> List[Scenario]: """Returns the leaf scenarios (scenarios with vertices without outgoing edges). Returns ------- List[Scenario] All leaf scenarios in index order """ return [vx["scenario"] for vx in self.graph.vs if vx.outdegree() == 0]