Skip to content

Commit c41aab8

Browse files
authored
Add sphinx._cli (sphinx-doc#10877)
This is the first step towards a new ``sphinx`` command.
1 parent 0d74c85 commit c41aab8

File tree

5 files changed

+568
-0
lines changed

5 files changed

+568
-0
lines changed

.ruff.toml

+4
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,9 @@ select = [
386386
# from .flake8
387387
"sphinx/*" = ["E241"]
388388

389+
# whitelist ``print`` for stdout messages
390+
"sphinx/_cli/__init__.py" = ["T201"]
391+
389392
# whitelist ``print`` for stdout messages
390393
"sphinx/cmd/build.py" = ["T201"]
391394
"sphinx/cmd/make_mode.py" = ["T201"]
@@ -435,6 +438,7 @@ forced-separate = [
435438
preview = true
436439
quote-style = "single"
437440
exclude = [
441+
"sphinx/_cli/*",
438442
"sphinx/addnodes.py",
439443
"sphinx/application.py",
440444
"sphinx/builders/*",

sphinx/_cli/__init__.py

+296
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
"""Base 'sphinx' command.
2+
3+
Subcommands are loaded lazily from the ``_COMMANDS`` table for performance.
4+
5+
All subcommand modules must define three attributes:
6+
7+
- ``parser_description``, a description of the subcommand. The first paragraph
8+
is taken as the short description for the command.
9+
- ``set_up_parser``, a callable taking and returning an ``ArgumentParser``. This
10+
function is responsible for adding options and arguments to the subcommand's
11+
parser.
12+
- ``run``, a callable taking parsed arguments and returning an exit code. This
13+
function is responsible for running the main body of the subcommand and
14+
returning the exit status.
15+
16+
The entire ``sphinx._cli`` namespace is private, only the command line interface
17+
has backwards-compatability guarantees.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import argparse
23+
import importlib
24+
import locale
25+
import sys
26+
from typing import TYPE_CHECKING
27+
28+
from sphinx._cli.util.colour import (
29+
bold,
30+
disable_colour,
31+
enable_colour,
32+
terminal_supports_colour,
33+
underline,
34+
)
35+
from sphinx.locale import __, init_console
36+
37+
if TYPE_CHECKING:
38+
from collections.abc import Callable, Iterable, Iterator, Sequence
39+
from typing import NoReturn
40+
41+
_PARSER_SETUP = Callable[[argparse.ArgumentParser], argparse.ArgumentParser]
42+
_RUNNER = Callable[[argparse.Namespace], int]
43+
44+
from typing import Protocol
45+
46+
class _SubcommandModule(Protocol):
47+
parser_description: str
48+
set_up_parser: _PARSER_SETUP # takes and returns argument parser
49+
run: _RUNNER # takes parsed args, returns exit code
50+
51+
52+
# Map of command name to import path.
53+
_COMMANDS: dict[str, str] = {
54+
}
55+
56+
57+
def _load_subcommand_descriptions() -> Iterator[tuple[str, str]]:
58+
for command, module_name in _COMMANDS.items():
59+
module: _SubcommandModule = importlib.import_module(module_name)
60+
try:
61+
description = module.parser_description
62+
except AttributeError:
63+
# log an error here, but don't fail the full enumeration
64+
print(f"Failed to load the description for {command}", file=sys.stderr)
65+
else:
66+
yield command, description.split('\n\n', 1)[0]
67+
68+
69+
class _RootArgumentParser(argparse.ArgumentParser):
70+
def format_help(self) -> str:
71+
help_fragments: list[str] = [
72+
bold(underline(__('Usage:'))),
73+
' ',
74+
__('{0} [OPTIONS] <COMMAND> [<ARGS>]').format(bold(self.prog)),
75+
'\n',
76+
'\n',
77+
__(' The Sphinx documentation generator.'),
78+
'\n',
79+
]
80+
81+
if commands := list(_load_subcommand_descriptions()):
82+
command_max_length = min(max(map(len, next(zip(*commands), ()))), 22)
83+
help_fragments += [
84+
'\n',
85+
bold(underline(__('Commands:'))),
86+
'\n',
87+
]
88+
help_fragments += [
89+
f' {command_name: <{command_max_length}} {command_desc}'
90+
for command_name, command_desc in commands
91+
]
92+
help_fragments.append('\n')
93+
94+
# self._action_groups[1] is self._optionals
95+
# Uppercase the title of the Optionals group
96+
self._optionals.title = __('Options')
97+
for argument_group in self._action_groups[1:]:
98+
if arguments := [action for action in argument_group._group_actions
99+
if action.help != argparse.SUPPRESS]:
100+
help_fragments += self._format_optional_arguments(
101+
arguments,
102+
argument_group.title or '',
103+
)
104+
105+
help_fragments += [
106+
'\n',
107+
__('For more information, visit https://www.sphinx-doc.org/en/master/man/.'),
108+
'\n',
109+
]
110+
return ''.join(help_fragments)
111+
112+
def _format_optional_arguments(
113+
self,
114+
actions: Iterable[argparse.Action],
115+
title: str,
116+
) -> Iterator[str]:
117+
yield '\n'
118+
yield bold(underline(title + ':'))
119+
yield '\n'
120+
121+
for action in actions:
122+
prefix = ' ' * all(o[1] == '-' for o in action.option_strings)
123+
opt = prefix + ' ' + ', '.join(map(bold, action.option_strings))
124+
if action.nargs != 0:
125+
opt += ' ' + self._format_metavar(
126+
action.nargs, action.metavar, action.choices, action.dest,
127+
)
128+
yield opt
129+
yield '\n'
130+
if action_help := (action.help or '').strip():
131+
yield from (f' {line}\n' for line in action_help.splitlines())
132+
133+
@staticmethod
134+
def _format_metavar(
135+
nargs: int | str | None,
136+
metavar: str | tuple[str, ...] | None,
137+
choices: Iterable[str] | None,
138+
dest: str,
139+
) -> str:
140+
if metavar is None:
141+
if choices is not None:
142+
metavar = '{' + ', '.join(sorted(choices)) + '}'
143+
else:
144+
metavar = dest.upper()
145+
if nargs is None:
146+
return f'{metavar}'
147+
elif nargs == argparse.OPTIONAL:
148+
return f'[{metavar}]'
149+
elif nargs == argparse.ZERO_OR_MORE:
150+
if len(metavar) == 2:
151+
return f'[{metavar[0]} [{metavar[1]} ...]]'
152+
else:
153+
return f'[{metavar} ...]'
154+
elif nargs == argparse.ONE_OR_MORE:
155+
return f'{metavar} [{metavar} ...]'
156+
elif nargs == argparse.REMAINDER:
157+
return '...'
158+
elif nargs == argparse.PARSER:
159+
return f'{metavar} ...'
160+
msg = 'invalid nargs value'
161+
raise ValueError(msg)
162+
163+
def error(self, message: str) -> NoReturn:
164+
sys.stderr.write(__(
165+
'{0}: error: {1}\n'
166+
"Run '{0} --help' for information" # NoQA: COM812
167+
).format(self.prog, message))
168+
raise SystemExit(2)
169+
170+
171+
def _create_parser() -> _RootArgumentParser:
172+
parser = _RootArgumentParser(
173+
prog='sphinx',
174+
description=__(' Manage documentation with Sphinx.'),
175+
epilog=__('For more information, visit https://www.sphinx-doc.org/en/master/man/.'),
176+
add_help=False,
177+
allow_abbrev=False,
178+
)
179+
parser.add_argument(
180+
'-V', '--version',
181+
action='store_true',
182+
default=argparse.SUPPRESS,
183+
help=__('Show the version and exit.'),
184+
)
185+
parser.add_argument(
186+
'-h', '-?', '--help',
187+
action='store_true',
188+
default=argparse.SUPPRESS,
189+
help=__('Show this message and exit.'),
190+
)
191+
192+
# logging control
193+
log_control = parser.add_argument_group(__('Logging'))
194+
log_control.add_argument(
195+
'-v', '--verbose',
196+
action='count',
197+
dest='verbosity',
198+
default=0,
199+
help=__('Increase verbosity (can be repeated)'),
200+
)
201+
log_control.add_argument(
202+
'-q', '--quiet',
203+
action='store_const',
204+
dest='verbosity',
205+
const=-1,
206+
help=__('Only print errors and warnings.'),
207+
)
208+
log_control.add_argument(
209+
'--silent',
210+
action='store_const',
211+
dest='verbosity',
212+
const=-2,
213+
help=__('No output at all'),
214+
)
215+
216+
parser.add_argument(
217+
'COMMAND',
218+
nargs=argparse.REMAINDER,
219+
metavar=__('<command>'),
220+
)
221+
return parser
222+
223+
224+
def _parse_command(argv: Sequence[str] = ()) -> tuple[str, Sequence[str]]:
225+
parser = _create_parser()
226+
args = parser.parse_args(argv)
227+
command_name, *command_argv = args.COMMAND or ('help',)
228+
command_name = command_name.lower()
229+
230+
if terminal_supports_colour():
231+
enable_colour()
232+
else:
233+
disable_colour()
234+
235+
# Handle '--version' or '-V' passed to the main command or any subcommand
236+
if 'version' in args or {'-V', '--version'}.intersection(command_argv):
237+
from sphinx import __display_version__
238+
sys.stderr.write(f'sphinx {__display_version__}\n')
239+
raise SystemExit(0)
240+
241+
# Handle '--help' or '-h' passed to the main command (subcommands may have
242+
# their own help text)
243+
if 'help' in args or command_name == 'help':
244+
sys.stderr.write(parser.format_help())
245+
raise SystemExit(0)
246+
247+
if command_name not in _COMMANDS:
248+
sys.stderr.write(__(f'sphinx: {command_name!r} is not a sphinx command. '
249+
"See 'sphinx --help'.\n"))
250+
raise SystemExit(2)
251+
252+
return command_name, command_argv
253+
254+
255+
def _load_subcommand(command_name: str) -> tuple[str, _PARSER_SETUP, _RUNNER]:
256+
try:
257+
module: _SubcommandModule = importlib.import_module(_COMMANDS[command_name])
258+
except KeyError:
259+
msg = f'invalid command name {command_name!r}.'
260+
raise ValueError(msg) from None
261+
return module.parser_description, module.set_up_parser, module.run
262+
263+
264+
def _create_sub_parser(
265+
command_name: str,
266+
description: str,
267+
parser_setup: _PARSER_SETUP,
268+
) -> argparse.ArgumentParser:
269+
parser = argparse.ArgumentParser(
270+
prog=f'sphinx {command_name}',
271+
description=description,
272+
formatter_class=argparse.RawDescriptionHelpFormatter,
273+
allow_abbrev=False,
274+
)
275+
return parser_setup(parser)
276+
277+
278+
def run(argv: Sequence[str] = (), /) -> int:
279+
locale.setlocale(locale.LC_ALL, '')
280+
init_console()
281+
282+
argv = argv or sys.argv[1:]
283+
try:
284+
cmd_name, cmd_argv = _parse_command(argv)
285+
cmd_description, set_up_parser, runner = _load_subcommand(cmd_name)
286+
cmd_parser = _create_sub_parser(cmd_name, cmd_description, set_up_parser)
287+
cmd_args = cmd_parser.parse_args(cmd_argv)
288+
return runner(cmd_args)
289+
except SystemExit as exc:
290+
return exc.code # type: ignore[return-value]
291+
except (Exception, KeyboardInterrupt):
292+
return 2
293+
294+
295+
if __name__ == '__main__':
296+
raise SystemExit(run())

sphinx/_cli/util/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)