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