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 ADDED
@@ -0,0 +1,3 @@
1
+ """Top-level package for click_docs."""
2
+
3
+ __version__ = "0.2.0"
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", {})
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/click-docs)](https://pypi.org/project/click-docs/)
51
+ [![Python versions](https://img.shields.io/pypi/pyversions/click-docs)](https://pypi.org/project/click-docs/)
52
+ [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
53
+ [![CI](https://img.shields.io/github/actions/workflow/status/callowayproject/click-docs/test.yaml)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ click-docs = click_docs.cli:cli
@@ -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.