#!/usr/bin/env python3
"""Module for running the tool from the CLI."""
import argparse
import importlib
import pkg_resources
import pathlib
import shutil
from typing import Callable, Set
import sphinx
from .files import is_feature_file, is_rst_file, scan_tree
from .glossary import make_steps_glossary
from .parsers import parsers
from .utils import make_flat_name, set_dry_run, set_verbose, verbose
from .writer import feature_to_rst, toctree
# This is a pretty arbitrary number controlling how much detail
# will show up in the various TOCs.
DEFAULT_TOC_DEPTH = 4
def _get_function_from_command_line_arg(module_func_str: str) -> Callable:
"""Get a function from a module:func string that comes from a command arg.
The ``module_func_str`` must be in the form ``{module_name}:{function_name}``.
:param module_func_str: The string containing the module and function names.
:return: The imported python function based on the given string.
"""
module_name, function_name = module_func_str.split(":", maxsplit=1)
module = importlib.import_module(module_name)
return getattr(module, function_name)
[docs]def process_args(
args: argparse.Namespace,
gherkin_path: pathlib.Path,
output_path: pathlib.Path,
doc_project: str,
) -> None:
"""Process the supplied CLI args."""
work_to_do = scan_tree(gherkin_path, args.private, args.exclude_patterns)
maxtocdepth = args.maxtocdepth
toc_name = args.toc_name
step_glossary_name = args.step_glossary_name
group_step_glossary = args.group_step_glossary
doc_project = args.doc_project
root_path = gherkin_path.resolve().parent
top_level_toc_filename = output_path / f"{toc_name}.rst"
non_empty_dirs: Set[pathlib.Path] = set()
get_url_from_tag = None
get_url_from_step = None
dir_display_name_converter = None
# Set parsers once and pass along where they are needed.
for entry_point in pkg_resources.iter_entry_points("parsers"):
# `url` is a supported legacy key value for the `tag_url`.
if entry_point.name in ("url", "tag_url"):
get_url_from_tag = entry_point.load()
if entry_point.name == "step_url":
get_url_from_step = entry_point.load()
if entry_point.name == "dir_display_name":
dir_display_name_converter = entry_point.load()
# Override parsers if there is a command line arg
if args.url_from_tag:
get_url_from_tag = _get_function_from_command_line_arg(args.url_from_tag)
if args.url_from_step:
get_url_from_step = _get_function_from_command_line_arg(args.url_from_step)
if args.display_name_from_dir:
dir_display_name_converter = _get_function_from_command_line_arg(
args.display_name_from_dir
)
while work_to_do:
current = work_to_do.pop()
new_subdirs = []
for subdir in current.sub_dirs:
subdir_path = pathlib.Path() / current.dir_path / subdir
if subdir_path in non_empty_dirs:
new_subdirs.append(subdir)
if not (current.files or new_subdirs):
continue
non_empty_dirs.add(current.dir_path)
if args.dry_run:
continue
# Make a copy of the list, as some items may be removed.
files_for_toc = list(current.files)
for a_file in current.files:
a_file_list = current.path_list + [a_file]
source_name = pathlib.Path().joinpath(*a_file_list)
source_path = root_path / source_name
if is_feature_file(a_file):
dest_name = output_path / make_flat_name(a_file_list, is_dir=False)
feature_rst_file = feature_to_rst(
source_path,
root_path,
feature_parser=args.parser,
get_url_from_tag=get_url_from_tag,
get_url_from_step=get_url_from_step,
integrate_background=args.integrate_background,
background_step_format=args.background_step_format,
raw_descriptions=args.raw_descriptions,
include_tags=args.include_tags,
exclude_tags=args.exclude_tags,
)
if not feature_rst_file:
files_for_toc.remove(a_file)
continue
verbose(f'converting "{source_name}" to "{dest_name}"')
feature_rst_file.write_to_file(dest_name)
elif not is_rst_file(a_file):
dest_name = output_path / make_flat_name(
a_file_list, is_dir=False, ext=None
)
verbose(f'copying "{source_name}" to "{dest_name}"')
shutil.copy(source_path, dest_name)
toc_file = toctree(
current.path_list,
new_subdirs,
files_for_toc,
maxtocdepth,
root_path,
dir_display_name_converter=dir_display_name_converter,
)
# Check to see if we are at the last item to be processed
# (which has already been popped)
# to write the asked for master TOC file name.
if not work_to_do:
toc_filename = top_level_toc_filename
else:
toc_filename = output_path / make_flat_name(current.path_list, is_dir=True)
toc_file.write_to_file(toc_filename)
if step_glossary_name:
glossary_filename = output_path / f"{step_glossary_name}.rst"
glossary = make_steps_glossary(doc_project, group_by=group_step_glossary)
if args.dry_run:
verbose("No glossary generated")
return
if glossary is None:
print("No steps to include in the glossary: no glossary generated")
return
verbose(f"Writing sphinx glossary: {glossary_filename}")
glossary.write_to_file(glossary_filename)
[docs]def main() -> None:
"""Convert a directory-tree of Gherkin Feature files to rST files."""
description = (
"Look recursively in <gherkin_path> for Gherkin files and create one "
"reST file for each. Other rST files found along the way will be included "
"as prologue content above each TOC."
)
parser = argparse.ArgumentParser(
description=description, formatter_class=argparse.ArgumentDefaultsHelpFormatter
)
parser.add_argument("gherkin_path", help="Directory to search for Gherkin files")
parser.add_argument("output_path", help="Directory to place all output")
parser.add_argument(
"exclude_patterns",
nargs="*",
help="file and/or directory patterns that will be excluded",
)
parser.add_argument(
"-d",
"--maxtocdepth",
type=int,
default=DEFAULT_TOC_DEPTH,
help="Maximum depth of submodules to show in the TOC",
)
parser.add_argument(
"-n",
"--dry-run",
action="store_true",
help="Run the script without creating files",
)
parser.add_argument(
"-P", "--private", action="store_true", help='Include "_private" folders'
)
parser.add_argument("-N", "--toc-name", help="File name for TOC", default="gherkin")
parser.add_argument(
"-H", "--doc-project", help="Project name (default: root module name)"
)
parser.add_argument(
"-q", "--quiet", action="store_true", help="Silence any output to screen"
)
parser.add_argument(
"-G",
"--step-glossary-name",
default=None,
help="Include steps glossary under the given name."
" If not specified, no glossary will be created.",
)
parser.add_argument(
"-T",
"--group-step-glossary",
action="store_true",
default=False,
help="Group step glossary by step type",
)
parser.add_argument(
"--integrate-background",
action="store_true",
help=(
"Remove all references to Background, "
"and integrate the steps into each scenario."
),
)
parser.add_argument(
"--background-step-format",
default="{}",
help=(
"A format string to use to format integrated background steps. "
"It should contain a single pair of empty curly braces, "
"which is where the contents of the background step will go. "
"NOTE: This flag is only relevant when the --integrate-background flag "
"is also included."
),
)
parser.add_argument(
"--parser",
default="behave",
choices=list(parsers.keys()),
help="Specify an alternate parser to use.",
)
parser.add_argument(
"-v",
"--verbose",
action="store_true",
help="print files created and actions taken",
)
parser.add_argument(
"--version", action="store_true", help="Show version information and exit"
)
url_help = (
"A library and method name to call to build a URL from a tag. The string"
" should be <library>:<method_name> and it should accept a single string"
" parameter, the tag."
)
parser.add_argument("--url-from-tag", help=url_help)
step_url_help = (
"A library and method name to call to build a URL from a step. The string"
" should be <library>:<method_name> and it should accept a single string"
" parameter, the step name."
)
parser.add_argument("--url-from-step", help=step_url_help)
display_name_from_dir_help = (
"A library and method name to call to convert a directory name into a"
" display name. The string should be <library>:<method_name>"
" and it should accept a single string parameter, the directory name."
" The output of this function will be the same as creating"
" display_name.txt files for each directory, based on the directory name."
" Any display_name.txt files that exist will take precedence over this flag."
)
parser.add_argument("--display-name-from-dir", help=display_name_from_dir_help)
parser.add_argument(
"--raw-descriptions",
action="store_true",
help=(
"Treat text from feature and scenario descriptions as raw rST. "
"This allows descriptions to contain rST links, code blocks, etc."
),
)
include_exclude_tags_caveat = (
"If a feature/scenario has both an exclude and and include tag,"
"it will be excluded."
)
exclude_tags_help = (
"Features and scenarios tagged with these exclude tags "
"will not be included in the build docs. " + include_exclude_tags_caveat
)
parser.add_argument("--exclude-tags", help=exclude_tags_help, nargs="*")
include_tags_help = (
"Only features and scenarios tagged with at least one of these include tags "
"will be included in the build docs." + include_exclude_tags_caveat
)
parser.add_argument("--include-tags", help=include_tags_help, nargs="*")
args = parser.parse_args()
if args.version:
parser.exit(message=f"Sphinx (sphinx-gherkindoc) {sphinx.__display_version__}")
set_dry_run(args.dry_run)
set_verbose(args.verbose)
gherkin_path = pathlib.Path(args.gherkin_path)
if not gherkin_path.is_dir():
parser.error(f"{args.gherkin_path} is not a directory.")
if args.doc_project is None:
args.doc_project = gherkin_path.parts[-1]
output_path = pathlib.Path(args.output_path).resolve()
if not output_path.is_dir():
if not args.dry_run:
verbose(f"creating directory: {args.output_path}")
output_path.mkdir(parents=True)
process_args(args, gherkin_path, output_path, args.doc_project)
[docs]def config() -> None:
"""Emit a customized version of the sample sphinx config file."""
description = (
"Create a default Sphinx configuration for producing nice"
" Gherkin-based documentation"
)
parser = argparse.ArgumentParser(
description=description, formatter_class=argparse.ArgumentDefaultsHelpFormatter
)
parser.add_argument(
"project_name", default="Your Project Name Here", help="Name of your project"
)
parser.add_argument(
"author", default="Your Team Name Here", help="Directory to place all output"
)
parser.add_argument("--version", default="", help="version of your project, if any")
parser.add_argument("--release", default="", help="release of your project, if any")
args = parser.parse_args()
substitutions = {
"%%PROJECT%%": args.project_name,
"%%AUTHOR%%": args.author,
"%%VERSION%%": args.version,
"%%RELEASE%%": args.release,
}
source_dir = pathlib.Path(__file__).resolve().parent
with open(source_dir / "sample-conf.py", "r") as conf_fo:
sample_contents = conf_fo.read()
for old_value, new_value in substitutions.items():
sample_contents = sample_contents.replace(old_value, new_value)
print(sample_contents)
if __name__ == "__main__":
main()