Skip to content

Commit 0aecaa8

Browse files
committed
Add sphinx._cli
1 parent 7e4ef9f commit 0aecaa8

File tree

1 file changed

+271
-0
lines changed

1 file changed

+271
-0
lines changed

sphinx/_cli/__init__.py

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
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 locale
24+
import sys
25+
from typing import TYPE_CHECKING
26+
27+
from sphinx._cli.util.colour import (
28+
bold,
29+
disable_colour,
30+
enable_colour,
31+
terminal_supports_colour,
32+
underline,
33+
)
34+
from sphinx.locale import __, init_console
35+
36+
if TYPE_CHECKING:
37+
from collections.abc import Callable, Iterable, Iterator, Sequence
38+
from typing import NoReturn
39+
40+
_PARSER_SETUP = Callable[[argparse.ArgumentParser], argparse.ArgumentParser]
41+
_RUNNER = Callable[[argparse.Namespace], int]
42+
43+
if sys.version_info[:2] > (3, 8):
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+
else:
51+
from types import ModuleType as _SubcommandModule
52+
53+
54+
# Map of command name to import path.
55+
_COMMANDS: dict[str, str] = {
56+
}
57+
58+
59+
def _load_subcommand_descriptions() -> Iterator[tuple[str, str]]:
60+
import importlib
61+
62+
for command, module_name in _COMMANDS.items():
63+
module: _SubcommandModule = importlib.import_module(module_name)
64+
try:
65+
description = module.parser_description
66+
except AttributeError:
67+
continue
68+
else:
69+
yield command, description.split('\n\n', 1)[0]
70+
71+
72+
class _RootArgumentParser(argparse.ArgumentParser):
73+
def format_help(self):
74+
help_fragments: list[str] = [
75+
bold(underline(__('Usage:'))),
76+
' ',
77+
__('{0} [OPTIONS] <COMMAND> [<ARGS>]').format(bold(self.prog)),
78+
'\n',
79+
'\n',
80+
__(' The Sphinx documentation generator.'),
81+
'\n',
82+
]
83+
84+
if commands := [*_load_subcommand_descriptions()]:
85+
command_max_length = min(max(map(len, next(zip(*commands), ()))), 22)
86+
help_fragments += [
87+
'\n',
88+
bold(underline(__('Commands:'))),
89+
'\n',
90+
]
91+
help_fragments.extend(
92+
f' {command_name: <{command_max_length}} {command_desc}'
93+
for command_name, command_desc in commands
94+
)
95+
help_fragments.append('\n')
96+
97+
if options := [action for action in self._optionals._group_actions
98+
if action.help != argparse.SUPPRESS]:
99+
help_fragments += [
100+
'\n',
101+
bold(underline(__('Options:'))),
102+
'\n',
103+
]
104+
for action in options:
105+
opt = self._format_option_string(action.option_strings)
106+
if action.nargs != 0:
107+
opt += ' ' + self._format_metavar(
108+
action.nargs, action.metavar, action.choices, action.dest,
109+
)
110+
help_fragments.append(opt)
111+
help_fragments.append('\n')
112+
if action_help := (action.help or '').strip():
113+
help_fragments.extend(
114+
f' {line}\n' for line in action_help.splitlines()
115+
)
116+
117+
help_fragments += [
118+
'\n',
119+
__('For more information, visit https://www.sphinx-doc.org/en/master/man/.'),
120+
'\n',
121+
]
122+
return ''.join(help_fragments)
123+
124+
@staticmethod
125+
def _format_option_string(option_strings: Sequence[str]) -> str:
126+
# hide 'colour'
127+
option_strings = [o for o in option_strings if o != '--colour']
128+
prefix = ' ' * all(o[1] == '-' for o in option_strings)
129+
return prefix + ' ' + ', '.join(map(bold, option_strings))
130+
131+
@staticmethod
132+
def _format_metavar(
133+
nargs: int | str | None,
134+
metavar: str | tuple[str, ...] | None,
135+
choices: Iterable[str] | None | None,
136+
dest: str,
137+
) -> str:
138+
if metavar is not None:
139+
metavar = metavar
140+
elif choices is not None:
141+
metavar = '{' + ', '.join(sorted(choices)) + '}'
142+
else:
143+
metavar = dest.upper()
144+
if nargs is None:
145+
return f'{metavar}'
146+
elif nargs == argparse.OPTIONAL:
147+
return f'[{metavar}]'
148+
elif nargs == argparse.ZERO_OR_MORE:
149+
if len(metavar) == 2:
150+
return f'[{metavar[0]} [{metavar[1]} ...]]'
151+
else:
152+
return f'[{metavar} ...]'
153+
elif nargs == argparse.ONE_OR_MORE:
154+
return f'{metavar} [{metavar} ...]'
155+
elif nargs == argparse.REMAINDER:
156+
return '...'
157+
elif nargs == argparse.PARSER:
158+
return f'{metavar} ...'
159+
raise ValueError("invalid nargs value")
160+
161+
def error(self, message: str) -> NoReturn:
162+
sys.stderr.write(__(
163+
"{0}: error: {1}\n"
164+
"Run '{0} --help' for information" # NoQA: COM812
165+
).format(self.prog, message))
166+
raise SystemExit(2)
167+
168+
169+
def _create_parser() -> _RootArgumentParser:
170+
parser = _RootArgumentParser(
171+
prog='sphinx',
172+
description=__(' Manage documentation with Sphinx.'),
173+
epilog=__('For more information, visit https://www.sphinx-doc.org/en/master/man/.'),
174+
add_help=False,
175+
allow_abbrev=False,
176+
)
177+
parser.add_argument(
178+
'-V', '--version',
179+
action='store_true',
180+
default=argparse.SUPPRESS,
181+
help=__('Show the version and exit.'),
182+
)
183+
parser.add_argument(
184+
'-h', '-?', '--help',
185+
action='store_true',
186+
default=argparse.SUPPRESS,
187+
help=__('Show this message and exit.'),
188+
)
189+
190+
parser.add_argument(
191+
'COMMAND',
192+
nargs='...',
193+
metavar=__('<command>'),
194+
)
195+
return parser
196+
197+
198+
def _parse_command(argv: Sequence[str] = ()) -> tuple[str, Sequence[str]]:
199+
parser = _create_parser()
200+
args = parser.parse_args(argv)
201+
command_name, *command_argv = args.COMMAND or ('help',)
202+
command_name = command_name.lower()
203+
204+
if args.colour == 'yes' or (args.colour == 'auto' and terminal_supports_colour()):
205+
enable_colour()
206+
else:
207+
disable_colour()
208+
209+
# Handle '--version' or '-V' passed to the main command or any subcommand
210+
if 'version' in args or {'-V', '--version'}.intersection(command_argv):
211+
from sphinx import __display_version__
212+
sys.stderr.write(f'sphinx {__display_version__}\n')
213+
raise SystemExit(0)
214+
215+
# Handle '--help' or '-h' passed to the main command (subcommands may have
216+
# their own help text)
217+
if 'help' in args or command_name == 'help':
218+
sys.stderr.write(parser.format_help())
219+
raise SystemExit(0)
220+
221+
if command_name not in _COMMANDS:
222+
sys.stderr.write(__(f'sphinx: {command_name!r} is not a sphinx command. '
223+
"See 'sphinx --help'.\n"))
224+
raise SystemExit(2)
225+
226+
return command_name, command_argv
227+
228+
229+
def _load_subcommand(command_name: str) -> tuple[str, _PARSER_SETUP, _RUNNER]:
230+
import importlib
231+
232+
try:
233+
module: _SubcommandModule = importlib.import_module(_COMMANDS[command_name])
234+
except KeyError:
235+
raise ValueError(f'invalid command name {command_name!r}.') from None
236+
return module.parser_description, module.set_up_parser, module.run
237+
238+
239+
def _create_sub_parser(
240+
command_name: str,
241+
description: str,
242+
parser_setup: _PARSER_SETUP,
243+
) -> argparse.ArgumentParser:
244+
parser = argparse.ArgumentParser(
245+
prog=f'sphinx {command_name}',
246+
description=description,
247+
formatter_class=argparse.RawDescriptionHelpFormatter,
248+
allow_abbrev=False,
249+
)
250+
return parser_setup(parser)
251+
252+
253+
def run(argv: Sequence[str] = (), /) -> int:
254+
locale.setlocale(locale.LC_ALL, '')
255+
init_console()
256+
257+
argv = argv or sys.argv[1:]
258+
try:
259+
cmd_name, cmd_argv = _parse_command(argv)
260+
cmd_description, set_up_parser, runner = _load_subcommand(cmd_name)
261+
cmd_parser = _create_sub_parser(cmd_name, cmd_description, set_up_parser)
262+
cmd_args = cmd_parser.parse_args(cmd_argv)
263+
return runner(cmd_args)
264+
except SystemExit as exc:
265+
return exc.code # type: ignore[return-value]
266+
except (Exception, KeyboardInterrupt):
267+
return 2
268+
269+
270+
if __name__ == '__main__':
271+
raise SystemExit(run())

0 commit comments

Comments
 (0)