Skip to content
Merged
15 changes: 15 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,21 @@ _Notes on upcoming releases will be added here_

<!-- Maintainers, insert changes / features for the next release here -->

### Documentation

#### CLI reference pages now have linkable arguments (#502)

Each command-line argument (`--help`, `-v`, `--config`, etc.) in the CLI
reference documentation now has a permalink anchor (¶). You can hover over any
argument to reveal the link, then share direct URLs to specific options.

- Improved metadata display: Default values, types, and "Required" tags are now
styled more clearly
- Help text containing glob patterns like `django-*` no longer triggers
documentation build warnings
- Multiple CLI commands can now appear on the same documentation page without
ID conflicts

### Bug fixes

#### Improve downstream compatibility in test matrix (#504)
Expand Down
31 changes: 29 additions & 2 deletions docs/_ext/argparse_exemplar.py
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,33 @@ def _create_example_section(
-------
nodes.section
A section node with title and code blocks.

Examples
--------
Create a section from a definition node containing example commands:

>>> from docutils import nodes
>>> def_node = nodes.definition()
>>> def_node += nodes.paragraph(text="myapp sync")
>>> section = _create_example_section("examples:", def_node)
>>> section["ids"]
['examples']
>>> section[0].astext()
'Examples'

With a page prefix for uniqueness across documentation pages:

>>> section = _create_example_section("examples:", def_node, page_prefix="sync")
>>> section["ids"]
['sync-examples']

Category-prefixed examples create descriptive section IDs:

>>> section = _create_example_section("Machine-readable output examples:", def_node)
>>> section["ids"]
['machine-readable-output-examples']
>>> section[0].astext()
'Machine-Readable Output Examples'
"""
config = config or ExemplarConfig()
section_id = make_section_id(
Expand Down Expand Up @@ -823,7 +850,7 @@ def process_node(
else:
new_children.append(child)
if children_changed:
node.children = new_children
node[:] = new_children # type: ignore[index]

return node

Expand Down Expand Up @@ -1133,7 +1160,7 @@ def _extract_sections_from_container(
remaining_children.append(child)

# Update container with remaining children only
container.children = remaining_children
container[:] = remaining_children # type: ignore[index]

return container, extracted_sections

Expand Down
18 changes: 9 additions & 9 deletions docs/_ext/argparse_lexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,14 @@ class ArgparseUsageLexer(RegexLexer):
(r"\.\.\.", Punctuation),
# Long options with = value (e.g., --log-level=VALUE)
(
r"(--[a-zA-Z0-9][-a-zA-Z0-9]*)(=)([A-Z][A-Z0-9_]*|[a-z][-a-z0-9]*)",
r"(--[a-zA-Z0-9][-a-zA-Z0-9]*)(=)([A-Z][A-Z0-9_]*|[a-z][-a-z0-9_]*)",
bygroups(Name.Tag, Operator, Name.Variable), # type: ignore[no-untyped-call]
),
# Long options standalone
(r"--[a-zA-Z0-9][-a-zA-Z0-9]*", Name.Tag),
# Short options with space-separated value (e.g., -S socket-path)
(
r"(-[a-zA-Z0-9])(\s+)([A-Z][A-Z0-9_]*|[a-z][-a-z0-9]*)",
r"(-[a-zA-Z0-9])(\s+)([A-Z][A-Z0-9_]*|[a-z][-a-z0-9_]*)",
bygroups(Name.Attribute, Whitespace, Name.Variable), # type: ignore[no-untyped-call]
),
# Short options standalone
Expand All @@ -94,7 +94,7 @@ class ArgparseUsageLexer(RegexLexer):
# UPPERCASE meta-variables (COMMAND, FILE, PATH)
(r"\b[A-Z][A-Z0-9_]*\b", Name.Variable),
# Subcommand/positional names (Name.Function for distinct styling)
(r"\b[a-z][-a-z0-9]*\b", Name.Function),
(r"\b[a-z][-a-z0-9_]*\b", Name.Function),
# Catch-all for any other text
(r"[^\s\[\]|(){},]+", Text),
],
Expand All @@ -105,14 +105,14 @@ class ArgparseUsageLexer(RegexLexer):
(r"\.\.\.", Punctuation),
# Long options with = value (e.g., --log-level=VALUE)
(
r"(--[a-zA-Z0-9][-a-zA-Z0-9]*)(=)([A-Z][A-Z0-9_]*|[a-z][-a-z0-9]*)",
r"(--[a-zA-Z0-9][-a-zA-Z0-9]*)(=)([A-Z][A-Z0-9_]*|[a-z][-a-z0-9_]*)",
bygroups(Name.Tag, Operator, Name.Variable), # type: ignore[no-untyped-call]
),
# Long options standalone
(r"--[a-zA-Z0-9][-a-zA-Z0-9]*", Name.Tag),
# Short options with space-separated value (e.g., -S socket-path)
(
r"(-[a-zA-Z0-9])(\s+)([A-Z][A-Z0-9_]*|[a-z][-a-z0-9]*)",
r"(-[a-zA-Z0-9])(\s+)([A-Z][A-Z0-9_]*|[a-z][-a-z0-9_]*)",
bygroups(Name.Attribute, Whitespace, Name.Variable), # type: ignore[no-untyped-call]
),
# Short options standalone
Expand All @@ -132,7 +132,7 @@ class ArgparseUsageLexer(RegexLexer):
# UPPERCASE meta-variables (COMMAND, FILE, PATH)
(r"\b[A-Z][A-Z0-9_]*\b", Name.Variable),
# Positional/command names (lowercase with dashes)
(r"\b[a-z][-a-z0-9]*\b", Name.Label),
(r"\b[a-z][-a-z0-9_]*\b", Name.Label),
# Catch-all for any other text
(r"[^\s\[\]|(){},]+", Text),
],
Expand Down Expand Up @@ -234,14 +234,14 @@ class ArgparseHelpLexer(RegexLexer):
(r"\.\.\.", Punctuation),
# Long options with = value
(
r"(--[a-zA-Z0-9][-a-zA-Z0-9]*)(=)([A-Z][A-Z0-9_]*|[a-z][-a-z0-9]*)",
r"(--[a-zA-Z0-9][-a-zA-Z0-9]*)(=)([A-Z][A-Z0-9_]*|[a-z][-a-z0-9_]*)",
bygroups(Name.Tag, Operator, Name.Variable), # type: ignore[no-untyped-call]
),
# Long options standalone
(r"--[a-zA-Z0-9][-a-zA-Z0-9]*", Name.Tag),
# Short options with value
(
r"(-[a-zA-Z0-9])(\s+)([A-Z][A-Z0-9_]*|[a-z][-a-z0-9]*)",
r"(-[a-zA-Z0-9])(\s+)([A-Z][A-Z0-9_]*|[a-z][-a-z0-9_]*)",
bygroups(Name.Attribute, Whitespace, Name.Variable), # type: ignore[no-untyped-call]
),
# Short options standalone
Expand All @@ -259,7 +259,7 @@ class ArgparseHelpLexer(RegexLexer):
# UPPERCASE metavars
(r"\b[A-Z][A-Z0-9_]*\b", Name.Variable),
# Subcommand/positional names (Name.Function for distinct styling)
(r"\b[a-z][-a-z0-9]*\b", Name.Function),
(r"\b[a-z][-a-z0-9_]*\b", Name.Function),
# Other text
(r"[^\s\[\]|(){},\n]+", Text),
],
Expand Down
32 changes: 26 additions & 6 deletions docs/_ext/sphinx_argparse_neo/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,13 +191,24 @@ def get_parser_from_module(

Examples
--------
Load vcspull's parser factory:
Load a parser from a module with a factory function:

>>> parser = get_parser_from_module("vcspull.cli", "create_parser")
>>> import argparse
>>> import sys
>>> # Create a test module with a parser factory
>>> import types
>>> test_mod = types.ModuleType("_test_parser_mod")
>>> def _create_parser():
... p = argparse.ArgumentParser(prog="test")
... return p
>>> test_mod.create_parser = _create_parser
>>> sys.modules["_test_parser_mod"] = test_mod
>>> parser = get_parser_from_module("_test_parser_mod", "create_parser")
>>> parser.prog
'vcspull'
'test'
>>> hasattr(parser, 'parse_args')
True
>>> del sys.modules["_test_parser_mod"]
"""
ctx = mock_imports(mock_modules) if mock_modules else contextlib.nullcontext()

Expand Down Expand Up @@ -250,11 +261,20 @@ def get_parser_from_entry_point(

Examples
--------
Load vcspull's parser using entry point syntax:
Load a parser using entry point syntax:

>>> parser = get_parser_from_entry_point("vcspull.cli:create_parser")
>>> import argparse
>>> import sys
>>> import types
>>> test_mod = types.ModuleType("_test_ep_mod")
>>> def _create_parser():
... return argparse.ArgumentParser(prog="test-ep")
>>> test_mod.create_parser = _create_parser
>>> sys.modules["_test_ep_mod"] = test_mod
>>> parser = get_parser_from_entry_point("_test_ep_mod:create_parser")
>>> parser.prog
'vcspull'
'test-ep'
>>> del sys.modules["_test_ep_mod"]

Invalid format raises ValueError:

Expand Down
123 changes: 103 additions & 20 deletions docs/_ext/sphinx_argparse_neo/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,49 @@
sys.path.insert(0, str(_ext_dir))

from argparse_lexer import ArgparseUsageLexer # noqa: E402
from sphinx_argparse_neo.utils import strip_ansi # noqa: E402


def _generate_argument_id(names: list[str], id_prefix: str = "") -> str:
"""Generate unique ID for an argument based on its names.

Creates a slug-style ID suitable for HTML anchors by:
1. Stripping leading dashes from option names
2. Joining multiple names with hyphens
3. Prepending optional prefix for namespace isolation

Parameters
----------
names : list[str]
List of argument names (e.g., ["-L", "--socket-name"]).
id_prefix : str
Optional prefix for uniqueness (e.g., "shell" -> "shell-L-socket-name").

Returns
-------
str
A slug-style ID suitable for HTML anchors.

Examples
--------
>>> _generate_argument_id(["-L"])
'L'
>>> _generate_argument_id(["--help"])
'help'
>>> _generate_argument_id(["-v", "--verbose"])
'v-verbose'
>>> _generate_argument_id(["-L"], "shell")
'shell-L'
>>> _generate_argument_id(["filename"])
'filename'
>>> _generate_argument_id([])
''
"""
clean_names = [name.lstrip("-") for name in names if name.lstrip("-")]
if not clean_names:
return ""
name_part = "-".join(clean_names)
return f"{id_prefix}-{name_part}" if id_prefix else name_part


def _token_to_css_class(token_type: t.Any) -> str:
Expand Down Expand Up @@ -347,7 +390,7 @@ def visit_argparse_usage_html(self: HTML5Translator, node: argparse_usage) -> No
node : argparse_usage
The usage node being visited.
"""
usage = node.get("usage", "")
usage = strip_ansi(node.get("usage", ""))
# Add both argparse-usage class and highlight class for CSS targeting
self.body.append('<pre class="argparse-usage highlight-argparse-usage">')
# Prepend "usage: " and highlight the full usage string
Expand Down Expand Up @@ -418,6 +461,9 @@ def visit_argparse_argument_html(
- Positional arguments get class 'nl' (Name.Label)
- Metavars get class 'nv' (Name.Variable)

The argument is wrapped in a container div with a unique ID for linking.
A headerlink anchor (¶) is added for direct navigation.

Parameters
----------
self : HTML5Translator
Expand All @@ -427,11 +473,28 @@ def visit_argparse_argument_html(
"""
names: list[str] = node.get("names", [])
metavar = node.get("metavar")
id_prefix: str = node.get("id_prefix", "")

# Generate unique ID for this argument
arg_id = _generate_argument_id(names, id_prefix)

# Open wrapper div with ID for linking
if arg_id:
self.body.append(f'<div class="argparse-argument-wrapper" id="{arg_id}">\n')
else:
self.body.append('<div class="argparse-argument-wrapper">\n')

# Build the argument signature with syntax highlighting
highlighted_sig = _highlight_argument_names(names, metavar, self.encode)

self.body.append(f'<dt class="argparse-argument-name">{highlighted_sig}</dt>\n')
# Add headerlink anchor inside dt for navigation
headerlink = ""
if arg_id:
headerlink = f'<a class="headerlink" href="#{arg_id}">¶</a>'

self.body.append(
f'<dt class="argparse-argument-name">{highlighted_sig}{headerlink}</dt>\n'
)
self.body.append('<dd class="argparse-argument-help">')

# Add help text
Expand All @@ -446,6 +509,7 @@ def depart_argparse_argument_html(
"""Depart argparse_argument node - close argument entry.

Adds default, choices, and type information if present.
Default values are wrapped in ``<span class="nv">`` for styled display.

Parameters
----------
Expand All @@ -454,31 +518,50 @@ def depart_argparse_argument_html(
node : argparse_argument
The argument node being departed.
"""
# Add metadata (default, choices, type)
metadata: list[str] = []

# Build metadata as definition list items
default = node.get("default_string")
if default is not None:
metadata.append(f"Default: {self.encode(default)}")

choices = node.get("choices")
if choices:
choices_str = ", ".join(str(c) for c in choices)
metadata.append(f"Choices: {self.encode(choices_str)}")

type_name = node.get("type_name")
if type_name:
metadata.append(f"Type: {self.encode(type_name)}")

required = node.get("required", False)
if required:
metadata.append("Required")

if metadata:
meta_str = " | ".join(metadata)
self.body.append(f'<p class="argparse-argument-meta">{meta_str}</p>')
if default is not None or choices or type_name or required:
self.body.append('<dl class="argparse-argument-meta">\n')

if default is not None:
self.body.append('<div class="argparse-meta-item">')
self.body.append('<dt class="argparse-meta-key">Default</dt>')
self.body.append(
f'<dd class="argparse-meta-value">'
f'<span class="nv">{self.encode(default)}</span></dd>'
)
self.body.append("</div>\n")

if type_name:
self.body.append('<div class="argparse-meta-item">')
self.body.append('<dt class="argparse-meta-key">Type</dt>')
self.body.append(
f'<dd class="argparse-meta-value">'
f'<span class="nv">{self.encode(type_name)}</span></dd>'
)
self.body.append("</div>\n")

if choices:
choices_str = ", ".join(str(c) for c in choices)
self.body.append('<div class="argparse-meta-item">')
self.body.append('<dt class="argparse-meta-key">Choices</dt>')
self.body.append(
f'<dd class="argparse-meta-value">{self.encode(choices_str)}</dd>'
)
self.body.append("</div>\n")

if required:
self.body.append('<dt class="argparse-meta-tag">Required</dt>\n')

self.body.append("</dl>\n")

self.body.append("</dd>\n")
# Close wrapper div
self.body.append("</div>\n")


def visit_argparse_subcommands_html(
Expand Down
Loading