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
Binary file modified .coverage
Binary file not shown.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ wheels/

# Virtual environments
.venv
uv.lock
2 changes: 2 additions & 0 deletions mkdocs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ theme:
- search.highlight
- search.suggest
- toc.follow
watch:
- src/

nav:
- Home: index.md
Expand Down
8 changes: 5 additions & 3 deletions src/mkdocs_typer2/cli.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import typer

from typing import Annotated

app = typer.Typer()
import typer

app = typer.Typer(help="A sample CLI")


@app.command()
def docs(name: str = typer.Option(..., help="The name of the project")):
"""Generate docs for a project"""
print(f"Generating docs for {name}")


Expand All @@ -20,6 +21,7 @@ def hello(
str, typer.Option("--color", help="The color of the output")
] = None,
):
"""Some docstring content"""
_str = ""
if caps:
_str = f"Hello {name.capitalize()}"
Expand Down
85 changes: 78 additions & 7 deletions src/mkdocs_typer2/pretty.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import re
from pydantic import BaseModel
from typing import List, Optional

from pydantic import BaseModel


class Option(BaseModel):
name: str
Expand All @@ -28,33 +29,101 @@

def parse_markdown_to_tree(content: str) -> CommandNode:
lines = content.split("\n")
root = None

# Find the root command name (# heading)
for i, line in enumerate(lines):
if line.startswith("# "):
root = CommandNode(name=lines[i].replace("# ", ""))
root = CommandNode(name=line.replace("# ", "").replace("`", ""))

# Capture description for root command
desc_lines = []
j = i + 1

# Skip empty lines
while j < len(lines) and not lines[j].strip():
j += 1

# Collect description lines until we hit a section marker or subcommand
while (
j < len(lines)
and not lines[j].startswith("#")
and not lines[j].startswith("**")
and not lines[j].startswith("```")
):
if lines[j].strip(): # Only add non-empty lines
desc_lines.append(lines[j].strip())
j += 1

if desc_lines:
root.description = "\n".join(desc_lines)

break

if not root:
# If no root command was found, create a default one
root = CommandNode(name="Unknown Command")

Check warning on line 65 in src/mkdocs_typer2/pretty.py

View check run for this annotation

Codecov / codecov/patch

src/mkdocs_typer2/pretty.py#L65

Added line #L65 was not covered by tests

current_command = root
current_section = None
in_description = False
desc_lines = []

i = 0
while i < len(lines):
line = lines[i]

for line in lines[1:]:
if line.startswith("## `"):
# New subcommand
cmd_name = line.replace("## `", "").replace("`", "")
cmd_name = line.replace("##", "")
new_cmd = CommandNode(name=cmd_name)
root.subcommands.append(new_cmd)
current_command = new_cmd
in_description = True # Start capturing description for this command
desc_lines = []

elif (
in_description
and not line.startswith("**")
and not line.startswith("```")
and i > 0
):
# We're in description mode and not at a section marker yet
if line.strip(): # Only add non-empty lines
desc_lines.append(line.strip())

elif line.startswith("```console"):
# Usage section
usage_line = next(lines[i] for i, line in enumerate(lines) if "$ " in line)
current_command.usage = usage_line.replace("$ ", "")
# Usage section - end of description
if in_description and desc_lines:
current_command.description = "\n".join(desc_lines)
in_description = False
# Find the line with usage example
j = i
while j < len(lines) and "$ " not in lines[j]:
j += 1
if j < len(lines):
current_command.usage = lines[j].replace("$ ", "")

elif line.startswith("**Arguments**:"):
# End of description section
if in_description and desc_lines:
current_command.description = "\n".join(desc_lines)

Check warning on line 110 in src/mkdocs_typer2/pretty.py

View check run for this annotation

Codecov / codecov/patch

src/mkdocs_typer2/pretty.py#L110

Added line #L110 was not covered by tests
in_description = False
current_section = "arguments"

elif line.startswith("**Options**:"):
# End of description section
if in_description and desc_lines:
current_command.description = "\n".join(desc_lines)

Check warning on line 117 in src/mkdocs_typer2/pretty.py

View check run for this annotation

Codecov / codecov/patch

