diff --git a/python/cookiecutter.json b/python/cookiecutter.json index 1733049..5aad69f 100644 --- a/python/cookiecutter.json +++ b/python/cookiecutter.json @@ -7,5 +7,6 @@ "your_name": "", "your_email": "", "add_docs": [false, true], + "add_cli": [false, true], "add_fastapi": [false, true] } diff --git a/python/hooks/post_gen_project.py b/python/hooks/post_gen_project.py index 54e8ec6..60e5fa0 100644 --- a/python/hooks/post_gen_project.py +++ b/python/hooks/post_gen_project.py @@ -8,10 +8,16 @@ shutil.rmtree("docs") Path(".readthedocs.yaml").unlink() +if not {{ cookiecutter.add_cli }}: + Path("./src/{{ cookiecutter.project_slug }}/cli.py").unlink() + if {{ cookiecutter.add_docs }}: + Path("./docs/source/cli_reference.rst").unlink() if not {{ cookiecutter.add_fastapi }}: Path("tests/test_api.py").unlink() Path("src/{{ cookiecutter.project_slug }}/api.py").unlink() Path("src/{{ cookiecutter.project_slug }}/models.py").unlink() Path("src/{{ cookiecutter.project_slug }}/config.py").unlink() - Path("src/{{ cookiecutter.project_slug }}/logging.py").unlink() + +if (not {{ cookiecutter.add_fastapi }}) and (not {{ cookiecutter.add_cli }}): + Path("./src/{{ cookiecutter.project_slug }}/logging.py").unlink() diff --git a/python/{{cookiecutter.project_slug}}/docs/source/cli_reference.rst b/python/{{cookiecutter.project_slug}}/docs/source/cli_reference.rst new file mode 100644 index 0000000..c5290ca --- /dev/null +++ b/python/{{cookiecutter.project_slug}}/docs/source/cli_reference.rst @@ -0,0 +1,8 @@ +.. _cli-reference: + +Command-line interface +---------------------- + +.. click:: {{ cookiecutter.project_slug }}.cli:cli + :prog: {{ cookiecutter.project_slug | replace("_", "-") }} + :nested: full diff --git a/python/{{cookiecutter.project_slug}}/docs/source/conf.py b/python/{{cookiecutter.project_slug}}/docs/source/conf.py index 9346029..520b27a 100644 --- a/python/{{cookiecutter.project_slug}}/docs/source/conf.py +++ b/python/{{cookiecutter.project_slug}}/docs/source/conf.py @@ -77,3 +77,106 @@ def linkcode_resolve(domain, info): # -- code block style -------------------------------------------------------- pygments_style = "default" pygements_dark_style = "monokai" + + +# -- sphinx-click ------------------------------------------------------------ +# These functions let us write descriptions/docstrings in a way that doesn't look +# weird in the Click CLI, but get additional formatting in the sphinx-click autodocs for +# better readability. +import re + +from click.core import Context +from sphinx.application import Sphinx +from sphinx_click.ext import _get_usage, _indent + + +CMD_PATTERN = r"--[^ ]+" +STR_PATTERN = r"\"[^ ]+\"" +SNAKE_PATTERN = r"[A-Z]+_[A-Z_]*[A-Z][., ]" + + +def _add_formatting_to_string(line: str) -> str: + """Add fixed-width code formatting to span sections in lines: + + * shell options, eg `--update_all` + * double-quoted strings, eg `"HGNC"` + * all caps SNAKE_CASE env vars, eg `GENE_NORM_REMOTE_DB_URL` + """ + for pattern in (CMD_PATTERN, STR_PATTERN, SNAKE_PATTERN): + line = re.sub(pattern, lambda x: f"``{x.group()}``", line) + return line + + +def process_description(app: Sphinx, ctx: Context, lines: list[str]): + """Add custom formatting to sphinx-click autodoc descriptions. + + * remove :param: :return: etc + * add fixed-width (code) font to certain words + * add code block formatting to example shell commands + * move primary usage example to the top of the description + + Because we have to modify the lines list in place, we have to make multiple passes + through it to format everything correctly. + """ + if not lines: + return + + # chop off params + param_boundary = None + for i, line in enumerate(lines): + if ":param" in line: + param_boundary = i + break + if param_boundary is not None: + del lines[param_boundary:] + lines[-1] = "" + + # add code formatting to strings, commands, and env vars + lines_to_fmt = [] + for i, line in enumerate(lines): + if line.startswith((" ", ">>> ", "|")): + continue # skip example code blocks + if any( + [ + re.findall(CMD_PATTERN, line), + re.findall(STR_PATTERN, line), + re.findall(SNAKE_PATTERN, line), + ] + ): + lines_to_fmt.append(i) + for line_num in lines_to_fmt: + lines[line_num] = _add_formatting_to_string(lines[line_num]) + + # add code block formatting to example console commands + for i in range(len(lines) - 1, -1, -1): + if lines[i].startswith((" ", "| ")): + if lines[i].startswith("| "): + lines[i] = lines[i][3:] + if (i == 0 or lines[i - 1] == "\b" or lines[i - 1] == ""): + lines.insert(i, "") + lines.insert(i, ".. code-block:: console") + + # put usage at the top of the description + lines.insert(0, "") + for usage_line in _get_usage(ctx).splitlines()[::-1]: + lines.insert(0, _indent(usage_line)) + lines.insert(0, "") + lines.insert(0, ".. code-block:: shell") + + +def process_option(app: Sphinx, ctx: Context, lines: list[str]): + """Add fixed-width formatting to strings in sphinx-click autodoc options.""" + for i, line in enumerate(lines): + if re.findall(STR_PATTERN, line): + lines[i] = re.sub(STR_PATTERN, lambda x: f"``{x.group()}``", line) + + +def setup(app): + """Used to hook format customization into sphinx-click build. + + In particular, since we move usage to the top of the command description, we need + an extra hook here to silence the built-in usage section. + """ + app.connect("sphinx-click-process-description", process_description) + app.connect("sphinx-click-process-options", process_option) + app.connect("sphinx-click-process-usage", lambda app, ctx, lines: lines.clear()) diff --git a/python/{{cookiecutter.project_slug}}/docs/source/index.rst b/python/{{cookiecutter.project_slug}}/docs/source/index.rst index 15e4d3c..b52626d 100644 --- a/python/{{cookiecutter.project_slug}}/docs/source/index.rst +++ b/python/{{cookiecutter.project_slug}}/docs/source/index.rst @@ -25,6 +25,9 @@ Installation Usage API Reference +{%- if cookiecutter.add_cli %} + CLI Reference +{%- endif %} Changelog Contributing License diff --git a/python/{{cookiecutter.project_slug}}/pyproject.toml b/python/{{cookiecutter.project_slug}}/pyproject.toml index 3120c68..c738041 100644 --- a/python/{{cookiecutter.project_slug}}/pyproject.toml +++ b/python/{{cookiecutter.project_slug}}/pyproject.toml @@ -18,14 +18,15 @@ classifiers = [ requires-python = ">=3.11" description = "{{ cookiecutter.description }}" license = {file = "LICENSE"} -{%- if cookiecutter.add_fastapi %} dependencies = [ +{%- if cookiecutter.add_cli %} + "click", +{%- endif %} +{%- if cookiecutter.add_fastapi %} "fastapi", "pydantic~=2.1", +{%- endif %} ] -{% else %} -dependencies = [] -{% endif -%} dynamic = ["version"] [project.optional-dependencies] @@ -48,9 +49,12 @@ docs = [ "sphinx-copybutton==0.5.2", "sphinxext-opengraph==0.8.2", "furo==2023.3.27", - "sphinx-github-changelog==1.2.1" + "sphinx-github-changelog==1.2.1", +{%- if cookiecutter.add_cli %} + "sphinx-click==5.0.1", +{%- endif %} ] -{% endif %} +{%- endif %} [project.urls] Homepage = "https://github.com/{{ cookiecutter.org }}/{{ cookiecutter.repo }}" @@ -60,6 +64,9 @@ Source = "https://github.com/{{ cookiecutter.org }}/{{ cookiecutter.repo }}" "Bug Tracker" = "https://github.com/{{ cookiecutter.org }}/{{ cookiecutter.repo }}/issues" [project.scripts] +{%- if cookiecutter.add_cli %} +{{ cookiecutter.project_slug | replace("_", "-") }} = "{{ cookiecutter.project_slug }}.cli:cli" +{%- endif %} [build-system] requires = ["setuptools>=64", "setuptools_scm>=8"] @@ -79,7 +86,9 @@ branch = true [tool.ruff] src = ["src"] -{% if cookiecutter.add_docs %}exclude = ["docs/source/conf.py"]{% endif %} +{%- if cookiecutter.add_docs %} +exclude = ["docs/source/conf.py"] +{%- endif %} [tool.ruff.lint] select = [ diff --git a/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/cli.py b/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/cli.py new file mode 100644 index 0000000..238c181 --- /dev/null +++ b/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/cli.py @@ -0,0 +1,21 @@ +"""Provide CLI for application.""" + +import click + +from {{ cookiecutter.project_slug }} import __version__ +from {{ cookiecutter.project_slug }}.logging import initialize_logs + + +@click.group() +@click.version_option(__version__) +def cli() -> None: + """Short description of CLI. + + \b + $ echo "provide a multiline description with a leading \\b" + $ echo "more commands here" + $ echo "otherwise, a single indent will pick up proper formatting" + + Conclude by summarizing additional commands + """ # noqa: D301 + initialize_logs()