Source code for sphinx_gherkindoc.utils

"""Generic utils used throughout the module."""
import pathlib
import string
from typing import Callable, List, Optional

import sphinx.util

from sphinx_gherkindoc.parsers import ScenarioClass, FeatureClass, ExamplesTableClass

# Increments of how much we indent Sphinx rST content when indenting.
INDENT_DEPTH = 4


MAIN_STEP_KEYWORDS = ["Given", "When", "Then"]

# The csv-table parser for restructuredtext does not allow for escaping so use
# a unicode character that looks like a quote but will not be in any Gherkin
QUOTE = "\u201C"


# DRY_RUN and VERBOSE are global states for all the code.
# By making these into global variables, the code "admits that" they are global;
# rather than cluttering up method parameters passing these values around,
# and having to track if any particular method/function needs or no-longer needs them.
DRY_RUN = False
VERBOSE = False


[docs]def verbose(message: str) -> None: """Print message only if VERBOSE, with a DRY_RUN prefix as appropriate.""" if not VERBOSE: return if DRY_RUN: message = "dry-run: " + message print(message)
[docs]def set_dry_run(value: bool) -> None: """Set the value for DRY_RUN outside this module.""" global DRY_RUN DRY_RUN = value
[docs]def set_verbose(value: bool) -> None: """Set the value for VERBOSE outside this module.""" global VERBOSE VERBOSE = value
# Build up dictionary of characters that need escaping _escape_mappings = {ord(x): f"\\{x}" for x in ("*", '"', "#", ":", "<", ">")} _advanced_escape_mappings = _escape_mappings.copy() _advanced_escape_mappings[ord("\\")] = "\\\\\\"
[docs]def rst_escape(unescaped: str, slash_escape: bool = False) -> str: """ Escape reST-ful characters to prevent parsing errors. Args: unescaped: A string that potentially contains characters needing escaping slash_escape: if True, escape slashes found in ``unescaped`` Returns: A string which has reST-ful characters appropriately escaped """ return unescaped.translate( _advanced_escape_mappings if slash_escape else _escape_mappings )
[docs]def make_flat_name( path_list: List[str], filename_root: str = None, is_dir: bool = False, ext: Optional[str] = ".rst", ) -> str: """ Build a flat file name from the provided information. Args: path_list: Directory hierarchy to flatten filename_root: If provided, the root of the filename to flatten (no extension) is_dir: If True, mark the new filename as a table of contents ext: Optional extension for the new file name Returns: A filename containing the full path, separated by periods """ if filename_root is not None: path_list = path_list + [filename_root] result = ".".join(path_list) if ext is None: return result return result + ("-toc" if is_dir else "-file") + ext
[docs]class SphinxWriter(object): """Easy Sphinx-format file creator.""" sections = ["", "=", "-", "~", ".", "*", "+", "_", "<", ">", "/"] def __init__(self) -> None: self._output: List[str] = []
[docs] def add_output(self, line: str, line_breaks: int = 1, indent_by: int = 0) -> None: """Add output to be written to file. Args: line: The line to be written line_breaks: The number of line breaks to include indenty_by: The number of spaces to indent the line. """ line_breaks_str = "\n" * line_breaks self._output.append(f"{' ' * indent_by}{line}{line_breaks_str}")
[docs] def blank_line(self) -> None: """Write a single blank line.""" self.add_output("")
[docs] def create_section(self, level: int, section: str) -> None: """ Create a reST-formatted section header based on the provided level. Args: level: The level depth of the section header (1-10 supported) section: The section title """ self.add_output(section) self.add_output(self.sections[level] * len(section.rstrip()), line_breaks=2)
[docs] def write_to_file(self, filename: pathlib.Path) -> None: """Write the provided output to the given filename. Args: filename: The full path to write the output """ verbose(f"Writing {filename}") with sphinx.util.osutil.FileAvoidWrite(filename) as f: # All version of Sphinx will accept a string-type, # but >=2.0 accepts _only_ strings (not bytes) f.write("".join(self._output))
[docs]def display_name( path: pathlib.Path, package_name: Optional[str] = "", dir_display_name_converter: Optional[Callable] = None, ) -> str: """ Create a human-readable name for a given project. Determine the display name for a project given a path and (optional) package name. If a display_name.txt file is found, the first line is returned. Otherwise, return a title-cased string from either the base directory or package_name (if provided). Args: path: Path for searching package_name: Sphinx-style, dot-delimited package name (optional) dir_display_name_converter: A function for converting a dir to a display name. Returns: A display name for the provided path """ name_path = path / "display_name.txt" if name_path.exists(): with open(name_path, "r") as name_fo: return name_fo.readline().rstrip("\r\n") raw_name = package_name.split(".")[-1] if package_name else path.name if dir_display_name_converter: return dir_display_name_converter(raw_name) return string.capwords(raw_name.replace("_", " "))
def _examples_table_if_included( examples_table: ExamplesTableClass, scenario_has_include_tag: bool, include_tags_set: set, exclude_tags_set: set, ) -> Optional[ExamplesTableClass]: """Return an examples table if it should be included.""" examples_table_tags_set = set(examples_table.tags) examples_table_has_include_tag = bool(examples_table_tags_set & include_tags_set) if any( # Exclude the examples table if: [ # Examples table has an exclude tag (examples_table_tags_set & exclude_tags_set), # Neither the scenario, # nor any scenario outline examples tables # have an include tag (not scenario_has_include_tag and not examples_table_has_include_tag), ] ): return None return examples_table def _scenario_if_included( scenario: ScenarioClass, feature_has_include_tag: bool, include_tags_set: set, exclude_tags_set: set, ) -> Optional[ScenarioClass]: """Return a (possibly modified) scenario if it should be included.""" scenario_tags_set = set(scenario.tags) scenario_examples = getattr(scenario, "examples", []) # If there are no include tags, # then treat it the same as if the scenario has an include tag. # If include tags exist, that will affect whether or not # any examples tables need to have an include tag. # If the feature has an include tag, then the scenario inherits it. scenario_has_include_tag = feature_has_include_tag or ( bool(scenario_tags_set & include_tags_set) if include_tags_set else True ) included_examples = list( filter( None, ( _examples_table_if_included( examples_table, scenario_has_include_tag, include_tags_set, exclude_tags_set, ) for examples_table in scenario_examples ), ) ) # This is the default, even if the scenario has no examples tables. all_examples_tables_have_exclude_tag = False if scenario_examples: all_examples_tables_have_exclude_tag = all( (set(examples_table.tags) & exclude_tags_set) for examples_table in scenario_examples ) at_least_one_examples_table_included = scenario_examples and included_examples if any( # Exclude if: [ # Scenario has an exclude tag (scenario_tags_set & exclude_tags_set), # Neither the scenario, nor any scenario examples tables are included (not scenario_has_include_tag and not at_least_one_examples_table_included), # All examples tables in the scenario have at least one exclude tag all_examples_tables_have_exclude_tag, ] ): return None # Only include examples tables that are included, # and only overwrite the examples attribute on the `scenario` object if necessary. if scenario_examples and len(scenario_examples) != len(included_examples): scenario.examples = included_examples return scenario
[docs]def get_all_included_scenarios( feature: FeatureClass, include_tags: List[str] = None, exclude_tags: List[str] = None, ) -> List[ScenarioClass]: """ Get all scenarios to include in the docs based on the include/exclude tags. This is designed to match what tests would be run if you ran a test suite with something like this (pytest-bdd format):: -m include_this_tag -m "not exclude_this_tag" Args: feature: The feature whose scenarios are to be filtered. include_tags: Tags for which scenarios should be included. exclude_tags: Tags for which scenarios should be excluded. Returns: All scenarios to include. """ # If there's no exclude/include logic return all scenarios if not include_tags and not exclude_tags: return feature.scenarios include_tags_set = set(include_tags) if include_tags else set() exclude_tags_set = set(exclude_tags) if exclude_tags else set() feature_tags_set = set(feature.tags) if feature_tags_set & exclude_tags_set: return [] # If there are no include tags, # then treat it the same as if the feature has an include tag. # If include tags exist, that will affect whether or not # the scenario needs to have an include tag. feature_has_include_tag = ( bool(feature_tags_set & include_tags_set) if include_tags else True ) return list( filter( None, ( _scenario_if_included( scenario, feature_has_include_tag, include_tags_set, exclude_tags_set, ) for scenario in feature.scenarios ), ) )