From b2389cb271e0b49e5dd248d42d3a59bff055a219 Mon Sep 17 00:00:00 2001
From: Gregor Sturm <gregor.sturm@boehringer-ingelheim.com>
Date: Tue, 14 Jan 2025 16:26:31 +0100
Subject: [PATCH] docs command reference (#82)

* Sphinx command reference with click_extra

* Docs: add command reference
---
 docs/command_reference.md    | 97 ++++++++++++++++++++++++++++++++++++
 docs/conf.py                 |  9 +++-
 docs/index.md                |  1 +
 pyproject.toml               |  4 +-
 src/dso/cli/__init__.py      | 34 ++++++-------
 src/dso/cli/_create.py       | 10 ++--
 src/dso/cli/_exec.py         |  6 +--
 tests/conftest.py            |  6 +--
 tests/test_cli.py            |  4 +-
 tests/test_compile_config.py |  8 +--
 tests/test_create.py         |  8 +--
 tests/test_exec.py           | 10 ++--
 tests/test_get_config.py     |  4 +-
 tests/test_init.py           |  6 +--
 tests/test_lint.py           |  4 +-
 tests/test_pandocfilter.py   |  4 +-
 tests/test_watermark.py      |  4 +-
 17 files changed, 162 insertions(+), 57 deletions(-)
 create mode 100644 docs/command_reference.md

diff --git a/docs/command_reference.md b/docs/command_reference.md
new file mode 100644
index 0000000..31d8b14
--- /dev/null
+++ b/docs/command_reference.md
@@ -0,0 +1,97 @@
+# Command reference
+
+## dso
+
+```{eval-rst}
+.. click:run::
+    from dso.cli import dso
+    invoke(dso, args=["--help"])
+```
+
+## dso compile-config
+
+```{eval-rst}
+.. click:run::
+    from dso.cli import dso
+    invoke(dso, args=["compile-config", "--help"])
+```
+
+## dso create
+
+```{eval-rst}
+.. click:run::
+    from dso.cli import dso
+    invoke(dso, args=["create", "--help"])
+```
+
+### dso create folder
+
+```{eval-rst}
+.. click:run::
+    from dso.cli import dso
+    invoke(dso, args=["create", "folder", "--help"])
+```
+
+### dso create stage
+
+```{eval-rst}
+.. click:run::
+    from dso.cli import dso
+    invoke(dso, args=["create", "stage", "--help"])
+```
+
+## dso exec
+
+```{eval-rst}
+.. click:run::
+    from dso.cli import dso
+    invoke(dso, args=["exec", "--help"])
+```
+
+### dso exec quarto
+
+```{eval-rst}
+.. click:run::
+    from dso.cli import dso
+    invoke(dso, args=["exec", "quarto" ,"--help"])
+```
+
+## dso get-config
+
+```{eval-rst}
+.. click:run::
+    from dso.cli import dso
+    invoke(dso, args=["get-config", "--help"])
+```
+
+## dso init
+
+```{eval-rst}
+.. click:run::
+    from dso.cli import dso
+    invoke(dso, args=["init", "--help"])
+```
+
+## dso lint
+
+```{eval-rst}
+.. click:run::
+    from dso.cli import dso
+    invoke(dso, args=["lint", "--help"])
+```
+
+## dso repro
+
+```{eval-rst}
+.. click:run::
+    from dso.cli import dso
+    invoke(dso, args=["repro", "--help"])
+```
+
+## dso watermark
+
+```{eval-rst}
+.. click:run::
+    from dso.cli import dso
+    invoke(dso, args=["watermark", "--help"])
+```
diff --git a/docs/conf.py b/docs/conf.py
index bb7f51e..541355b 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -9,6 +9,13 @@
 from datetime import datetime
 from importlib.metadata import metadata
 from pathlib import Path
+import os
+import rich_click as click
+
+# set maximum width for help pages
+click.rich_click.MAX_WIDTH = 96
+# Set force color for rich outputs shown in the docs
+os.environ["FORCE_COLOR"] = "1"
 
 HERE = Path(__file__).parent
 sys.path.insert(0, str(HERE / "extensions"))
@@ -58,7 +65,7 @@
     "sphinx.ext.mathjax",
     "IPython.sphinxext.ipython_console_highlighting",
     "sphinxext.opengraph",
-    "sphinxcontrib.programoutput",
+    "click_extra.sphinx",
     *[p.stem for p in (HERE / "extensions").glob("*.py")],
 ]
 
