Skip to content

Commit 017a2e8

Browse files
committed
Add ":hide-header:", ":nested: complete"
Adding :hide-header: to the directive will hide the title, description and usage and only show the commands. This also means that the commands will not be under a single section, but rather add a section each to where the directive is located. The ":nested: complete" option shows the name, description, usage, short command overview (basically the entire ":nested: short" option) followed by the details of the commands from the ":nested: full" option.
1 parent 1c15fe1 commit 017a2e8

File tree

3 files changed

+223
-58
lines changed

3 files changed

+223
-58
lines changed

sphinx_click/ext.py

Lines changed: 118 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import inspect
1+
import collections.abc
22
import functools
3+
import inspect
4+
import itertools
35
import re
46
import traceback
57
import typing as ty
@@ -21,10 +23,11 @@
2123

2224
LOG = logging.getLogger(__name__)
2325

26+
NESTED_COMPLETE = 'complete'
2427
NESTED_FULL = 'full'
2528
NESTED_SHORT = 'short'
2629
NESTED_NONE = 'none'
27-
NestedT = ty.Literal['full', 'short', 'none', None]
30+
NestedT = ty.Literal['complete', 'full', 'short', 'none', None]
2831

2932
ANSI_ESC_SEQ_RE = re.compile(r'\x1B\[\d+(;\d+){0,2}m', flags=re.MULTILINE)
3033

@@ -263,7 +266,7 @@ def _format_arguments(ctx: click.Context) -> ty.Generator[str, None, None]:
263266

