Skip to content

Commit a287aba

Browse files
authored
feat(python): add CLI option (#83)
1 parent 3cfc44c commit a287aba

File tree

7 files changed

+159
-8
lines changed

7 files changed

+159
-8
lines changed

python/cookiecutter.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@
77
"your_name": "",
88
"your_email": "",
99
"add_docs": [false, true],
10+
"add_cli": [false, true],
1011
"add_fastapi": [false, true]
1112
}

python/hooks/post_gen_project.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,16 @@
88
shutil.rmtree("docs")
99
Path(".readthedocs.yaml").unlink()
1010

11+
if not {{ cookiecutter.add_cli }}:
12+
Path("./src/{{ cookiecutter.project_slug }}/cli.py").unlink()
13+
if {{ cookiecutter.add_docs }}:
14+
Path("./docs/source/cli_reference.rst").unlink()
1115

1216
if not {{ cookiecutter.add_fastapi }}:
1317
Path("tests/test_api.py").unlink()
1418
Path("src/{{ cookiecutter.project_slug }}/api.py").unlink()
1519
Path("src/{{ cookiecutter.project_slug }}/models.py").unlink()
1620
Path("src/{{ cookiecutter.project_slug }}/config.py").unlink()
17-
Path("src/{{ cookiecutter.project_slug }}/logging.py").unlink()
21+
22+
if (not {{ cookiecutter.add_fastapi }}) and (not {{ cookiecutter.add_cli }}):
23+
Path("./src/{{ cookiecutter.project_slug }}/logging.py").unlink()
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.. _cli-reference:
2+
3+
Command-line interface
4+
----------------------
5+
6+
.. click:: {{ cookiecutter.project_slug }}.cli:cli
7+
:prog: {{ cookiecutter.project_slug | replace("_", "-") }}
8+
:nested: full

python/{{cookiecutter.project_slug}}/docs/source/conf.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,106 @@ def linkcode_resolve(domain, info):
7777
# -- code block style --------------------------------------------------------
7878
pygments_style = "default"
7979
pygements_dark_style = "monokai"
80+
81+
82+
# -- sphinx-click ------------------------------------------------------------
83+
# These functions let us write descriptions/docstrings in a way that doesn't look
84+
# weird in the Click CLI, but get additional formatting in the sphinx-click autodocs for
85+
# better readability.
86+
import re
87+
88+
from click.core import Context
89+
from sphinx.application import Sphinx
90+
from sphinx_click.ext import _get_usage, _indent
91+
92+
93+
CMD_PATTERN = r"--[^ ]+"
94+
STR_PATTERN = r"\"[^ ]+\""
95+
SNAKE_PATTERN = r"[A-Z]+_[A-Z_]*[A-Z][., ]"
96+
97+
98+
def _add_formatting_to_string(line: str) -> str:
99+
"""Add fixed-width code formatting to span sections in lines:
100+
101+
* shell options, eg `--update_all`
102+
* double-quoted strings, eg `"HGNC"`
103+
* all caps SNAKE_CASE env vars, eg `GENE_NORM_REMOTE_DB_URL`
104+
"""
105+
for pattern in (CMD_PATTERN, STR_PATTERN, SNAKE_PATTERN):
106+
line = re.sub(pattern, lambda x: f"``{x.group()}``", line)
107+
return line
108+
109+
110+
def process_description(app: Sphinx, ctx: Context, lines: list[str]):
111+
"""Add custom formatting to sphinx-click autodoc descriptions.
112+
113+
* remove :param: :return: etc
114+
* add fixed-width (code) font to certain words
115+
* add code block formatting to example shell commands
116+
* move primary usage example to the top of the description
117+
118+
Because we have to modify the lines list in place, we have to make multiple passes
119+
through it to format everything correctly.
120+
"""
121+
if not lines:
122+
return
123+
124+
# chop off params
125+
param_boundary = None
126+
for i, line in enumerate(lines):
127+
if ":param" in line:
128+
param_boundary = i
129+
break
130+
if param_boundary is not None:
131+
del lines[param_boundary:]
132+
lines[-1] = ""
133+
134+
# add code formatting to strings, commands, and env vars
135+
lines_to_fmt = []
136+
for i, line in enumerate(lines):
137+
if line.startswith((" ", ">>> ", "|")):
138+
continue # skip example code blocks
139+
if any(
140+
[
141+
re.findall(CMD_PATTERN, line),
142+
re.findall(STR_PATTERN, line),
143+
re.findall(SNAKE_PATTERN, line),
144+
]
145+
):
146+
lines_to_fmt.append(i)
147+
for line_num in lines_to_fmt:
148+
lines[line_num] = _add_formatting_to_string(lines[line_num])
149+
150+
# add code block formatting to example console commands
151+
for i in range(len(lines) - 1, -1, -1):
152+
if lines[i].startswith((" ", "| ")):
153+
if lines[i].startswith("| "):
154+
lines[i] = lines[i][3:]
155+
if (i == 0 or lines[i - 1] == "\b" or lines[i - 1] == ""):
156+
lines.insert(i, "")
157+
lines.insert(i, ".. code-block:: console")
158+
159+
# put usage at the top of the description
160+
lines.insert(0, "")
161+
for usage_line in _get_usage(ctx).splitlines()[::-1]:
162+
lines.insert(0, _indent(usage_line))
163+
lines.insert(0, "")
164+
lines.insert(0, ".. code-block:: shell")
165+
166+
167+
def process_option(app: Sphinx, ctx: Context, lines: list[str]):
168+
"""Add fixed-width formatting to strings in sphinx-click autodoc options."""
169+
for i, line in enumerate(lines):
170+
if re.findall(STR_PATTERN, line):
171+
lines[i] = re.sub(STR_PATTERN, lambda x: f"``{x.group()}``", line)
172+
173+
174+
def setup(app):
175+
"""Used to hook format customization into sphinx-click build.
176+
177+
In particular, since we move usage to the top of the command description, we need
178+
an extra hook here to silence the built-in usage section.
179+
"""
180+
app.connect("sphinx-click-process-description", process_description)
181+
app.connect("sphinx-click-process-options", process_option)
182+
app.connect("sphinx-click-process-usage", lambda app, ctx, lines: lines.clear())

