Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

`click-docs` is a Python library that generates Markdown documentation from Click CLI applications. It introspects Click command objects and produces formatted Markdown with usage info, options, and nested command hierarchies.

## Commands

This project uses `uv` for package management.

Add `--agent-digest=terminal` to `uv run pytest` commands to optimize test output.

```bash
# Install all dependency groups
uv sync --all-groups

# Run all tests
uv run pytest --agent-digest=terminal

# Run a single test file
uv run pytest --agent-digest=terminal tests/test_core/test_indented_logger.py

# Run a single test by name
uv run pytest --agent-digest=terminal tests/test_core/test_indented_logger.py::test_function_name

# Lint (check only)
ruff check .

# Format check
ruff format --check .

# Auto-fix lint and format
ruff check --fix . && ruff format .

# Type checking
mypy click_docs

# Install pre-commit hooks (one-time setup)
pre-commit install

# Run pre-commit on all files
pre-commit run --all-files
```

## Architecture

The project has three core modules:

**`click_docs/loader.py`** — Dynamic module loading. Takes a filesystem path to a Python file, imports it as a module, and resolves a dotted attribute path to find the Click command object.

**`click_docs/generator.py`** — Core documentation generation. `generate_docs()` is the main entry point. It recursively traverses Click command groups, rendering each command's usage, description, and options to Markdown. Supports two rendering styles (`plain` / `table`), configurable header depth, command exclusions, hidden command filtering, and ASCII art (`\b` block) removal.

**`click_docs/cli.py`** — CLI interface. Wires together loader and generator, exposing all generator options as Click flags.

## Data Flow

```
click-docs <module_path> [options]
→ cli.py parses options
→ loader.py imports the module and resolves the Click command object
→ generator.py recursively generates Markdown
→ stdout or --output file
```

## Testing

Tests live in `tests/`. The fixture Click application used across tests is `tests/app/cli.py` — it contains nested groups, hidden commands/options, special parameter types, and ASCII art blocks. When adding new generator features, add corresponding fixtures there.

CI runs on Python 3.12 and 3.13 via GitHub Actions (`.github/workflows/test.yaml`), triggered on changes to `click_docs/*` or `tests/*`.

## Code Style

- Docstrings: Google style (enforced by `pydoclint`, coverage ≥90% via `interrogate`)
- Type annotations: Required on all functions (enforced by ruff `ANN` rules)
- Ruff preview mode is enabled with strict rules — run `ruff check` before committing
2 changes: 1 addition & 1 deletion click_docs/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from pathlib import Path
from typing import Any

import click
import rich_click as click
from click.core import ParameterSource

from .config import find_config
Expand Down
18 changes: 16 additions & 2 deletions click_docs/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@

import click

_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*[mK]")


def _strip_ansi(text: str) -> str:
"""Strip ANSI terminal escape codes from *text*.

Args:
text: The string potentially containing ANSI escape sequences.

Returns:
The string with all ANSI escape sequences removed.
"""
return _ANSI_ESCAPE_RE.sub("", text)


def generate_docs(
command: click.BaseCommand,
Expand Down Expand Up @@ -244,7 +258,7 @@ def _make_usage(ctx: click.Context) -> Iterator[str]:
formatter = ctx.make_formatter()
pieces = ctx.command.collect_usage_pieces(ctx)
formatter.write_usage(ctx.command_path, " ".join(pieces), prefix="")
usage = formatter.getvalue().strip()
usage = _strip_ansi(formatter.getvalue()).strip()

yield "**Usage:**"
yield ""
Expand Down Expand Up @@ -298,7 +312,7 @@ def _make_options_plain(ctx: click.Context, show_hidden: bool = False) -> Iterat
with formatter.section("Options"):
formatter.write_dl(records)

option_lines = formatter.getvalue().splitlines()[1:] # strip "Options:" header
option_lines = _strip_ansi(formatter.getvalue()).splitlines()[1:] # strip "Options:" header
if not option_lines:
return

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ requires-python = ">=3.10"
dependencies = [
"click >=8.1",
"markdown>=3.7",
"rich-click>=1.8.4",
"tomli>=2.0; python_version < '3.11'",
]
authors = [
Expand Down
36 changes: 35 additions & 1 deletion tests/test_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@

import click
import pytest
import rich_click as rclick

from click_docs.generator import generate_docs
from click_docs.generator import _strip_ansi, generate_docs

EXPECTED_DIR = Path(__file__).parent / "app"

Expand Down Expand Up @@ -408,3 +409,36 @@ def test_snapshot_remove_ascii_art(self):
expected = (EXPECTED_DIR / "expected_ascii_art_removed.md").read_text()
result = generate_docs(_ascii_art, program_name="ascii-art", remove_ascii_art=True)
assert result == expected


# ---------------------------------------------------------------------------
# ANSI stripping (rich-click compatibility)
# ---------------------------------------------------------------------------


@rclick.command()
@rclick.option("--name", default="World", help="Who to greet.")
def _rich_hello(name: str) -> None:
"""A rich-click command for ANSI stripping tests."""


def test_strip_ansi_removes_escape_codes() -> None:
assert _strip_ansi("\x1b[1mhello\x1b[0m") == "hello"


def test_strip_ansi_leaves_plain_text_unchanged() -> None:
assert _strip_ansi("hello [OPTIONS]") == "hello [OPTIONS]"


class TestAnsiStripping:
def test_usage_contains_no_ansi_escapes(self) -> None:
result = generate_docs(_rich_hello, program_name="rich-hello")
assert "\x1b[" not in result

def test_options_contain_no_ansi_escapes(self) -> None:
result = generate_docs(_rich_hello, program_name="rich-hello", style="plain")
assert "\x1b[" not in result

def test_usage_plain_text_format(self) -> None:
result = generate_docs(_rich_hello, program_name="rich-hello")
assert "rich-hello [OPTIONS]" in result
Loading
Loading