From 7d9c7b6ade6256c4ad96093c25a39570aea60ff2 Mon Sep 17 00:00:00 2001 From: Jim Bosch Date: Sat, 4 Dec 2021 23:59:37 -0500 Subject: [PATCH] Add class that build Sphinx docs from pipeline definitions. --- doc/lsst.pipe.base/index.rst | 2 + python/lsst/pipe/base/pipeline_doc_builder.py | 819 ++++++++++++++++++ 2 files changed, 821 insertions(+) create mode 100644 python/lsst/pipe/base/pipeline_doc_builder.py diff --git a/doc/lsst.pipe.base/index.rst b/doc/lsst.pipe.base/index.rst index 8a86e166a..8c6a8639d 100644 --- a/doc/lsst.pipe.base/index.rst +++ b/doc/lsst.pipe.base/index.rst @@ -101,3 +101,5 @@ Python API reference .. automodapi:: lsst.pipe.base.pipelineIR :no-main-docstr: + +.. automodapi:: lsst.pipe.base.pipeline_doc_builder diff --git a/python/lsst/pipe/base/pipeline_doc_builder.py b/python/lsst/pipe/base/pipeline_doc_builder.py new file mode 100644 index 000000000..69dad9053 --- /dev/null +++ b/python/lsst/pipe/base/pipeline_doc_builder.py @@ -0,0 +1,819 @@ +# This file is part of pipe_base. +# +# Developed for the LSST Data Management System. +# This product includes software developed by the LSST Project +# (http://www.lsst.org). +# See the COPYRIGHT file at the top-level directory of this distribution +# for details of code ownership. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Tools for generating Sphinx documentation from Pipeline definitions. + +The tools in this module were first developed as something to be run by the +SCons build system, generating files that are later used in ``documenteer`` +builds, but it was designed to be usable from other Python code as well (e.g. +some future version of ``documenteer`` itself). See +`PackagePipelinesDocBuilder.scons_generate` for notes on how interfacing with a +SCons build works, as a few quirks of SCons' behavior need to be worked around. +""" + +from __future__ import annotations + +__all__ = ("PackagePipelinesDocBuilder", "PipelineDocBuilder") + +import argparse +import contextlib +import dataclasses +import io +import os +import textwrap +from pathlib import Path +from typing import Dict, Iterable, Iterator, Optional, Sequence, Tuple + +from .dotTools import pipeline2dot +from .pipeline import Pipeline, TaskDef + + +@dataclasses.dataclass +class _DocPaths: + """A base class providing utility methods for structs that maintain a path + to a reStructuredText file. + """ + + rst_path: Path + """Path to a reStructuredText file (`Path`). + """ + + def _relative_to_rst(self, target: Path) -> Path: + """Compute a version of the given path that is relative to the + directory containing `rst_path`. + + Parameters + ---------- + target : `Path` + Path to compute a relative version of. + + Returns + ------- + relative_target : `Path` + A relative version of ``target``. + + Notes + ----- + Unlike `Path.relative_to`, this method can backtrack by including + ``..`` terms where appropriate, provided ``target`` and `rst_path` + have some common root directory. + """ + common = os.path.commonpath([target, self.rst_path.parent]) + target_to_common = target.relative_to(common) + rst_to_common = self.rst_path.parent.relative_to(common) + terms = [".."] * len(rst_to_common.parts) + terms.extend(target_to_common.parts) + return Path(os.path.join(*terms)) + + @staticmethod + def _sanitize_for_rst(*args: str) -> str: + """Combine the given strings and replace any ``/`` characters into + a string suitable for use as a reStructuredText label. + + Parameters + ---------- + *args : `str` + Strings to combine. + + Returns + ------- + sanitized : `str` + Sanitized, combined string. + + Notes + ----- + This method does not attempt to replace all possible problematic + characters, just those common in pipeline names derived from + the directory hierarchy in a typical ``pipelines`` subdirectory. + """ + return ".".join(s.replace("/", "-") for s in args) + + @staticmethod + @contextlib.contextmanager + def _mkdir_and_open(filename: Path) -> Iterator[io.TextIO]: + """Return a context manager that opens a file for writing after first + ensuring its parent directory exists. + + Parameters + ---------- + filename : `Path` + File to open. + + Returns + ------- + cm : `contextlib.ContextManager` [ `io.TextIO` ] + Context manager wrapping a text file open for writing. + """ + filename.parent.mkdir(parents=True, exist_ok=True) + with open(filename, "w") as buffer: + yield buffer + + +@dataclasses.dataclass +class _TaskInPipelineDocBuilder(_DocPaths): + """Struct containing paths relevant for building the docs for a task + within a pipeline. + + This class is intended to be used only by `PipelineDocBuilder`. + """ + + sanitized_name: str + """Name that combines the task label and pipeline name, sanitized for + use as a reStructuredText label. + """ + + config_path: Path + """Path to the config file for this label. + """ + + dot_path: Path + """Path to the GraphViz dot file for a graph that includes just this task + and its inputs and outputs. + """ + + graph_path: Path + """Path to the rendered graph that includes just this task and its inputs + and outputs. + """ + + @classmethod + def from_pipeline_dirs( + cls, + pipeline_name: str, + label: str, + *, + rst_dir: Path, + config_dir: Path, + dot_dir: Path, + graph_dir: Path, + graph_suffix: str, + ) -> _TaskInPipelineDocBuilder: + """Construct from base directories. + + Parameters + ---------- + pipeline_name : `str` + Display name of the pipeline to which this task belongs. + label : `str` + Label of the task within the pipeline. + rst_dir : `Path` + Path to the directory that will contain all reStructuredText files + for the pipeline. + config_dir : `Path` + Path to the directory that will contain all `lsst.pex.config` files + for the pipeline. + dot_dir : `Path` + Path to the directory that will contain all GraphViz DOT files for + the pipeline. + graph_dir : `Path` + Path to the directory that will contain all rendered graphs for + the pipeline. + graph_suffix : `str` + File extension (including the ``.``) for rendered graph files. + + Returns + ------- + instance : `_TaskInPipelineDocBuilder` + New instance of this class. + """ + return cls( + sanitized_name=cls._sanitize_for_rst(pipeline_name, label), + rst_path=rst_dir.joinpath("tasks", label + ".rst"), + config_path=config_dir.joinpath("config", label + ".py"), + dot_path=dot_dir.joinpath("dot", label + ".dot"), + graph_path=graph_dir.joinpath("graph", label + graph_suffix), + ) + + def write_dot(self, task_def: TaskDef) -> None: + """Write the GraphViz DOT file for this task. + + Parameters + ---------- + task_def : `TaskDef` + Expanded `TaskDef` for this task in its pipeline. + """ + with self._mkdir_and_open(self.dot_path) as buffer: + pipeline2dot([task_def], buffer) + + def write_rst(self, pipeline_name: str, task_def: TaskDef) -> None: + """Write the reStructuredText file for this task. + + Parameters + ---------- + pipeline_name : `str` + Display name of the pipeline to which this task belongs. + task_def : `TaskDef` + Expanded `TaskDef` for this task in its pipeline. + """ + with self._mkdir_and_open(self.rst_path) as buffer: + title = f"{pipeline_name}.{task_def.label}: `~{task_def.taskName}`" + buffer.write( + textwrap.dedent( + f"""\ + .. _{self.sanitized_name}: + + {title} + {'"' * len(title)} + + `{task_def.taskName}` + + + (open graph in a separate tab/window to zoom and pan) + + .. image:: {self._relative_to_rst(self.graph_path)} + + .. literalinclude:: {self._relative_to_rst(self.config_path)} + + """ + ) + ) + + +@dataclasses.dataclass +class PipelineDocBuilder(_DocPaths): + """A Sphinx documentation builder for a single `Pipeline`. + + This should generally be constructed via the `from_dirs` factory method, + not a direct call to the constructor. + + The function call operator can be used to write all outputs. It optionally + takes a sequence of `TaskDef` (the result of a call to + `Pipeline.toExpandedPipeline`) as its only argument; this can be ignored + to expand the pipeline internally, and is only useful as an optimization if + calling code already has access to the expanded pipeline. + + Notes + ----- + The documentation build for a pipeline includes expanding the pipeline + itself (applying all config defaults and overrides) and generating GraphViz + DOT diagrams for both the full pipeline and each task. ReStructuredText + files are generated for the pipeline as well as each of its tasks, + referencing that content. + + Transforming ``.dot`` files into images is not handled directly by this + class; it merely manages the paths to those rendered diagrams. See + `PackagePipelinesDocBuilder.scons_generate` for an example of how to + invoke the ``dot`` tool to do this. + """ + + pipeline: Pipeline + """Pipeline to document.""" + + name: str + """Display name and relative filesystem path for the pipeline in + documentation. + + This is usually the same as the path to the pipeline definition ``yaml`` + file relative to a ``pipelines/`` directory; it is normal for it to contain + ``/`` characters. + """ + + sanitized_name: str + """Name for the pipeline that is safe for use as a reStructuredText label. + """ + + yaml_path: Path + """Path to the YAML definition file for the expanded pipeline. + """ + + dot_path: Path + """Path to the GraphViz DOT file for the pipeline. + """ + + graph_path: Path + """Path to the rendered graph for the pipeline. + """ + + tasks: Dict[str, _TaskInPipelineDocBuilder] = dataclasses.field(default_factory=dict) + """Mapping of associated builders for each task in the pipeline. + + Keys are task labels. + """ + + @classmethod + def from_dirs( + cls, + name: str, + pipeline: Pipeline, + *, + rst_dir: Path, + yaml_dir: Path, + dot_dir: Path, + graph_dir: Path, + graph_suffix: str, + ) -> PipelineDocBuilder: + """Construct a builder from the directories that will contain its + outputs. + + Parameters + ---------- + name : `str` + Display name and relative filesystem path for the pipeline. + sanitized_name : `str` + Name for the pipeline that is safe for us as a reStructuredText + label. + rst_dir : `Path` + Path to the directory that will contain all reStructuredText files + for the pipeline. + config_dir : `Path` + Path to the directory that will contain all `lsst.pex.config` files + for the pipeline. + dot_dir : `Path` + Path to the directory that will contain all GraphViz DOT files for + the pipeline. + graph_dir : `Path` + Path to the directory that will contain all rendered graphs for + the pipeline. + graph_suffix : `str` + File extension (including the ``.``) for rendered graph files. + """ + return cls( + pipeline=pipeline, + name=name, + sanitized_name=cls._sanitize_for_rst(name), + rst_path=rst_dir.joinpath("pipeline.rst"), + yaml_path=yaml_dir.joinpath("pipeline.yaml"), + dot_path=dot_dir.joinpath("pipeline.dot"), + graph_path=graph_dir.joinpath("pipeline" + graph_suffix), + tasks={ + label: _TaskInPipelineDocBuilder.from_pipeline_dirs( + pipeline_name=name, + label=label, + rst_dir=rst_dir, + config_dir=yaml_dir, + dot_dir=dot_dir, + graph_dir=graph_dir, + graph_suffix=graph_suffix, + ) + for label in pipeline.tasks + }, + ) + + def __call__(self, task_defs: Optional[Sequence[TaskDef]] = None) -> None: + if task_defs is None: + task_defs = list(self.pipeline) + self.write_expanded_pipeline(task_defs) + self.write_dot(task_defs) + self.write_rst(task_defs) + + def iter_write_paths(self) -> Iterator[Path]: + """Iterate over the paths of all files written by this object's + function call operator. + + This does not include `graph_path` or the similar graph paths for each + task, as those are not actually produced by this class. + """ + yield self.rst_path + yield self.yaml_path + yield self.dot_path + for task_paths in self.tasks.values(): + yield task_paths.rst_path + yield task_paths.config_path + yield task_paths.dot_path + + def iter_graph_dot_paths(self) -> Iterator[Tuple[Path, Path]]: + """Iterate over pairs of ``(graph_path, dot_path)`` for the pipeline + and all of its tasks. + + This is intended to be used to contruct calls to the ``dot`` tool (or + some other GraphViz interpreter) that build the rendered graph files. + """ + yield (self.graph_path, self.dot_path) + for task_paths in self.tasks.values(): + yield (task_paths.graph_path, task_paths.dot_path) + + def write_expanded_pipeline(self, task_defs: Optional[Sequence[TaskDef]] = None) -> None: + """Write the expanded pipeline. + + This just calls `Pipeline.write_to_uri` with ``expand=True``. + + Parameters + ---------- + task_defs : `Sequence` [ `TaskDef` ], optional + The result of a call to `Pipeline.toExpandedPipeline`, captured in + a sequence. May be `None` (default) to expand internally; provided + as a way for calling code to only expand the pipeline once. + """ + if task_defs is None: + task_defs = list(self.pipeline) + self.yaml_path.parent.mkdir(parents=True, exist_ok=True) + self.pipeline.write_to_uri(self.yaml_path.parent, expand=True, task_defs=task_defs) + + def write_dot(self, task_defs: Optional[Sequence[TaskDef]] = None) -> None: + """Write the GraphViz DOT representations of the pipeline and its + tasks. + + Parameters + ---------- + task_defs : `Sequence` [ `TaskDef` ], optional + The result of a call to `Pipeline.toExpandedPipeline`, captured in + a sequence. May be `None` (default) to expand internally; provided + as a way for calling code to only expand the pipeline once. + """ + if task_defs is None: + task_defs = list(self.pipeline) + with self._mkdir_and_open(self.dot_path) as buffer: + pipeline2dot(task_defs, buffer) + for task_def in task_defs: + self.tasks[task_def.label].write_dot(task_def) + + def write_rst(self, task_defs: Optional[Sequence[TaskDef]] = None) -> None: + """Write the reStructuredText files for the pipeline and its tasks. + + Parameters + ---------- + task_defs : `Sequence` [ `TaskDef` ], optional + The result of a call to `Pipeline.toExpandedPipeline`, captured in + a sequence. May be `None` (default) to expand internally; provided + as a way for calling code to only expand the pipeline once. + """ + if task_defs is None: + task_defs = list(self.pipeline) + with self._mkdir_and_open(self.rst_path) as buffer: + buffer.write( + textwrap.dedent( + f"""\ + .. _{self.sanitized_name}: + + {self.name} + {'-' * len(self.name)} + + {self.pipeline.description} + + Tasks + ^^^^^ + .. toctree:: + :maxdepth: 1 + + """ + ) + ) + for task_def in task_defs: + buffer.write( + f" {task_def.label} <{self._relative_to_rst(self.tasks[task_def.label].rst_path)}>\n" + ) + buffer.write("\n") + buffer.write( + textwrap.dedent( + f"""\ + Graph + ^^^^^ + + (open in a separate tab/window to zoom and pan) + + .. image:: {self._relative_to_rst(self.graph_path)} + + Definition + ^^^^^^^^^^ + + .. literalinclude:: {self._relative_to_rst(self.yaml_path)} + + """ + ) + ) + for task_def in task_defs: + self.tasks[task_def.label].write_rst(self.name, task_def) + + @classmethod + def scons_script(cls, args): + """Command-line script used to invoke the builder by SCons. + + This script builds the docs for a single pipeline. + + Parameters + ---------- + args : `argparse.Namespace` + Parsed command-line arguments. Run this module with + ``python -m pipeline --help`` for details. + + See Also + -------- + PackagePipelinesDocBuilder.scons_generate + """ + pipeline = Pipeline.from_uri(args.source_yaml) + builder = PipelineDocBuilder.from_dirs( + args.name, + pipeline, + rst_dir=Path(args.rst_dir), + yaml_dir=Path(args.yaml_dir), + dot_dir=Path(args.dot_dir), + graph_dir=Path(args.graph_dir), + graph_suffix=args.graph_suffix, + ) + builder() + + +@dataclasses.dataclass +class PackagePipelinesDocBuilder(_DocPaths): + """A Sphinx documentation builder for all Pipelines in a single package. + + This should generally be constructed via the `from_source` factory method, + not a direct call to the constructor. + """ + + pipelines: Dict[Path, PipelineDocBuilder] + """Builders for each pipeline, keyed by the path to the ``yaml`` source + file for it (i.e. by convention a path in the packages ``pipelines`` + directory). + """ + + @classmethod + def from_source( + cls, + source_root: Path, + *, + rst_root: Path, + pipeline_root: Path, + dot_root: Path, + graph_root: Path, + graph_suffix: str = ".svg", + rst_path: Optional[Path] = None, + ) -> PackagePipelinesDocBuilder: + """Construct by walking a directory tree containing source ``yaml`` + pipeline files. + + Parameters + ---------- + source_root : `Path` + Directory path to walk for source ``yaml`` pipeline files. + rst_dir : `Path` + Path to the directory that will contain all reStructuredText files + for all pipelines. + config_dir : `Path` + Path to the directory that will contain all `lsst.pex.config` files + for all pipelines. + dot_dir : `Path` + Path to the directory that will contain all GraphViz DOT files for + all pipelines. + graph_dir : `Path` + Path to the directory that will contain all rendered graphs for + all pipelines. + graph_suffix : `str`, optional + File extension (including the ``.``) for rendered graph files. + Defaults to ``.svg``. + rst_path : `Path`, optional + Path to the reStructuredText index file. This file must be + included in the package's Sphinx documentation manually, via + a ``toctree`` or ``include`` directive. Defaults to + ``{rst_root}/index.rst``. + """ + pipelines = {} + for dir_path, _, file_names in os.walk(source_root): + for file_name in file_names: + file_path = Path(dir_path).joinpath(file_name) + if file_path.suffix == ".yaml": + name = cls._name_from_source(file_path, source_root) + pipeline = Pipeline.from_uri(file_path) + pipelines[file_path] = PipelineDocBuilder.from_dirs( + name=name, + pipeline=pipeline, + rst_dir=rst_root.joinpath(name), + yaml_dir=pipeline_root.joinpath(name), + dot_dir=dot_root.joinpath(name), + graph_dir=graph_root.joinpath(name), + graph_suffix=graph_suffix, + ) + return cls( + rst_path=rst_path if rst_path is not None else rst_root.joinpath("index.rst"), + pipelines=pipelines, + ) + + def write_index_rst(self) -> None: + """Write the index reStructuredText file for all pipelines in the + package. + """ + self._write_index_rst_standalone( + self.rst_path, + [self._relative_to_rst(pipeline.rst_path) for pipeline in self.pipelines.values()], + ) + + @classmethod + def _write_index_rst_standalone(cls, target_path: Path, relative_pipeline_rst_paths: Iterable[Path]): + """Implementation of `write_index_rst`. + + This is a classmethod so it can also be called by `scons_script` + without reconstructing all nested `PipelineDocBuilder` instances, with + just the state needed for this method passed in from the command-line. + """ + with cls._mkdir_and_open(target_path) as buffer: + buffer.write( + textwrap.dedent( + """\ + Pipelines + ========= + + .. toctree:: + :maxdepth: 1 + + """ + ) + ) + for path in relative_pipeline_rst_paths: + buffer.write(f" {path}\n") + + @classmethod + def scons_script(cls, args): + """Command-line script used to invoke the builder by SCons. + + This script builds only the index reStructuredText file, not the + per-pipeline content. + + Parameters + ---------- + args : `argparse.Namespace` + Parsed command-line arguments. Run this module with + ``python -m index --help`` for details. + + See Also + -------- + PackagePipelinesDocBuilder.scons_generate + """ + cls._write_index_rst_standalone(Path(args.target), [Path(p) for p in args.relative]) + + @staticmethod + def _name_from_source(source_yaml: Path, source_root: Path) -> str: + """Construct the name for a pipeline from the path to its source + ``yaml`` file and the root for those files. + + Parameters + ---------- + source_yaml : `Path` + Path to a source pipeline ``yaml`` file. + source_root : `Path + Directory path for all source pipeline ``yaml`` files in this + package (usually the package ``pipelines`` directory). + """ + return str(source_yaml.relative_to(source_root).with_suffix("")) + + def scons_generate(self, env, graph_action="dot ${SOURCE} -Tsvg -o ${TARGET}"): + """A generator for SCons actions that build the documentation for all + pipelines in a package. + + Parameters + ---------- + env : `SCons.Environment` + SCons build environment instance. + graph_action : `str` or `Callable`, optional + A string command-line (or more rarely, a Python callable) that + renders a GraphViz DOT into an graphics file consistent with the + ``graph_suffix`` passed to `from_source`. satisfying the SCons + "Action" interface. The default runs ``dot -Tsvg``. + + Yields + ------ + node : `SCons.Node.Node` + An SCons build node for a documentation file generated by this + class. + + Notes + ----- + SCons is Python-based, but it makes a strong distinction between code + that is run when its scripts are merely executed vs. code that runs + when targets are actually built. This method is an example of the + former; it yields SCon objects that run this module on the command-line + via ``python -m`` to achieve the latter. It would be more natural to + just instantiate a `PackagePipelinesDocBuilder` once, and then invoke + each of its nested `PipelineDocBuilder` instances and call + `write_index_rst` directly, but this isn't possible for two reasons: + + - In parallel builds (i.e. ``scons -j``) the (apparent) use of + multithreading causes problems with (apparent) globals in reading and + expanding `Pipelines`. By making each action a separate command-line + invocation, we ensure they are run in their own processes. + + - SCons executes its `SConscript` files with the current directory set + to the directory that `SConscript` file is in, but then builds + targets with the `SConstruct` directory current; it really wants + actions that depend on paths to utilize the targets and sources they + are passed (which are corrected for this shift) instead of + remembering them internally (as this class and those nested within it + do). To work around this, we use relative paths in the script phase + (constructing a `PackagePipelinesDocBuilder` and calling this method) + so SCons can correctly reason about dependencies, and then passing + absolute paths on the command-line so the change of working directory + is relevant. + + Examples + -------- + + Usage in a ``doc/SConscript`` file, where ``lsst.drp.pipe`` is the + name of the package, and the environment object and management of + top-level tarets comes from `lsst.sconsUtils`:: + + from lsst.sconsUtils.state import env, targets + from pathlib import Path + from lsst.pipe.base.pipeline_doc_builder import ( + PackagePipelinesDocBuilder + ) + + target_root = Path(str(env.Dir("lsst.drp.pipe/pipelines"))) + artifacts = list( + PackagePipelinesDocBuilder.from_source( + Path(str(env.Dir("#pipelines"))), + rst_root=target_root, + pipeline_root=target_root, + dot_root=target_root, + graph_root=target_root, + graph_suffix=".svg", + rst_path=Path(str(env.File("lsst.drp.pipe/pipelines_index.rst"))), + ).scons_generate(env) + ) + + env.AlwaysBuild(artifacts) + env.Clean("doc", artifacts) + + targets["doc"].extend(artifacts) + + We use ``AlwaysBuild`` because SCons has no way of knowing when some + modification to an upstream configuration file or pipeline ``yaml`` + ingredient file could change the outputs, so it is safest to rebuild + whenever ``scons`` is run. + """ + source_files = [] + for source_yaml_path, pipeline_builder in self.pipelines.items(): + source_file = env.File(source_yaml_path) + source_files.append(source_file) + target_files = [env.File(p) for p in pipeline_builder.iter_write_paths()] + yield from env.Command( + target_files, + [source_file], + action=( + f"python -m {__name__} pipeline " + f"{pipeline_builder.name} --source-yaml $SOURCE " + f"--rst-dir {pipeline_builder.rst_path.parent.resolve()} " + f"--yaml-dir {pipeline_builder.yaml_path.parent.resolve()} " + f"--dot-dir {pipeline_builder.dot_path.parent.resolve()} " + f"--graph-dir {pipeline_builder.graph_path.parent.resolve()} " + f"--graph-suffix={pipeline_builder.graph_path.suffix} " + ), + ) + if graph_action: + for graph_path, dot_path in pipeline_builder.iter_graph_dot_paths(): + yield from env.Command( + [env.File(graph_path)], + [env.File(dot_path)], + action=graph_action, + ) + relative_pipeline_rst_paths = " ".join( + str(self._relative_to_rst(p.rst_path)) for p in self.pipelines.values() + ) + yield from env.Command( + [env.File(str(self.rst_path))], + source_files, + action=(f"python -m {__name__} index " f"$TARGET {relative_pipeline_rst_paths}"), + ) + + +def main(argv): + """Entry point for command-line invocations used as SCons actions. + + Parameters + ---------- + argv : `Sequence` [ `str` ] + Command-line arguments to parse; generally ``sys.argv[1:]``. + + See Also + -------- + PackagePipelinesDocBuilder.scons_generate + """ + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers() + index_parser = subparsers.add_parser("index") + index_parser.add_argument("target", type=str) + index_parser.add_argument("relative", type=str, nargs="*") + index_parser.set_defaults(func=PackagePipelinesDocBuilder.scons_script) + pipeline_parser = subparsers.add_parser("pipeline") + pipeline_parser.add_argument("name", type=str) + pipeline_parser.add_argument("--source-yaml", type=str) + pipeline_parser.add_argument("--rst-dir", type=str) + pipeline_parser.add_argument("--yaml-dir", type=str) + pipeline_parser.add_argument("--dot-dir", type=str) + pipeline_parser.add_argument("--graph-dir", type=str) + pipeline_parser.add_argument("--graph-suffix", type=str, default=".svg") + pipeline_parser.set_defaults(func=PipelineDocBuilder.scons_script) + args = parser.parse_args(argv) + args.func(args) + + +if __name__ == "__main__": + import sys + + main(sys.argv[1:])