diff --git a/docs/index.md b/docs/index.md
index 01e1eec..29ae1fc 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -15,6 +15,7 @@ getting_started.md
 :maxdepth: 1
 :caption: DSO CLI
 
+command_reference.md
 CHANGELOG.md
 contributing.md
 ```
diff --git a/pyproject.toml b/pyproject.toml
index dbb7042..61976ef 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -41,6 +41,7 @@ dependencies = [
 
 optional-dependencies.dev = [ "hatch", "pre-commit" ]
 optional-dependencies.doc = [
+  "click-extra[sphinx]",
   "docutils>=0.8,!=0.18.*,!=0.19.*",
   # For notebooks
   "ipykernel",
@@ -52,7 +53,6 @@ optional-dependencies.doc = [
   "sphinx-book-theme>=1",
   "sphinx-copybutton",
   "sphinxcontrib-bibtex>=1",
-  "sphinxcontrib-programoutput>=0.18",
   "sphinxext-opengraph",
 ]
 optional-dependencies.test = [
@@ -67,7 +67,7 @@ optional-dependencies.test = [
 urls.Documentation = "https://github.com/Boehringer-Ingelheim/dso"
 urls.Home-page = "https://github.com/Boehringer-Ingelheim/dso"
 urls.Source = "https://github.com/Boehringer-Ingelheim/dso"
-scripts.dso = "dso.cli:cli"
+scripts.dso = "dso.cli:dso"
 
 [tool.hatch.version]
 source = "vcs"
diff --git a/src/dso/cli/__init__.py b/src/dso/cli/__init__.py
index ddf8897..bd60b7e 100644
--- a/src/dso/cli/__init__.py
+++ b/src/dso/cli/__init__.py
@@ -15,15 +15,15 @@
 from dso._metadata import __version__
 from dso._util import get_project_root, get_template_path, instantiate_with_repo
 
-from ._create import create_cli
-from ._exec import exec_cli
+from ._create import dso_create
+from ._exec import dso_exec
 
 click.rich_click.USE_MARKDOWN = True
 
 
 @click.command(name="compile-config")
 @click.argument("args", nargs=-1)
-def compile_config_cli(args):
+def dso_compile_config(args):
     """Compile params.in.yaml into params.yaml using Jinja2 templating and resolving recursive templates.
 
     If passing no arguments, configs will be resolved for the current working directory (i.e. all parent configs,
@@ -58,7 +58,7 @@ def compile_config_cli(args):
 @click.argument(
     "stage",
 )
-def get_config_cli(stage, all, skip_compile):
+def dso_get_config(stage, all, skip_compile):
     """Get the configuration for a given stage and print it to STDOUT in yaml format.
 
     The path to the stage must be relative to the root dir of the project.
@@ -85,7 +85,7 @@ def get_config_cli(stage, all, skip_compile):
 @click.command(
     "init",
 )
-def init_cli(name: str | None = None, description: str | None = None):
+def dso_init(name: str | None = None, description: str | None = None):
     """
     Initialize a new project. A project can contain several stages organized in arbitrary subdirectories.
 
@@ -122,7 +122,7 @@ def init_cli(name: str | None = None, description: str | None = None):
     is_flag=True,
 )
 @click.argument("args", nargs=-1)
-def lint_cli(args, skip_compile: bool = False):
+def dso_lint(args, skip_compile: bool = False):
     """Lint a dso project
 
     Performs consistency checks according to a set of rules.
@@ -149,7 +149,7 @@ def lint_cli(args, skip_compile: bool = False):
     context_settings={"ignore_unknown_options": True},
 )
 @click.argument("args", nargs=-1, type=click.UNPROCESSED)
-def repro_cli(args):
+def dso_repro(args):
     """Wrapper around dvc repro, compiling configuration before running."""
     from dso._compile_config import compile_all_configs
     from dso._util import check_ask_pre_commit
@@ -176,7 +176,7 @@ def repro_cli(args):
 @click.option("--font_outline", type=int)
 @click.option("--font_color", help="Use RGBA (e.g. `#AAAAAA88`) to specify transparency")
 @click.option("--font_outline_color", help="Use RGBA (e.g. `#AAAAAA88`) to specify transparency")
