click-docs 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- click_docs/__init__.py +3 -0
- click_docs/cli.py +190 -0
- click_docs/config.py +56 -0
- click_docs/generator.py +368 -0
- click_docs/loader.py +83 -0
- click_docs-0.2.0.dist-info/METADATA +140 -0
- click_docs-0.2.0.dist-info/RECORD +10 -0
- click_docs-0.2.0.dist-info/WHEEL +4 -0
- click_docs-0.2.0.dist-info/entry_points.txt +2 -0
- click_docs-0.2.0.dist-info/licenses/LICENSE +13 -0
click_docs/__init__.py
ADDED
click_docs/cli.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Command-line entry point for click-docs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from click.core import ParameterSource
|
|
11
|
+
|
|
12
|
+
from .config import find_config
|
|
13
|
+
from .generator import generate_docs
|
|
14
|
+
from .loader import LoadError, load_command
|
|
15
|
+
|
|
16
|
+
# Maps Python parameter names (underscores) to pyproject.toml keys (hyphens).
|
|
17
|
+
# Note: "exclude" is intentionally absent — it needs tuple() coercion and is handled
|
|
18
|
+
# as a special case in _apply_config.
|
|
19
|
+
_PARAM_TO_CONFIG_KEY: dict[str, str] = {
|
|
20
|
+
"command_name": "command-name",
|
|
21
|
+
"program_name": "program-name",
|
|
22
|
+
"header_depth": "header-depth",
|
|
23
|
+
"style": "style",
|
|
24
|
+
"output": "output",
|
|
25
|
+
"depth": "depth",
|
|
26
|
+
"show_hidden": "show-hidden",
|
|
27
|
+
"list_subcommands": "list-subcommands",
|
|
28
|
+
"remove_ascii_art": "remove-ascii-art",
|
|
29
|
+
"full_command_path": "full-command-path",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _apply_config(ctx: click.Context, config: dict[str, Any], kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
34
|
+
"""Override defaulted CLI params with values from config.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
ctx: The Click context for the current invocation.
|
|
38
|
+
config: Dict of [tool.click-docs] values from pyproject.toml.
|
|
39
|
+
kwargs: Current parameter values keyed by Python name (underscores).
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Updated kwargs dict with config values applied where CLI used its default.
|
|
43
|
+
"""
|
|
44
|
+
result = dict(kwargs)
|
|
45
|
+
for param, config_key in _PARAM_TO_CONFIG_KEY.items():
|
|
46
|
+
if ctx.get_parameter_source(param) == ParameterSource.DEFAULT and config_key in config:
|
|
47
|
+
result[param] = config[config_key]
|
|
48
|
+
if ctx.get_parameter_source("exclude") == ParameterSource.DEFAULT and "exclude" in config:
|
|
49
|
+
result["exclude"] = tuple(config["exclude"])
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@click.command()
|
|
54
|
+
@click.pass_context
|
|
55
|
+
@click.argument("module_path")
|
|
56
|
+
@click.option(
|
|
57
|
+
"--command-name",
|
|
58
|
+
default="cli",
|
|
59
|
+
show_default=True,
|
|
60
|
+
help="Dotted attribute path to the Click command object.",
|
|
61
|
+
)
|
|
62
|
+
@click.option(
|
|
63
|
+
"--program-name",
|
|
64
|
+
default=None,
|
|
65
|
+
help="Display name for the command in headings and usage lines.",
|
|
66
|
+
)
|
|
67
|
+
@click.option(
|
|
68
|
+
"--header-depth",
|
|
69
|
+
default=1,
|
|
70
|
+
show_default=True,
|
|
71
|
+
type=click.IntRange(1, 6),
|
|
72
|
+
help="Markdown header level for the command title (1-6).",
|
|
73
|
+
)
|
|
74
|
+
@click.option(
|
|
75
|
+
"--style",
|
|
76
|
+
default="plain",
|
|
77
|
+
show_default=True,
|
|
78
|
+
type=click.Choice(["plain", "table"]),
|
|
79
|
+
help="Options rendering style.",
|
|
80
|
+
)
|
|
81
|
+
@click.option(
|
|
82
|
+
"--output",
|
|
83
|
+
default=None,
|
|
84
|
+
metavar="FILE",
|
|
85
|
+
help="Write output to FILE instead of stdout.",
|
|
86
|
+
)
|
|
87
|
+
@click.option(
|
|
88
|
+
"--depth",
|
|
89
|
+
default=None,
|
|
90
|
+
type=click.IntRange(min=0),
|
|
91
|
+
metavar="N",
|
|
92
|
+
help="Maximum subcommand recursion depth (0=root only; default=unlimited).",
|
|
93
|
+
)
|
|
94
|
+
@click.option(
|
|
95
|
+
"--exclude",
|
|
96
|
+
multiple=True,
|
|
97
|
+
metavar="PATH",
|
|
98
|
+
help="Dotted command path to exclude (e.g. root.admin.reset). Repeatable.",
|
|
99
|
+
)
|
|
100
|
+
@click.option(
|
|
101
|
+
"--show-hidden",
|
|
102
|
+
is_flag=True,
|
|
103
|
+
default=False,
|
|
104
|
+
help="Include commands and options marked hidden=True.",
|
|
105
|
+
)
|
|
106
|
+
@click.option(
|
|
107
|
+
"--list-subcommands",
|
|
108
|
+
is_flag=True,
|
|
109
|
+
default=False,
|
|
110
|
+
help="Prepend a bulleted TOC of subcommands at the root level.",
|
|
111
|
+
)
|
|
112
|
+
@click.option(
|
|
113
|
+
"--remove-ascii-art",
|
|
114
|
+
is_flag=True,
|
|
115
|
+
default=False,
|
|
116
|
+
help=r"Strip \b-prefixed blocks (ASCII art) from help text.",
|
|
117
|
+
)
|
|
118
|
+
@click.option(
|
|
119
|
+
"--full-command-path",
|
|
120
|
+
is_flag=True,
|
|
121
|
+
default=False,
|
|
122
|
+
help="Use the full command path in headers (e.g. 'cli admin' instead of 'admin').",
|
|
123
|
+
)
|
|
124
|
+
def cli(
|
|
125
|
+
ctx: click.Context,
|
|
126
|
+
module_path: str,
|
|
127
|
+
command_name: str,
|
|
128
|
+
program_name: str | None,
|
|
129
|
+
header_depth: int,
|
|
130
|
+
style: str,
|
|
131
|
+
output: str | None,
|
|
132
|
+
depth: int | None,
|
|
133
|
+
exclude: tuple[str, ...],
|
|
134
|
+
show_hidden: bool,
|
|
135
|
+
list_subcommands: bool,
|
|
136
|
+
remove_ascii_art: bool,
|
|
137
|
+
full_command_path: bool,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""
|
|
140
|
+
Generate Markdown documentation for a Click application.
|
|
141
|
+
|
|
142
|
+
MODULE_PATH is a file system path to the Python module containing the Click command.
|
|
143
|
+
"""
|
|
144
|
+
resolved = _apply_config(
|
|
145
|
+
ctx,
|
|
146
|
+
find_config(),
|
|
147
|
+
{
|
|
148
|
+
"command_name": command_name,
|
|
149
|
+
"program_name": program_name,
|
|
150
|
+
"header_depth": header_depth,
|
|
151
|
+
"style": style,
|
|
152
|
+
"output": output,
|
|
153
|
+
"depth": depth,
|
|
154
|
+
"exclude": exclude,
|
|
155
|
+
"show_hidden": show_hidden,
|
|
156
|
+
"list_subcommands": list_subcommands,
|
|
157
|
+
"remove_ascii_art": remove_ascii_art,
|
|
158
|
+
"full_command_path": full_command_path,
|
|
159
|
+
},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
command = load_command(module_path, resolved["command_name"])
|
|
164
|
+
except LoadError as exc:
|
|
165
|
+
click.echo(f"Error: {exc}", err=True)
|
|
166
|
+
sys.exit(1)
|
|
167
|
+
|
|
168
|
+
markdown = generate_docs(
|
|
169
|
+
command,
|
|
170
|
+
program_name=resolved["program_name"],
|
|
171
|
+
header_depth=resolved["header_depth"],
|
|
172
|
+
style=resolved["style"],
|
|
173
|
+
depth=resolved["depth"],
|
|
174
|
+
exclude=resolved["exclude"],
|
|
175
|
+
show_hidden=resolved["show_hidden"],
|
|
176
|
+
list_subcommands=resolved["list_subcommands"],
|
|
177
|
+
remove_ascii_art=resolved["remove_ascii_art"],
|
|
178
|
+
full_command_path=resolved["full_command_path"],
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if resolved["output"] is None:
|
|
182
|
+
click.echo(markdown, nl=False)
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
out_path = Path(resolved["output"])
|
|
186
|
+
if not out_path.parent.exists():
|
|
187
|
+
click.echo(f"Error: Output directory {out_path.parent!r} does not exist.", err=True)
|
|
188
|
+
sys.exit(1)
|
|
189
|
+
|
|
190
|
+
out_path.write_text(markdown, encoding="utf-8")
|
click_docs/config.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Configuration loading from pyproject.toml for click-docs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def find_config(start: Path | None = None) -> dict[str, Any]:
|
|
10
|
+
"""
|
|
11
|
+
Searches for a 'pyproject.toml' file starting from a given directory and traversing up the directory tree.
|
|
12
|
+
|
|
13
|
+
If found, the configuration file is read and returned as a dictionary. If no configuration file is found, an
|
|
14
|
+
empty dictionary is returned.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
start: Starting directory path to begin the search for 'pyproject.toml'. If None, the current
|
|
18
|
+
working directory is used.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
A dictionary containing the configuration data parsed from 'pyproject.toml'. Returns an empty
|
|
22
|
+
dictionary if no configuration file is located.
|
|
23
|
+
"""
|
|
24
|
+
if start is None:
|
|
25
|
+
start = Path.cwd()
|
|
26
|
+
|
|
27
|
+
current = Path(start).resolve()
|
|
28
|
+
while True:
|
|
29
|
+
candidate = current / "pyproject.toml"
|
|
30
|
+
if candidate.is_file():
|
|
31
|
+
return _read_config(candidate)
|
|
32
|
+
parent = current.parent
|
|
33
|
+
if parent == current:
|
|
34
|
+
return {}
|
|
35
|
+
current = parent
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _read_config(path: Path) -> dict[str, Any]:
|
|
39
|
+
"""
|
|
40
|
+
Read the *[tool.click-docs]* section from a pyproject.toml file.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
path: Path to the pyproject.toml file.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Dict of *[tool.click-docs]* values, or empty dict if the section is absent.
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
import tomllib
|
|
50
|
+
except ImportError:
|
|
51
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
52
|
+
|
|
53
|
+
with path.open("rb") as f:
|
|
54
|
+
data = tomllib.load(f)
|
|
55
|
+
|
|
56
|
+
return data.get("tool", {}).get("click-docs", {})
|
click_docs/generator.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Generate Markdown documentation for a Click command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import re
|
|
7
|
+
from contextlib import contextmanager, nullcontext
|
|
8
|
+
from typing import Iterator
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def generate_docs(
|
|
14
|
+
command: click.BaseCommand,
|
|
15
|
+
program_name: str | None = None,
|
|
16
|
+
header_depth: int = 1,
|
|
17
|
+
style: str = "plain",
|
|
18
|
+
depth: int | None = None,
|
|
19
|
+
exclude: tuple[str, ...] | list[str] = (),
|
|
20
|
+
show_hidden: bool = False,
|
|
21
|
+
list_subcommands: bool = False,
|
|
22
|
+
remove_ascii_art: bool = False,
|
|
23
|
+
full_command_path: bool = False,
|
|
24
|
+
) -> str:
|
|
25
|
+
"""
|
|
26
|
+
Generate Markdown documentation for a Click command.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
command: The Click command to document.
|
|
30
|
+
program_name: The display name used in the heading and usage line.
|
|
31
|
+
Defaults to the command's own ``name`` attribute.
|
|
32
|
+
header_depth: Markdown header level for the command title (1-6).
|
|
33
|
+
style: Options rendering style; ``"plain"`` (default) or ``"table"``.
|
|
34
|
+
depth: Maximum recursion depth. ``0`` = root only, ``None`` = unlimited.
|
|
35
|
+
exclude: Dotted command paths to skip (e.g. ``("root.admin.reset",)``).
|
|
36
|
+
The root command name is the first segment.
|
|
37
|
+
show_hidden: Include commands and options marked ``hidden=True``.
|
|
38
|
+
list_subcommands: Prepend a bulleted TOC of direct subcommands at the root.
|
|
39
|
+
remove_ascii_art: Strip ``\\b``-prefixed blocks (up to the next blank line).
|
|
40
|
+
full_command_path: Use the full command path in headers (e.g. ``cli admin``).
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
A Markdown string ending with a single newline.
|
|
44
|
+
"""
|
|
45
|
+
if program_name is None:
|
|
46
|
+
program_name = command.name or ""
|
|
47
|
+
exclude_set = frozenset(exclude)
|
|
48
|
+
# Collapse consecutive blank lines to a single blank line while iterating.
|
|
49
|
+
deduped: list[str] = []
|
|
50
|
+
for line in _recursively_make_command_docs(
|
|
51
|
+
command=command,
|
|
52
|
+
prog_name=program_name,
|
|
53
|
+
parent_ctx=None,
|
|
54
|
+
header_depth=header_depth,
|
|
55
|
+
style=style,
|
|
56
|
+
current_depth=0,
|
|
57
|
+
max_depth=depth,
|
|
58
|
+
exclude_set=exclude_set,
|
|
59
|
+
show_hidden=show_hidden,
|
|
60
|
+
list_subcommands=list_subcommands,
|
|
61
|
+
remove_ascii_art=remove_ascii_art,
|
|
62
|
+
full_command_path=full_command_path,
|
|
63
|
+
command_path=program_name,
|
|
64
|
+
):
|
|
65
|
+
if line == "" and deduped and deduped[-1] == "": # NOQA: PLC1901
|
|
66
|
+
continue
|
|
67
|
+
deduped.append(line)
|
|
68
|
+
return "\n".join(deduped) + "\n"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _recursively_make_command_docs(
|
|
72
|
+
command: click.BaseCommand,
|
|
73
|
+
prog_name: str,
|
|
74
|
+
parent_ctx: click.Context | None,
|
|
75
|
+
header_depth: int,
|
|
76
|
+
style: str,
|
|
77
|
+
current_depth: int,
|
|
78
|
+
max_depth: int | None,
|
|
79
|
+
exclude_set: frozenset[str],
|
|
80
|
+
show_hidden: bool,
|
|
81
|
+
list_subcommands: bool,
|
|
82
|
+
remove_ascii_art: bool,
|
|
83
|
+
full_command_path: bool,
|
|
84
|
+
command_path: str,
|
|
85
|
+
) -> Iterator[str]:
|
|
86
|
+
"""Yield Markdown lines for a command and its subcommands (depth-first)."""
|
|
87
|
+
if getattr(command, "hidden", False) and not show_hidden:
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
ctx = command.make_context(prog_name, [], parent=parent_ctx, resilient_parsing=True)
|
|
91
|
+
|
|
92
|
+
yield from _make_title(ctx, header_depth, full_command_path)
|
|
93
|
+
yield from _make_description(ctx, remove_ascii_art)
|
|
94
|
+
yield from _make_usage(ctx)
|
|
95
|
+
yield from _make_options(ctx, style, show_hidden)
|
|
96
|
+
|
|
97
|
+
if max_depth is not None and current_depth >= max_depth:
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
subcommands = _get_subcommands(command, ctx)
|
|
101
|
+
if not subcommands:
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
subcommands.sort(key=lambda cmd: cmd.name or "")
|
|
105
|
+
|
|
106
|
+
if list_subcommands and current_depth == 0:
|
|
107
|
+
yield ""
|
|
108
|
+
yield from _make_subcommands_toc(subcommands, ctx, show_hidden, full_command_path)
|
|
109
|
+
|
|
110
|
+
for subcmd in subcommands:
|
|
111
|
+
sub_name = subcmd.name or ""
|
|
112
|
+
sub_path = f"{command_path}.{sub_name}"
|
|
113
|
+
if sub_path in exclude_set:
|
|
114
|
+
continue
|
|
115
|
+
yield ""
|
|
116
|
+
yield from _recursively_make_command_docs(
|
|
117
|
+
command=subcmd,
|
|
118
|
+
prog_name=sub_name,
|
|
119
|
+
parent_ctx=ctx,
|
|
120
|
+
header_depth=header_depth + 1,
|
|
121
|
+
style=style,
|
|
122
|
+
current_depth=current_depth + 1,
|
|
123
|
+
max_depth=max_depth,
|
|
124
|
+
exclude_set=exclude_set,
|
|
125
|
+
show_hidden=show_hidden,
|
|
126
|
+
list_subcommands=list_subcommands,
|
|
127
|
+
remove_ascii_art=remove_ascii_art,
|
|
128
|
+
full_command_path=full_command_path,
|
|
129
|
+
command_path=sub_path,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _get_subcommands(command: click.BaseCommand, ctx: click.Context) -> list[click.BaseCommand]:
|
|
134
|
+
"""Return direct subcommands of a group command."""
|
|
135
|
+
commands: dict = getattr(command, "commands", {})
|
|
136
|
+
if commands:
|
|
137
|
+
return list(commands.values())
|
|
138
|
+
if isinstance(command, click.Group):
|
|
139
|
+
result = []
|
|
140
|
+
for name in command.list_commands(ctx):
|
|
141
|
+
subcmd = command.get_command(ctx, name)
|
|
142
|
+
if subcmd is not None:
|
|
143
|
+
result.append(subcmd)
|
|
144
|
+
return result
|
|
145
|
+
return []
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _slugify(text: str) -> str:
|
|
149
|
+
"""Convert *text* to a GitHub-compatible anchor slug."""
|
|
150
|
+
text = text.lower()
|
|
151
|
+
text = re.sub(r"[^\w\s-]", "", text)
|
|
152
|
+
text = re.sub(r"[\s_]+", "-", text)
|
|
153
|
+
return text.strip("-")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _make_subcommands_toc(
|
|
157
|
+
subcommands: list[click.BaseCommand],
|
|
158
|
+
parent_ctx: click.Context,
|
|
159
|
+
show_hidden: bool,
|
|
160
|
+
full_command_path: bool,
|
|
161
|
+
) -> Iterator[str]:
|
|
162
|
+
"""Yield a bulleted TOC of direct subcommands with anchor links."""
|
|
163
|
+
yield "**Subcommands:**"
|
|
164
|
+
yield ""
|
|
165
|
+
for cmd in subcommands:
|
|
166
|
+
if getattr(cmd, "hidden", False) and not show_hidden:
|
|
167
|
+
continue
|
|
168
|
+
cmd_name = cmd.name or ""
|
|
169
|
+
header_text = f"{parent_ctx.command_path} {cmd_name}" if full_command_path else cmd_name
|
|
170
|
+
anchor = _slugify(header_text)
|
|
171
|
+
short_help = _get_short_help(cmd)
|
|
172
|
+
if short_help:
|
|
173
|
+
yield f"- [{cmd_name}](#{anchor}): {short_help}"
|
|
174
|
+
else:
|
|
175
|
+
yield f"- [{cmd_name}](#{anchor})"
|
|
176
|
+
yield ""
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _get_short_help(command: click.BaseCommand) -> str:
|
|
180
|
+
"""Return the first line of a command's help text (before \\f or \\n)."""
|
|
181
|
+
help_text = getattr(command, "help", None) or getattr(command, "short_help", None) or ""
|
|
182
|
+
help_text = inspect.cleandoc(help_text)
|
|
183
|
+
help_text = help_text.partition("\f")[0]
|
|
184
|
+
return help_text.splitlines()[0].strip() if help_text.strip() else ""
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _make_title(
|
|
188
|
+
ctx: click.Context,
|
|
189
|
+
header_depth: int = 1,
|
|
190
|
+
full_command_path: bool = False,
|
|
191
|
+
) -> Iterator[str]:
|
|
192
|
+
"""Yield the command title as a Markdown heading at the given depth."""
|
|
193
|
+
text = ctx.command_path if full_command_path else ctx.info_name
|
|
194
|
+
yield f"{'#' * header_depth} {text}"
|
|
195
|
+
yield ""
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _make_description(ctx: click.Context, remove_ascii_art: bool = False) -> Iterator[str]:
|
|
199
|
+
"""Yield help text lines, truncating at ``\\f``.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
ctx: The Click command context.
|
|
203
|
+
remove_ascii_art: Strip ``\\b``-prefixed ASCII art blocks (first ``\\b`` line
|
|
204
|
+
through the next blank line). Defaults to False.
|
|
205
|
+
|
|
206
|
+
Yields:
|
|
207
|
+
str: Lines from the command's help text.
|
|
208
|
+
"""
|
|
209
|
+
help_text = ctx.command.help or ctx.command.short_help
|
|
210
|
+
if not help_text:
|
|
211
|
+
return
|
|
212
|
+
help_text = inspect.cleandoc(help_text)
|
|
213
|
+
help_text = help_text.partition("\f")[0]
|
|
214
|
+
|
|
215
|
+
if not remove_ascii_art:
|
|
216
|
+
yield from help_text.splitlines()
|
|
217
|
+
yield ""
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
# Strip the \b block: from first \b line through the next blank line.
|
|
221
|
+
in_ascii_art = False
|
|
222
|
+
for i, line in enumerate(help_text.splitlines()):
|
|
223
|
+
if not in_ascii_art:
|
|
224
|
+
if i == 0 and line.strip() == "\b": # \b blocks only appear at the start of help text
|
|
225
|
+
in_ascii_art = True
|
|
226
|
+
continue
|
|
227
|
+
yield line
|
|
228
|
+
else:
|
|
229
|
+
if not line.strip():
|
|
230
|
+
in_ascii_art = False # blank line ends the block; skip it
|
|
231
|
+
# else: skip this ascii art line
|
|
232
|
+
yield ""
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _make_usage(ctx: click.Context) -> Iterator[str]:
|
|
236
|
+
"""Yield a fenced-code usage block.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
ctx: The Click command context.
|
|
240
|
+
|
|
241
|
+
Yields:
|
|
242
|
+
str: Markdown lines forming the ``**Usage:**`` fenced block.
|
|
243
|
+
"""
|
|
244
|
+
formatter = ctx.make_formatter()
|
|
245
|
+
pieces = ctx.command.collect_usage_pieces(ctx)
|
|
246
|
+
formatter.write_usage(ctx.command_path, " ".join(pieces), prefix="")
|
|
247
|
+
usage = formatter.getvalue().strip()
|
|
248
|
+
|
|
249
|
+
yield "**Usage:**"
|
|
250
|
+
yield ""
|
|
251
|
+
yield "```text"
|
|
252
|
+
yield usage
|
|
253
|
+
yield "```"
|
|
254
|
+
yield ""
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _make_options(ctx: click.Context, style: str = "plain", show_hidden: bool = False) -> Iterator[str]:
|
|
258
|
+
"""Dispatch to the appropriate options renderer."""
|
|
259
|
+
if style == "table":
|
|
260
|
+
yield from _make_options_table(ctx, show_hidden)
|
|
261
|
+
else:
|
|
262
|
+
yield from _make_options_plain(ctx, show_hidden)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@contextmanager
|
|
266
|
+
def _unhide_options(ctx: click.Context) -> Iterator[None]:
|
|
267
|
+
"""Temporarily unhide all hidden options on the command."""
|
|
268
|
+
hidden_opts = [p for p in ctx.command.params if isinstance(p, click.Option) and p.hidden]
|
|
269
|
+
try:
|
|
270
|
+
for opt in hidden_opts:
|
|
271
|
+
opt.hidden = False
|
|
272
|
+
yield
|
|
273
|
+
finally:
|
|
274
|
+
for opt in hidden_opts:
|
|
275
|
+
opt.hidden = True
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _make_options_plain(ctx: click.Context, show_hidden: bool = False) -> Iterator[str]:
|
|
279
|
+
"""Yield options as a fenced preformatted block from Click's own formatter.
|
|
280
|
+
|
|
281
|
+
Help text is truncated at ``\\f`` before being passed to the formatter.
|
|
282
|
+
"""
|
|
283
|
+
ctx_mgr = _unhide_options(ctx) if show_hidden else nullcontext()
|
|
284
|
+
with ctx_mgr:
|
|
285
|
+
records = []
|
|
286
|
+
for param in ctx.command.get_params(ctx):
|
|
287
|
+
record = param.get_help_record(ctx)
|
|
288
|
+
if record is None:
|
|
289
|
+
continue
|
|
290
|
+
name, help_text = record
|
|
291
|
+
help_text = help_text.partition("\f")[0]
|
|
292
|
+
records.append((name, help_text))
|
|
293
|
+
|
|
294
|
+
if not records:
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
formatter = ctx.make_formatter()
|
|
298
|
+
with formatter.section("Options"):
|
|
299
|
+
formatter.write_dl(records)
|
|
300
|
+
|
|
301
|
+
option_lines = formatter.getvalue().splitlines()[1:] # strip "Options:" header
|
|
302
|
+
if not option_lines:
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
yield "**Options:**"
|
|
306
|
+
yield ""
|
|
307
|
+
yield "```text"
|
|
308
|
+
yield from option_lines
|
|
309
|
+
yield "```"
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _make_options_table(ctx: click.Context, show_hidden: bool = False) -> Iterator[str]:
|
|
313
|
+
"""Yield options as a Markdown table with Name, Type, and Description columns."""
|
|
314
|
+
options = [
|
|
315
|
+
p
|
|
316
|
+
for p in ctx.command.params
|
|
317
|
+
if isinstance(p, click.Option) and not _is_help_option(p) and (show_hidden or not p.hidden)
|
|
318
|
+
]
|
|
319
|
+
if not options:
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
yield "**Options:**"
|
|
323
|
+
yield ""
|
|
324
|
+
yield "| Name | Type | Description |"
|
|
325
|
+
yield "| --- | --- | --- |"
|
|
326
|
+
for opt in options:
|
|
327
|
+
name = " / ".join(opt.opts)
|
|
328
|
+
type_str = _format_param_type(opt.type)
|
|
329
|
+
help_text = (opt.help or "").partition("\f")[0].strip()
|
|
330
|
+
yield f"| {name} | {type_str} | {help_text} |"
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _is_help_option(opt: click.Option) -> bool:
|
|
334
|
+
"""Return True if *opt* is the auto-generated ``--help`` flag."""
|
|
335
|
+
return "--help" in opt.opts and opt.is_eager
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _format_param_type(param_type: click.ParamType) -> str:
|
|
339
|
+
"""Return a human-readable string for *param_type*, including constraints."""
|
|
340
|
+
if isinstance(param_type, click.Choice):
|
|
341
|
+
return f"one of: {', '.join(param_type.choices)}"
|
|
342
|
+
if isinstance(param_type, (click.IntRange, click.FloatRange)):
|
|
343
|
+
return _format_range(param_type)
|
|
344
|
+
if isinstance(param_type, click.DateTime):
|
|
345
|
+
return ", ".join(param_type.formats)
|
|
346
|
+
if isinstance(param_type, click.File):
|
|
347
|
+
return f"file ({param_type.mode})"
|
|
348
|
+
return param_type.name.upper()
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _format_range(param_type: click.IntRange | click.FloatRange) -> str:
|
|
352
|
+
"""Render an IntRange or FloatRange as a bounds expression like ``0<=x<=10``.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
param_type: The range object with ``min``, ``max``, ``min_open``, and ``max_open``.
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
A bounds string, e.g. ``0<=x``, ``x<=10``, or ``0<=x<=10``.
|
|
359
|
+
"""
|
|
360
|
+
min_op = "<" if getattr(param_type, "min_open", False) else "<="
|
|
361
|
+
max_op = "<" if getattr(param_type, "max_open", False) else "<="
|
|
362
|
+
parts = []
|
|
363
|
+
if param_type.min is not None:
|
|
364
|
+
parts.append(f"{param_type.min}{min_op}")
|
|
365
|
+
parts.append("x")
|
|
366
|
+
if param_type.max is not None:
|
|
367
|
+
parts.append(f"{max_op}{param_type.max}")
|
|
368
|
+
return "".join(parts)
|
click_docs/loader.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Load a Click command object from a file system path."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LoadError(Exception):
|
|
14
|
+
"""Raised when a Click command cannot be loaded."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_command(module_path: str, command_name: str = "cli") -> click.BaseCommand:
|
|
18
|
+
"""
|
|
19
|
+
Load a Click command from a Python file.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
module_path: File system path to the Python module (absolute or relative to cwd).
|
|
23
|
+
command_name: Dotted attribute path on the module, e.g. ``cli`` or ``commands.root``.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
The resolved Click command object.
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
LoadError: If the file does not exist, the attribute is missing, or the
|
|
30
|
+
resolved object is not a Click command.
|
|
31
|
+
"""
|
|
32
|
+
path = Path(module_path)
|
|
33
|
+
if not path.exists():
|
|
34
|
+
raise LoadError(f"Module path {str(module_path)!r} does not exist.")
|
|
35
|
+
|
|
36
|
+
module = _load_module_from_path(path)
|
|
37
|
+
obj = _resolve_dotted_attr(module, command_name, module_path)
|
|
38
|
+
|
|
39
|
+
if not isinstance(obj, click.Command):
|
|
40
|
+
raise LoadError(
|
|
41
|
+
f"Attribute {command_name!r} in {str(module_path)!r} is not a Click command (got {type(obj).__name__!r})."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return obj
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _load_module_from_path(path: Path) -> Any:
|
|
48
|
+
"""Import a module from a file path and return the module object."""
|
|
49
|
+
resolved = path.resolve()
|
|
50
|
+
package_dir = resolved.parent
|
|
51
|
+
module_name = resolved.stem
|
|
52
|
+
|
|
53
|
+
# Insert the package directory so relative imports resolve correctly.
|
|
54
|
+
if str(package_dir) not in sys.path:
|
|
55
|
+
sys.path.insert(0, str(package_dir))
|
|
56
|
+
|
|
57
|
+
spec = importlib.util.spec_from_file_location(
|
|
58
|
+
module_name,
|
|
59
|
+
resolved,
|
|
60
|
+
submodule_search_locations=[str(package_dir)],
|
|
61
|
+
)
|
|
62
|
+
if spec is None or spec.loader is None: # pragma: no cover
|
|
63
|
+
raise LoadError(f"Cannot load module from {str(path)!r}.")
|
|
64
|
+
module = importlib.util.module_from_spec(spec)
|
|
65
|
+
module.__package__ = module_name
|
|
66
|
+
sys.modules[module_name] = module
|
|
67
|
+
try:
|
|
68
|
+
spec.loader.exec_module(module) # type: ignore[union-attr]
|
|
69
|
+
except Exception as exc:
|
|
70
|
+
sys.modules.pop(module_name, None)
|
|
71
|
+
raise LoadError(f"Error importing {str(path)!r}: {exc}") from exc
|
|
72
|
+
return module
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _resolve_dotted_attr(obj: Any, dotted_path: str, source: str) -> Any:
|
|
76
|
+
"""Walk a dotted attribute path on *obj*, raising LoadError if any segment is missing."""
|
|
77
|
+
parts = dotted_path.split(".")
|
|
78
|
+
current = obj
|
|
79
|
+
for part in parts:
|
|
80
|
+
if not hasattr(current, part):
|
|
81
|
+
raise LoadError(f"{source!r} has no attribute {part!r} (in path {dotted_path!r}).")
|
|
82
|
+
current = getattr(current, part)
|
|
83
|
+
return current
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: click-docs
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Generate Markdown documentation for your Click application
|
|
5
|
+
Project-URL: Homepage, https://github.com/callowayproject/click-docs
|
|
6
|
+
Project-URL: Documentation, https://callowayproject.github.io/click_docs
|
|
7
|
+
Project-URL: Repository, https://github.com/callowayproject/click-docs
|
|
8
|
+
Project-URL: Changelog, https://github.com/callowayproject/click-docs/CHANGELOG.md
|
|
9
|
+
Author-email: Corey Oordt <coreyoordt@gmail.com>
|
|
10
|
+
License: Copyright 2026 Corey Oordt
|
|
11
|
+
|
|
12
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
13
|
+
you may not use this file except in compliance with the License.
|
|
14
|
+
You may obtain a copy of the License at
|
|
15
|
+
|
|
16
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
17
|
+
|
|
18
|
+
Unless required by applicable law or agreed to in writing, software
|
|
19
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
20
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
21
|
+
See the License for the specific language governing permissions and
|
|
22
|
+
limitations under the License.
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Keywords: Markdown,click,documentation
|
|
25
|
+
Classifier: Development Status :: 4 - Beta
|
|
26
|
+
Classifier: Environment :: Console
|
|
27
|
+
Classifier: Intended Audience :: Developers
|
|
28
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
29
|
+
Classifier: Natural Language :: English
|
|
30
|
+
Classifier: Operating System :: OS Independent
|
|
31
|
+
Classifier: Programming Language :: Python
|
|
32
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
37
|
+
Classifier: Topic :: Documentation
|
|
38
|
+
Classifier: Topic :: Software Development :: Documentation
|
|
39
|
+
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
40
|
+
Requires-Python: >=3.10
|
|
41
|
+
Requires-Dist: click>=8.1
|
|
42
|
+
Requires-Dist: markdown>=3.7
|
|
43
|
+
Requires-Dist: tomli>=2.0; python_version < '3.11'
|
|
44
|
+
Description-Content-Type: text/markdown
|
|
45
|
+
|
|
46
|
+
# click-docs
|
|
47
|
+
|
|
48
|
+
Generate Markdown documentation from your Click application — automatically, from source, with no manual editing required.
|
|
49
|
+
|
|
50
|
+
[](https://pypi.org/project/click-docs/)
|
|
51
|
+
[](https://pypi.org/project/click-docs/)
|
|
52
|
+
[](LICENSE)
|
|
53
|
+
[](https://github.com/callowayproject/click-docs/actions)
|
|
54
|
+
|
|
55
|
+
## About
|
|
56
|
+
|
|
57
|
+
Click applications document themselves — click-docs reads that information and turns it into clean Markdown. Point it at a Python file, get a complete reference page: usage lines, options tables, nested subcommands, the works.
|
|
58
|
+
|
|
59
|
+
It introspects Click objects directly and never executes your application, so there are no side effects and the output is always in sync with your source.
|
|
60
|
+
|
|
61
|
+
## Features
|
|
62
|
+
|
|
63
|
+
- **Zero-execution introspection** — reads Click objects, never runs your app
|
|
64
|
+
- **Nested command support** — recursively documents subcommands with configurable depth
|
|
65
|
+
- **Two option styles** — `plain` (preformatted text) or `table` (Markdown table)
|
|
66
|
+
- **Configurable via `pyproject.toml`** — set defaults once in `[tool.click-docs]`
|
|
67
|
+
- **Subcommand TOC** — optional bulleted table of contents at the top
|
|
68
|
+
- **Filtering** — exclude specific commands, skip hidden commands, strip ASCII art blocks
|
|
69
|
+
|
|
70
|
+
## Installation
|
|
71
|
+
|
|
72
|
+
```console
|
|
73
|
+
$ pip install click-docs
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Or with `uv`:
|
|
77
|
+
|
|
78
|
+
```console
|
|
79
|
+
$ uv add click-docs
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Requirements:** Python 3.10+, Click 8.1+
|
|
83
|
+
|
|
84
|
+
## Quick start
|
|
85
|
+
|
|
86
|
+
Given a file `deployer.py` containing a Click application:
|
|
87
|
+
|
|
88
|
+
```console
|
|
89
|
+
$ click-docs deployer.py --program-name deployer --output docs/cli-reference.md
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
That's it. `docs/cli-reference.md` now contains the full Markdown reference for every command and option.
|
|
93
|
+
|
|
94
|
+
## Common options
|
|
95
|
+
|
|
96
|
+
| Option | Description |
|
|
97
|
+
|------------------------|-----------------------------------------------|
|
|
98
|
+
| `--program-name TEXT` | Display name in headings and usage lines |
|
|
99
|
+
| `--output FILE` | Write to file instead of stdout |
|
|
100
|
+
| `--style plain\|table` | Options rendering style |
|
|
101
|
+
| `--depth N` | Max subcommand depth (0 = root only) |
|
|
102
|
+
| `--exclude PATH` | Exclude a command by dotted path (repeatable) |
|
|
103
|
+
| `--list-subcommands` | Prepend a TOC of subcommands |
|
|
104
|
+
| `--remove-ascii-art` | Strip `\b`-prefixed ASCII art blocks |
|
|
105
|
+
|
|
106
|
+
Run `click-docs --help` for the full list.
|
|
107
|
+
|
|
108
|
+
## Configure defaults in pyproject.toml
|
|
109
|
+
|
|
110
|
+
Set project-wide defaults so you don't repeat flags on every run:
|
|
111
|
+
|
|
112
|
+
```toml
|
|
113
|
+
[tool.click-docs]
|
|
114
|
+
program-name = "my-tool"
|
|
115
|
+
style = "table"
|
|
116
|
+
list-subcommands = true
|
|
117
|
+
remove-ascii-art = true
|
|
118
|
+
output = "docs/cli-reference.md"
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Documentation
|
|
122
|
+
|
|
123
|
+
Full documentation, tutorials, and API reference: [callowayproject.github.io/click_docs](https://callowayproject.github.io/click_docs)
|
|
124
|
+
|
|
125
|
+
## Contributing
|
|
126
|
+
|
|
127
|
+
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
|
128
|
+
|
|
129
|
+
### Development setup
|
|
130
|
+
|
|
131
|
+
```console
|
|
132
|
+
$ git clone https://github.com/callowayproject/click-docs.git
|
|
133
|
+
$ cd click-docs
|
|
134
|
+
$ uv sync
|
|
135
|
+
$ uv run pytest
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
click-docs is licensed under the Apache 2.0 license. See the [`LICENSE`](LICENSE) file for details.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
click_docs/__init__.py,sha256=MGLxrTAnHZo01cKz0t6Cjc-o6BxZlsw_om3N2YrM3xc,63
|
|
2
|
+
click_docs/cli.py,sha256=o962sw5yWiQKztGaPYAX6AdQxi4U_VIPRJKUpuqm5dM,5521
|
|
3
|
+
click_docs/config.py,sha256=jtD9cDQqqNbmRsCEPEM0f9lJbH0xVKwbjblLYXo-jhQ,1668
|
|
4
|
+
click_docs/generator.py,sha256=fUzFXxKgKFXhkVjijOj4Pi7xijmK1fEwOqQB-uHzwqo,12399
|
|
5
|
+
click_docs/loader.py,sha256=dgjaPOdQdSNFwD-gViKovjDxIexFmfwoG_wAeQ5pDeY,2746
|
|
6
|
+
click_docs-0.2.0.dist-info/METADATA,sha256=UHgmR6Akh7PQAwfR9AFbMC6r6mQTHnQul37xePPIZ-k,5516
|
|
7
|
+
click_docs-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
8
|
+
click_docs-0.2.0.dist-info/entry_points.txt,sha256=M1mdrvlvFX9mnytUDqpLRHI15ul-7GElRKt9cqmmuWs,50
|
|
9
|
+
click_docs-0.2.0.dist-info/licenses/LICENSE,sha256=aHLo2QQ-TxK4ntWM6fKSlGu2LQFCxg3wEDYSLMqOvhc,548
|
|
10
|
+
click_docs-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Copyright 2026 Corey Oordt
|
|
2
|
+
|
|
3
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
you may not use this file except in compliance with the License.
|
|
5
|
+
You may obtain a copy of the License at
|
|
6
|
+
|
|
7
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
|
|
9
|
+
Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
See the License for the specific language governing permissions and
|
|
13
|
+
limitations under the License.
|