264267
def _format_envvar(
265268
ctx: click.Context,
266-
param: ty.Union[click.core.Option, click.Argument],
269+
param: click.core.Parameter,
267270
) -> ty.Generator[str, None, None]:
268271
"""Format the envvars of a `click.Option` or `click.Argument`."""
269272
command_name = _format_command_name(ctx)
@@ -367,21 +370,52 @@ def _filter_commands(
367370
return [lookup[command] for command in commands if command in lookup]
368371

369372

373+
def _format_header(ctx: click.Context) -> ty.Generator[str, None, None]:
374+
for line in _format_description(ctx):
375+
yield line
376+
377+
yield '.. _{command_name}:'.format(
378+
command_name=_format_command_name(ctx),
379+
)
380+
yield ''
381+
yield '.. program:: {}'.format(ctx.command_path)
382+
383+
384+
def _format_subcommand_summary(
385+
ctx: click.Context,
386+
commands: ty.Optional[ty.List[str]] = None,
387+
) -> ty.Generator[str, None, None]:
388+
command_objs = _filter_commands(ctx, commands)
389+
390+
if command_objs:
391+
yield '.. rubric:: Commands'
392+
yield ''
393+
394+
for command_obj in command_objs:
395+
# Don't show hidden subcommands
396+
if command_obj.hidden:
397+
continue
398+
399+
for line in _format_subcommand(command_obj):
400+
yield line
401+
yield ''
402+
403+
370404
def _format_command(
371405
ctx: click.Context,
372406
nested: NestedT,
373407
commands: ty.Optional[ty.List[str]] = None,
408+
hide_header: bool = False,
374409
) -> ty.Generator[str, None, None]:
375410
"""Format the output of `click.Command`."""
376411
if ctx.command.hidden:
377412
return None
378413

379414
# description
380415

381-
for line in _format_description(ctx):
382-
yield line
383-
384-
yield '.. program:: {}'.format(ctx.command_path)
416+
if nested == NESTED_NONE or not hide_header:
417+
for line in _format_header(ctx):
418+
yield line
385419

386420
# usage
387421

@@ -429,24 +463,34 @@ def _format_command(
429463
if nested in (NESTED_FULL, NESTED_NONE):
430464
return
431465

432-
command_objs = _filter_commands(ctx, commands)
466+
for line in _format_subcommand_summary(ctx, commands):
467+
yield line
433468

434-
if command_objs:
435-
yield '.. rubric:: Commands'
436-
yield ''
437469

438-
for command_obj in command_objs:
439-
# Don't show hidden subcommands
440-
if command_obj.hidden:
441-
continue
470+
def _format_summary(
471+
ctx: click.Context,
472+
commands: ty.Optional[ty.List[str]] = None,
473+
hide_header: bool = False,
474+
) -> ty.Generator[str, None, None]:
475+
"""Format the output of `click.Command`."""
476+
if ctx.command.hidden:
477+
return
442478

443-
for line in _format_subcommand(command_obj):
479+
if not hide_header:
480+
# description
481+
for line in _format_header(ctx):
444482
yield line
445-
yield ''
483+
484+
# usage
485+
for line in _format_usage(ctx):
486+
yield line
487+
488+
for line in _format_subcommand_summary(ctx, commands):
489+
yield line
446490

447491

448492
def nested(argument: ty.Optional[str]) -> NestedT:
449-
values = (NESTED_FULL, NESTED_SHORT, NESTED_NONE, None)
493+
values = (NESTED_COMPLETE, NESTED_FULL, NESTED_SHORT, NESTED_NONE, None)
450494

451495
if argument not in values:
452496
raise ValueError(
@@ -465,6 +509,7 @@ class ClickDirective(rst.Directive):
465509
'nested': nested,
466510
'commands': directives.unchanged,
467511
'show-nested': directives.flag,
512+
'hide-header': directives.flag,
468513
}
469514

470515
def _load_module(self, module_path: str) -> ty.Union[click.Command, click.Group]:
@@ -513,7 +558,8 @@ def _generate_nodes(
513558
nested: NestedT,
514559
commands: ty.Optional[ty.List[str]] = None,
515560
semantic_group: bool = False,
516-
) -> ty.List[nodes.section]:
561+
hide_header: bool = False,
562+
) -> ty.List[nodes.Element]:
517563
"""Generate the relevant Sphinx nodes.
518564
519565
Format a `click.Group` or `click.Command`.
@@ -526,65 +572,96 @@ def _generate_nodes(
526572
empty
527573
:param semantic_group: Display command as title and description for
528574
`click.CommandCollection`.
575+
:param hide_header: Hide the title and summary.
529576
:returns: A list of nested docutil nodes
530577
"""
531578
ctx = click.Context(command, info_name=name, parent=parent)
532579

533580
if command.hidden:
534581
return []
535582

536-
# Title
537-
538-
section = nodes.section(
539-
'',
540-
nodes.title(text=name),
541-
ids=[nodes.make_id(ctx.command_path)],
542-
names=[nodes.fully_normalize_name(ctx.command_path)],
543-
)
544-
545583
# Summary
546584
source_name = ctx.command_path
547585
result = statemachine.StringList()
548586

587+
lines: collections.abc.Iterator[str] = iter(())
588+
hide_current_header = hide_header
589+
if nested == NESTED_COMPLETE:
590+
lines = itertools.chain(lines, _format_summary(ctx, commands, hide_header))
591+
nested = ty.cast(NestedT, NESTED_FULL)
592+
hide_current_header = True
593+
549594
ctx.meta["sphinx-click-env"] = self.env
550595
if semantic_group:
551-
lines = _format_description(ctx)
596+
lines = itertools.chain(lines, _format_description(ctx))
552597
else:
553-
lines = _format_command(ctx, nested, commands)
598+
lines = itertools.chain(
599+
lines, _format_command(ctx, nested, commands, hide_current_header)
600+
)
554601

555602
for line in lines:
556603
LOG.debug(line)
557604
result.append(line, source_name)
558605

559-
sphinx_nodes.nested_parse_with_titles(self.state, result, section)
560-
561606
# Subcommands
562607

608+
subcommand_nodes = []
563609
if nested == NESTED_FULL:
564610
if isinstance(command, click.CommandCollection):
565611
for source in command.sources:
566-
section.extend(
612+
subcommand_nodes.extend(
567613
self._generate_nodes(
568614
source.name,
569615
source,
570616
parent=ctx,
571617
nested=nested,
572618
semantic_group=True,
619+
hide_header=False, # Hiding the header should not propagate to children
573620
)
574621
)
575622
else:
576623
commands = _filter_commands(ctx, commands)
577624
for command in commands:
578625
parent = ctx if not semantic_group else ctx.parent
579-
section.extend(
626+
subcommand_nodes.extend(
580627
self._generate_nodes(
581-
command.name, command, parent=parent, nested=nested
628+
command.name,
629+
command,
630+
parent=parent,
631+
nested=nested,
632+
hide_header=False, # Hiding the header should not propagate to children
582633
)
583634
)
584635

585-
return [section]
636+
final_nodes: ty.List[nodes.Element]
637+
section: nodes.Element
638+
if hide_header:
639+
final_nodes = subcommand_nodes
586640

587-
def run(self) -> ty.Sequence[nodes.section]:
641+
if nested == NESTED_NONE or nested == NESTED_SHORT:
642+
section = nodes.paragraph()
643+
self.state.nested_parse(result, 0, section)
644+
final_nodes.insert(0, section)
645+
646+
else:
647+
# Title
648+
649+
section = nodes.section(
650+
'',
651+
nodes.title(text=name),
652+
ids=[nodes.make_id(ctx.command_path)],
653+
names=[nodes.fully_normalize_name(ctx.command_path)],
654+
)
655+
656+
sphinx_nodes.nested_parse_with_titles(self.state, result, section)
657+
658+
for node in subcommand_nodes:
659+
section.append(node)
660+
final_nodes = [section]
661+
662+
return final_nodes
663+
664+
def run(self) -> ty.Sequence[nodes.Element]:
588665
self.env = self.state.document.settings.env
589666

590667
command = self._load_module(self.arguments[0])
@@ -595,6 +672,7 @@ def run(self) -> ty.Sequence[nodes.section]:
595672
prog_name = self.options['prog']
596673
show_nested = 'show-nested' in self.options
597674
nested = self.options.get('nested')
675+
hide_header = 'hide-header' in self.options
598676

599677
if show_nested:
600678
if nested:
@@ -614,7 +692,9 @@ def run(self) -> ty.Sequence[nodes.section]:
614692
command.strip() for command in self.options['commands'].split(',')
615693
]
616694

617-
return self._generate_nodes(prog_name, command, None, nested, commands)
695+
return self._generate_nodes(
696+
prog_name, command, None, nested, commands, False, hide_header
697+
)
618698

619699

620700
def setup(app: application.Sphinx) -> ty.Dict[str, ty.Any]:

tests/test_extension.py

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -38,16 +38,18 @@ def test_basics(make_app, rootdir):
3838
assert section[0].astext() == 'greet'
3939
assert isinstance(section[1], nodes.paragraph)
4040
assert section[1].astext() == 'A sample command group.'
41-
assert isinstance(section[2], nodes.literal_block)
41+
assert isinstance(section[2], nodes.target)
42+
assert section[2].attributes['refid'] == 'greet'
43+
assert isinstance(section[3], nodes.literal_block)
4244

43-
assert isinstance(section[3], nodes.rubric)
44-
assert section[3].astext() == 'Commands'
45-
assert isinstance(section[4], sphinx_nodes.index)
46-
assert isinstance(section[5], sphinx_nodes.desc)
47-
assert section[5].astext() == 'hello\n\nGreet a user.'
48-
assert isinstance(section[6], sphinx_nodes.index)
49-
assert isinstance(section[7], sphinx_nodes.desc)
50-
assert section[7].astext() == 'world\n\nGreet the world.'
45+
assert isinstance(section[4], nodes.rubric)
46+
assert section[4].astext() == 'Commands'
47+
assert isinstance(section[5], sphinx_nodes.index)
48+
assert isinstance(section[6], sphinx_nodes.desc)
49+
assert section[6].astext() == 'hello\n\nGreet a user.'
50+
assert isinstance(section[7], sphinx_nodes.index)
51+
assert isinstance(section[8], sphinx_nodes.desc)
52+
assert section[8].astext() == 'world\n\nGreet the world.'
5153

5254

5355
def test_commands(make_app, rootdir):
@@ -80,14 +82,16 @@ def test_commands(make_app, rootdir):
8082
assert section[0].astext() == 'greet'
8183
assert isinstance(section[1], nodes.paragraph)
8284
assert section[1].astext() == 'A sample command group.'
83-
assert isinstance(section[2], nodes.literal_block)
85+
assert isinstance(section[2], nodes.target)
86+
assert section[2].attributes['refid'] == 'greet'
87+
assert isinstance(section[3], nodes.literal_block)
8488

8589
# we should only show a single command, 'world'
86-
assert isinstance(section[3], nodes.rubric)
87-
assert section[3].astext() == 'Commands'
88-
assert isinstance(section[4], sphinx_nodes.index)
89-
assert isinstance(section[5], sphinx_nodes.desc)
90-
assert section[5].astext() == 'world\n\nGreet the world.'
90+
assert isinstance(section[4], nodes.rubric)
91+
assert section[4].astext() == 'Commands'
92+
assert isinstance(section[5], sphinx_nodes.index)
93+
assert isinstance(section[6], sphinx_nodes.desc)
94+
assert section[6].astext() == 'world\n\nGreet the world.'
9195

9296

9397
def test_nested_full(make_app, rootdir):
@@ -124,23 +128,29 @@ def test_nested_full(make_app, rootdir):
124128
assert section[0].astext() == 'greet'
125129
assert isinstance(section[1], nodes.paragraph)
126130
assert section[1].astext() == 'A sample command group.'
127-
assert isinstance(section[2], nodes.literal_block)
131+
assert isinstance(section[2], nodes.target)
132+
assert section[2].attributes['refid'] == 'greet'
133+
assert isinstance(section[3], nodes.literal_block)
128134

129-
subsection_a = section[3]
135+
subsection_a = section[4]
130136
assert isinstance(subsection_a, nodes.section)
131137

132138
assert isinstance(subsection_a[0], nodes.title)
133139
assert subsection_a[0].astext() == 'hello'
134140
assert isinstance(subsection_a[1], nodes.paragraph)
135141
assert subsection_a[1].astext() == 'Greet a user.'
136-
assert isinstance(subsection_a[2], nodes.literal_block)
142+
assert isinstance(subsection_a[2], nodes.target)
143+
assert subsection_a[2].attributes['refid'] == 'greet-hello'
144+
assert isinstance(subsection_a[3], nodes.literal_block)
137145
# we don't need to verify the rest of this: that's done elsewhere
138146

139-
subsection_b = section[4]
147+
subsection_b = section[5]
140148
assert isinstance(subsection_b, nodes.section)
141149

142150
assert isinstance(subsection_b[0], nodes.title)
143151
assert subsection_b[0].astext() == 'world'
144152
assert isinstance(subsection_b[1], nodes.paragraph)
145153
assert subsection_b[1].astext() == 'Greet the world.'
146-
assert isinstance(subsection_b[2], nodes.literal_block)
154+
assert isinstance(subsection_b[2], nodes.target)
155+
assert subsection_b[2].attributes['refid'] == 'greet-world'
156+
assert isinstance(subsection_b[3], nodes.literal_block)

0 commit comments

Comments
 (0)