-def watermark_cli(input_image, output_image, text, **kwargs):
+def dso_watermark(input_image, output_image, text, **kwargs):
     """Add a watermark to an image
 
     To be called from the dso-r package for implementing a custom graphics device.
@@ -211,7 +211,7 @@ def watermark_cli(input_image, output_image, text, **kwargs):
     is_flag=True,
 )
 @click.version_option(version=__version__, prog_name="dso")
-def cli(quiet: int, verbose: bool):
+def dso(quiet: int, verbose: bool):
     """Root command"""
     if quiet >= 2:
         log.setLevel(logging.ERROR)
@@ -224,11 +224,11 @@ def cli(quiet: int, verbose: bool):
         os.environ["DSO_VERBOSE"] = "1"
 
 
-cli.add_command(create_cli)
-cli.add_command(init_cli)
-cli.add_command(compile_config_cli)
-cli.add_command(repro_cli)
-cli.add_command(exec_cli)
-cli.add_command(lint_cli)
-cli.add_command(get_config_cli)
-cli.add_command(watermark_cli)
+dso.add_command(dso_create)
+dso.add_command(dso_init)
+dso.add_command(dso_compile_config)
+dso.add_command(dso_repro)
+dso.add_command(dso_exec)
+dso.add_command(dso_lint)
+dso.add_command(dso_get_config)
+dso.add_command(dso_watermark)
diff --git a/src/dso/cli/_create.py b/src/dso/cli/_create.py
index 6eda77b..a3d2368 100644
--- a/src/dso/cli/_create.py
+++ b/src/dso/cli/_create.py
@@ -33,7 +33,7 @@
 @click.option("--template", type=click.Choice(list(STAGE_TEMPLATES)))
 @click.argument("name", required=False)
 @click.command("stage", help=CREATE_STAGE_HELP_TEXT)
-def create_stage_cli(name: str | None = None, template: str | None = None, description: str | None = None):
+def dso_create_stage(name: str | None = None, template: str | None = None, description: str | None = None):
     """Create a new stage."""
     import questionary
 
@@ -71,7 +71,7 @@ def create_stage_cli(name: str | None = None, template: str | None = None, descr
 
 @click.argument("name", required=False)
 @click.command("folder")
-def create_folder_cli(name: str | None = None):
+def dso_create_folder(name: str | None = None):
     """Create a new folder. A folder can contain subfolders or stages.
 
     Technically, nothing prevents you from just using `mkdir`. This command additionally adds some default
@@ -102,10 +102,10 @@ def create_folder_cli(name: str | None = None):
 
 
 @click.group(name="create")
-def create_cli():
+def dso_create():
     """Create stage folder structure subcommand."""
     pass
 
 
-create_cli.add_command(create_stage_cli)
-create_cli.add_command(create_folder_cli)
+dso_create.add_command(dso_create_stage)
+dso_create.add_command(dso_create_folder)
diff --git a/src/dso/cli/_exec.py b/src/dso/cli/_exec.py
index ca65090..36ae163 100644
--- a/src/dso/cli/_exec.py
+++ b/src/dso/cli/_exec.py
@@ -16,7 +16,7 @@
     default=bool(int(os.environ.get("DSO_SKIP_COMPILE", 0))),
     is_flag=True,
 )