src/mkdocs_typer2/pretty.py#L117

Added line #L117 was not covered by tests
in_description = False
current_section = "options"

elif line.startswith("**Commands**:"):
# End of description section
if in_description and desc_lines:
current_command.description = "\n".join(desc_lines)

Check warning on line 124 in src/mkdocs_typer2/pretty.py

View check run for this annotation

Codecov / codecov/patch

src/mkdocs_typer2/pretty.py#L124

Added line #L124 was not covered by tests
in_description = False

elif line.startswith("* `") and current_section == "options":
# Parse option
match = re.match(r"\* `(.*?)`: (.*?)(?:\s+\[(\w+)\])?$", line)
Expand All @@ -78,6 +147,8 @@
)
current_command.arguments.append(argument)

i += 1

return root


Expand Down
124 changes: 123 additions & 1 deletion tests/test_pretty.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import pytest

from mkdocs_typer2.pretty import (
Argument,
CommandNode,
Option,
Argument,
parse_markdown_to_tree,
tree_to_markdown,
)
Expand Down Expand Up @@ -123,3 +124,124 @@ def test_parse_usage_section(usage_text):
result = parse_markdown_to_tree(markdown)
assert result.usage is not None
assert result.usage.startswith("mycli")


def test_parse_markdown_with_descriptions():
"""Test that descriptions are properly parsed from the markdown."""
markdown = """
# mycli

A test CLI tool with description

## `command`

Command description spans
multiple lines with details

```console
$ mycli command
```

**Arguments**:
* `name`: The name argument [required]

**Options**:
* `--verbose`: Enable verbose mode
"""
tree = parse_markdown_to_tree(markdown)

# Check root command description
assert tree.name == "mycli"
assert "A test CLI tool with description" in tree.description

# Check subcommand description
assert len(tree.subcommands) == 1
subcommand = tree.subcommands[0]
assert subcommand.name == " `command`"
assert "Command description spans" in subcommand.description
assert "multiple lines with details" in subcommand.description


def test_parse_typer_generated_docs():
"""Test parsing the actual format generated by the Typer CLI tool."""
markdown = """
# `typer`

A sample CLI

**Usage**:

```console
$ typer [OPTIONS] COMMAND [ARGS]...
```

**Options**:

* `--install-completion`: Install completion for the current shell.
* `--show-completion`: Show completion for the current shell, to copy it or customize the installation.
* `--help`: Show this message and exit.

**Commands**:

* `docs`: Generate docs for a project
* `hello`: Some docstring content

## `typer docs`

Generate docs for a project

**Usage**:

```console
$ typer docs [OPTIONS]
```

**Options**:

* `--name TEXT`: The name of the project [required]
* `--help`: Show this message and exit.

## `typer hello`

Some docstring content

**Usage**:

```console
$ typer hello [OPTIONS] NAME
```

**Arguments**:

* `NAME`: The name of the person to greet [required]

**Options**:

* `--caps / --no-caps`: Whether to capitalize the name [default: no-caps]
* `--color TEXT`: The color of the output
* `--help`: Show this message and exit.
"""
tree = parse_markdown_to_tree(markdown)

# Check root command
assert tree.name == "typer"
assert "A sample CLI" in tree.description

# Check subcommands
assert len(tree.subcommands) == 2

# Check docs subcommand
docs_cmd = next(cmd for cmd in tree.subcommands if "typer docs" in cmd.name)
assert "Generate docs for a project" in docs_cmd.description
assert docs_cmd.usage == "typer docs [OPTIONS]"
assert len(docs_cmd.options) > 0
assert any(opt.name == "--name TEXT" for opt in docs_cmd.options)

# Check hello subcommand
hello_cmd = next(cmd for cmd in tree.subcommands if "typer hello" in cmd.name)
assert "Some docstring content" in hello_cmd.description
assert hello_cmd.usage == "typer hello [OPTIONS] NAME"
assert len(hello_cmd.arguments) > 0
assert len(hello_cmd.options) > 0
assert any(arg.name == "NAME" for arg in hello_cmd.arguments)
assert any(opt.name == "--caps / --no-caps" for opt in hello_cmd.options)
Loading