python/{{cookiecutter.project_slug}}/docs/source/index.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
Installation<install>
2626
Usage<usage>
2727
API Reference<reference/index>
28+
{%- if cookiecutter.add_cli %}
29+
CLI Reference<cli>
30+
{%- endif %}
2831
Changelog<changelog>
2932
Contributing<contributing>
3033
License<license>

python/{{cookiecutter.project_slug}}/pyproject.toml

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,15 @@ classifiers = [
1818
requires-python = ">=3.11"
1919
description = "{{ cookiecutter.description }}"
2020
license = {file = "LICENSE"}
21-
{%- if cookiecutter.add_fastapi %}
2221
dependencies = [
22+
{%- if cookiecutter.add_cli %}
23+
"click",
24+
{%- endif %}
25+
{%- if cookiecutter.add_fastapi %}
2326
"fastapi",
2427
"pydantic~=2.1",
28+
{%- endif %}
2529
]
26-
{% else %}
27-
dependencies = []
28-
{% endif -%}
2930
dynamic = ["version"]
3031

3132
[project.optional-dependencies]
@@ -48,9 +49,12 @@ docs = [
4849
"sphinx-copybutton==0.5.2",
4950
"sphinxext-opengraph==0.8.2",
5051
"furo==2023.3.27",
51-
"sphinx-github-changelog==1.2.1"
52+
"sphinx-github-changelog==1.2.1",
53+
{%- if cookiecutter.add_cli %}
54+
"sphinx-click==5.0.1",
55+
{%- endif %}
5256
]
53-
{% endif %}
57+
{%- endif %}
5458

5559
[project.urls]
5660
Homepage = "https://github.com/{{ cookiecutter.org }}/{{ cookiecutter.repo }}"
@@ -60,6 +64,9 @@ Source = "https://github.com/{{ cookiecutter.org }}/{{ cookiecutter.repo }}"
6064
"Bug Tracker" = "https://github.com/{{ cookiecutter.org }}/{{ cookiecutter.repo }}/issues"
6165

6266
[project.scripts]
67+
{%- if cookiecutter.add_cli %}
68+
{{ cookiecutter.project_slug | replace("_", "-") }} = "{{ cookiecutter.project_slug }}.cli:cli"
69+
{%- endif %}
6370

6471
[build-system]
6572
requires = ["setuptools>=64", "setuptools_scm>=8"]
@@ -79,7 +86,9 @@ branch = true
7986

8087
[tool.ruff]
8188
src = ["src"]
82-
{% if cookiecutter.add_docs %}exclude = ["docs/source/conf.py"]{% endif %}
89+
{%- if cookiecutter.add_docs %}
90+
exclude = ["docs/source/conf.py"]
91+
{%- endif %}
8392

8493
[tool.ruff.lint]
8594
select = [
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""Provide CLI for application."""
2+
3+
import click
4+
5+
from {{ cookiecutter.project_slug }} import __version__
6+
from {{ cookiecutter.project_slug }}.logging import initialize_logs
7+
8+
9+
@click.group()
10+
@click.version_option(__version__)
11+
def cli() -> None:
12+
"""Short description of CLI.
13+
14+
\b
15+
$ echo "provide a multiline description with a leading \\b"
16+
$ echo "more commands here"
17+
$ echo "otherwise, a single indent will pick up proper formatting"
18+
19+
Conclude by summarizing additional commands
20+
""" # noqa: D301
21+
initialize_logs()

0 commit comments

Comments
 (0)