-def exec_quarto_cli(stage: str, skip_compile: bool = True):
+def dso_exec_quarto(stage: str, skip_compile: bool = True):
     """
     Render a quarto stage. Quarto parameters are inherited from params.yaml
 
@@ -84,9 +84,9 @@ def exec_quarto_cli(stage: str, skip_compile: bool = True):
 
 
 @click.group(name="exec")
-def exec_cli():
+def dso_exec():
     """Dso wrappers around various tools"""
     pass
 
 
-exec_cli.add_command(exec_quarto_cli)
+dso_exec.add_command(dso_exec_quarto)
diff --git a/tests/conftest.py b/tests/conftest.py
index c37adc0..7a629f9 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -6,7 +6,7 @@
 from pytest import fixture
 
 from dso._compile_config import compile_all_configs
-from dso.cli import create_cli, init_cli
+from dso.cli import dso_create, dso_init
 
 TESTDATA = Path(__file__).parent / "data"
 
@@ -17,7 +17,7 @@ def dso_project(tmp_path) -> Path:
     runner = CliRunner()
     proj_name = "dso_project"
     chdir(tmp_path)
-    runner.invoke(init_cli, [proj_name, "--description", "a test project"])
+    runner.invoke(dso_init, [proj_name, "--description", "a test project"])
 
     return tmp_path / proj_name
 
@@ -31,7 +31,7 @@ def quarto_stage(dso_project) -> Path:
     runner = CliRunner()
     stage_name = "quarto_stage"
     chdir(dso_project)
-    runner.invoke(create_cli, ["stage", stage_name, "--template", "quarto", "--description", "a quarto stage"])
+    runner.invoke(dso_create, ["stage", stage_name, "--template", "quarto", "--description", "a quarto stage"])
     with (Path(stage_name) / "src" / f"{stage_name}.qmd").open("w") as f:
         f.write(
             dedent(
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 3268691..1506687 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,9 +1,9 @@
 from click.testing import CliRunner
 
-from dso.cli import cli
+from dso.cli import dso
 
 
 def test_root_command():
     runner = CliRunner()
-    result = runner.invoke(cli)
+    result = runner.invoke(dso)
     assert result.exit_code == 0
diff --git a/tests/test_compile_config.py b/tests/test_compile_config.py
index d1bf3de..0387cc5 100644
--- a/tests/test_compile_config.py
+++ b/tests/test_compile_config.py
@@ -14,7 +14,7 @@
     _get_parent_configs,
     _load_yaml_with_auto_adjusting_paths,
 )
-from dso.cli import compile_config_cli
+from dso.cli import dso_compile_config
 
 
 def _setup_yaml_configs(tmp_path, configs: dict[str, dict]):
@@ -94,7 +94,7 @@ def test_auto_adjusting_path_with_jinja(tmp_path, test_yaml, expected):
         with test_file.open("w") as f:
             f.write(dedent(test_yaml))
 
-        result = runner.invoke(compile_config_cli, [])
+        result = runner.invoke(dso_compile_config, [])
         print(result.output)
         td = Path(td)
         assert result.exit_code == 0
@@ -115,7 +115,7 @@ def test_compile_configs(tmp_path):
                 "A/B/C/params.in.yaml": {"value": "C", "jinja2": "{{ only_root }}", "list": [5]},
             },
         )
-        result = runner.invoke(compile_config_cli, [])
+        result = runner.invoke(dso_compile_config, [])
         print(result.output)
         td = Path(td)
         assert result.exit_code == 0
@@ -140,7 +140,7 @@ def test_compile_configs_null_override(tmp_path):
                 "A/B/params.in.yaml": {"str": None, "list": None, "dict": None, "null": None},
             },
         )
-        result = runner.invoke(compile_config_cli, [])
+        result = runner.invoke(dso_compile_config, [])
         print(result.output)
         td = Path(td)
         assert result.exit_code == 0
diff --git a/tests/test_create.py b/tests/test_create.py
index 9db93ee..68fcfd7 100644
--- a/tests/test_create.py
+++ b/tests/test_create.py
@@ -4,7 +4,7 @@
 import pytest
 from click.testing import CliRunner
 
-from dso.cli import create_cli
+from dso.cli import dso_create
 
 
 @pytest.mark.parametrize("template", ["bash", "quarto"])
@@ -12,7 +12,7 @@ def test_create_stage(dso_project, template):
     runner = CliRunner()
     chdir(dso_project)
     result = runner.invoke(
-        create_cli, ["stage", "teststage", "--template", template, "--description", "testdescription"]
+        dso_create, ["stage", "teststage", "--template", template, "--description", "testdescription"]
     )
     print(result.output)
     assert result.exit_code == 0
@@ -31,7 +31,7 @@ def test_create_stage(dso_project, template):
 def test_create_folder(dso_project):
     runner = CliRunner()
     chdir(dso_project)
-    result = runner.invoke(create_cli, ["folder", "testfolder"])
+    result = runner.invoke(dso_create, ["folder", "testfolder"])
     print(result.output)
     assert result.exit_code == 0
     assert (dso_project / "testfolder").is_dir()
@@ -48,7 +48,7 @@ def test_create_folder_existing_dir(dso_project):
     (dso_project / "testfolder").mkdir()
     (dso_project / "testfolder" / "dvc.yaml").touch()
     chdir(dso_project)
-    result = runner.invoke(create_cli, ["folder", "testfolder"], input="y")
+    result = runner.invoke(dso_create, ["folder", "testfolder"], input="y")
     print(result.output)
     assert result.exit_code == 0
     assert (dso_project / "testfolder").is_dir()
diff --git a/tests/test_exec.py b/tests/test_exec.py
index d7a2efe..2e0cacd 100644
--- a/tests/test_exec.py
+++ b/tests/test_exec.py
@@ -4,7 +4,7 @@
 import pytest
 from click.testing import CliRunner
 
-from dso.cli import exec_cli
+from dso.cli import dso_exec
 
 
 @pytest.mark.parametrize("quiet", [None, "2"])
@@ -21,7 +21,7 @@ def test_exec_quarto(quarto_stage, quiet, launch_dir):
     if quiet is not None:
         os.environ["DSO_QUIET"] = quiet
 
-    result = runner.invoke(exec_cli, ["quarto", stage_path])
+    result = runner.invoke(dso_exec, ["quarto", stage_path])
     assert result.exit_code == 0
     assert (quarto_stage / "report" / "quarto_stage.html").is_file()
     assert "Hello World!" in (quarto_stage / "report" / "quarto_stage.html").read_text()
@@ -32,7 +32,7 @@ def test_exec_quarto_empty_params(quarto_stage_empty_configs):
     chdir(quarto_stage_empty_configs)
     stage_path = "."
 
-    result = runner.invoke(exec_cli, ["quarto", stage_path])
+    result = runner.invoke(dso_exec, ["quarto", stage_path])
     assert result.exit_code == 0
     assert (quarto_stage_empty_configs / "report" / "quarto_stage.html").is_file()
     assert "Hello World!" in (quarto_stage_empty_configs / "report" / "quarto_stage.html").read_text()
@@ -43,7 +43,7 @@ def test_exec_quarto_bibliography(quarto_stage_bibliography):
     chdir(quarto_stage_bibliography)
     stage_path = "."
 
-    result = runner.invoke(exec_cli, ["quarto", stage_path])
+    result = runner.invoke(dso_exec, ["quarto", stage_path])
     assert result.exit_code == 0
     assert (quarto_stage_bibliography / "report" / "quarto_stage.html").is_file()
     assert "Knuth" in (quarto_stage_bibliography / "report" / "quarto_stage.html").read_text()
@@ -54,7 +54,7 @@ def test_exec_quarto_stylesheet(quarto_stage_css):
     chdir(quarto_stage_css)
     stage_path = "."
 
-    result = runner.invoke(exec_cli, ["quarto", stage_path])
+    result = runner.invoke(dso_exec, ["quarto", stage_path])
     assert result.exit_code == 0
     assert (quarto_stage_css / "report" / "quarto_stage.html").is_file()
     assert "h2.veryspecialclass1" in (quarto_stage_css / "report" / "quarto_stage.html").read_text()
diff --git a/tests/test_get_config.py b/tests/test_get_config.py
index 173e337..d797d99 100644
--- a/tests/test_get_config.py
+++ b/tests/test_get_config.py
@@ -5,7 +5,7 @@
 from click.testing import CliRunner
 
 from dso._get_config import _filter_nested_dict, get_config
-from dso.cli import get_config_cli
+from dso.cli import dso_get_config
 
 
 @pytest.mark.parametrize(
@@ -200,6 +200,6 @@ def test_get_config_invalid_stage(dso_project):
 def test_get_config_cli(quarto_stage):
     runner = CliRunner()
     chdir(quarto_stage)
-    result = runner.invoke(get_config_cli, ["quarto_stage"])
+    result = runner.invoke(dso_get_config, ["quarto_stage"])
     assert result.exit_code == 0
     assert "quarto:" in result.output
diff --git a/tests/test_init.py b/tests/test_init.py
index 0fa13e6..6b7ad77 100644
--- a/tests/test_init.py
+++ b/tests/test_init.py
@@ -4,13 +4,13 @@
 
 from click.testing import CliRunner
 
-from dso.cli import init_cli
+from dso.cli import dso_init
 
 
 def test_init(tmp_path):
     runner = CliRunner()
     with runner.isolated_filesystem(temp_dir=tmp_path) as td:
-        result = runner.invoke(init_cli, ["testproject", "--description", "testdescription"])
+        result = runner.invoke(dso_init, ["testproject", "--description", "testdescription"])
         print(result.output)
         td = Path(td)
         assert result.exit_code == 0
@@ -29,7 +29,7 @@ def test_init_existing_dir(dso_project):
     (dso_project / "README.md").unlink()
     rmtree(dso_project / ".git")
 
-    result = runner.invoke(init_cli, [str(dso_project), "--description", "testdescription"], input="y")
+    result = runner.invoke(dso_init, [str(dso_project), "--description", "testdescription"], input="y")
     assert result.exit_code == 0
     assert (dso_project).is_dir()
     assert (dso_project / ".git").is_dir()
diff --git a/tests/test_lint.py b/tests/test_lint.py
index 3b86b66..88f32af 100644
--- a/tests/test_lint.py
+++ b/tests/test_lint.py
@@ -6,7 +6,7 @@
 from click.testing import CliRunner
 
 from dso._lint import DSO001, DSOLinter, LintError, QuartoRule, Rule
-from dso.cli import lint_cli
+from dso.cli import dso_lint
 
 
 @pytest.mark.parametrize(
@@ -204,6 +204,6 @@ def check(cls, file):
 def test_lint_cli(dso_project, paths):
     runner = CliRunner()
     chdir(dso_project)  # proj root
-    result = runner.invoke(lint_cli, paths)
+    result = runner.invoke(dso_lint, paths)
     print(result.output)
     assert result.exit_code == 0
diff --git a/tests/test_pandocfilter.py b/tests/test_pandocfilter.py
index b0fe088..efe19e3 100644
--- a/tests/test_pandocfilter.py
+++ b/tests/test_pandocfilter.py
@@ -5,7 +5,7 @@
 from click.testing import CliRunner
 
 from dso._quarto import render_quarto
-from dso.cli import exec_cli
+from dso.cli import dso_exec
 from tests.conftest import TESTDATA
 
 
@@ -105,7 +105,7 @@ def test_override_config(quarto_stage):
     chdir(quarto_stage)
     stage_path = "."
 
-    result = runner.invoke(exec_cli, ["quarto", stage_path])
+    result = runner.invoke(dso_exec, ["quarto", stage_path])
     assert result.exit_code == 0
 
     out_html = (quarto_stage / "report" / "quarto_stage.html").read_text()
diff --git a/tests/test_watermark.py b/tests/test_watermark.py
index aa12aec..975e8b7 100644
--- a/tests/test_watermark.py
+++ b/tests/test_watermark.py
@@ -5,7 +5,7 @@
 from PIL import Image
 
 from dso._watermark import PDFWatermarker, SVGWatermarker, Watermarker
-from dso.cli import watermark_cli
+from dso.cli import dso_watermark
 from tests.conftest import TESTDATA
 
 
@@ -67,6 +67,6 @@ def test_add_watermark_cli(tmp_path, params):
     test_image = _get_test_image(tmp_path, format="png", size=(500, 500))
     test_image_out = tmp_path / "test_image_out.png"
 
-    result = runner.invoke(watermark_cli, [str(test_image), str(test_image_out), "--text", "test text", *params])
+    result = runner.invoke(dso_watermark, [str(test_image), str(test_image_out), "--text", "test text", *params])
     assert result.exit_code == 0
     assert test_image_out.is_file()