Skip to content

Commit

Permalink
Add sphinx._cli
Browse files Browse the repository at this point in the history
  • Loading branch information
AA-Turner committed Apr 29, 2024
1 parent 577016c commit aaad0bd
Show file tree
Hide file tree
Showing 2 changed files with 299 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,9 @@ select = [
# from .flake8
"sphinx/*" = ["E241"]

# whitelist ``print`` for stdout messages
"sphinx/_cli/__init__.py" = ["T201"]

# whitelist ``print`` for stdout messages
"sphinx/cmd/build.py" = ["T201"]
"sphinx/cmd/make_mode.py" = ["T201"]
Expand Down
296 changes: 296 additions & 0 deletions sphinx/_cli/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
"""Base 'sphinx' command.
Subcommands are loaded lazily from the ``_COMMANDS`` table for performance.
All subcommand modules must define three attributes:
- ``parser_description``, a description of the subcommand. The first paragraph
is taken as the short description for the command.
- ``set_up_parser``, a callable taking and returning an ``ArgumentParser``. This
function is responsible for adding options and arguments to the subcommand's
parser.
- ``run``, a callable taking parsed arguments and returning an exit code. This
function is responsible for running the main body of the subcommand and
returning the exit status.
The entire ``sphinx._cli`` namespace is private, only the command line interface
has backwards-compatability guarantees.
"""

from __future__ import annotations

import argparse
import importlib
import locale
import sys
from typing import TYPE_CHECKING

from sphinx._cli.util.colour import (
bold,
disable_colour,
enable_colour,
terminal_supports_colour,
underline,
)
from sphinx.locale import __, init_console

if TYPE_CHECKING:
from collections.abc import Callable, Collection, Iterable, Iterator, Sequence
from typing import NoReturn

_PARSER_SETUP = Callable[[argparse.ArgumentParser], argparse.ArgumentParser]
_RUNNER = Callable[[argparse.Namespace], int]

from typing import Protocol

class _SubcommandModule(Protocol):
parser_description: str
set_up_parser: _PARSER_SETUP # takes and returns argument parser
run: _RUNNER # takes parsed args, returns exit code


# Map of command name to import path.
_COMMANDS: dict[str, str] = {
}


def _load_subcommand_descriptions() -> Iterator[tuple[str, str]]:
for command, module_name in _COMMANDS.items():
module: _SubcommandModule = importlib.import_module(module_name)
try:
description = module.parser_description
except AttributeError:
# log an error here, but don't fail the full enumeration
print(f"Failed to load the description for {command}", file=sys.stderr)
else:
yield command, description.split('\n\n', 1)[0]


class _RootArgumentParser(argparse.ArgumentParser):
def format_help(self) -> str:
help_fragments: list[str] = [
bold(underline(__('Usage:'))),
' ',
__('{0} [OPTIONS] <COMMAND> [<ARGS>]').format(bold(self.prog)),
'\n',
'\n',
__(' The Sphinx documentation generator.'),
'\n',
]

if commands := list(_load_subcommand_descriptions()):
command_max_length = min(max(map(len, next(zip(*commands), ()))), 22)
help_fragments += [
'\n',
bold(underline(__('Commands:'))),
'\n',
]
help_fragments += [
f' {command_name: <{command_max_length}} {command_desc}'
for command_name, command_desc in commands
]
help_fragments.append('\n')

# self._action_groups[1] is self._optionals
# Uppercase the title of the Optionals group
self._optionals.title = __('Options')
for argument_group in self._action_groups[1:]:
if arguments := [action for action in argument_group._group_actions
if action.help != argparse.SUPPRESS]:
help_fragments += self._format_optional_arguments(
arguments,
argument_group.title or '',
)

help_fragments += [
'\n',
__('For more information, visit https://www.sphinx-doc.org/en/master/man/.'),
'\n',
]
return ''.join(help_fragments)

def _format_optional_arguments(
self,
actions: Iterable[argparse.Action],
title: str,
) -> Iterator[str]:
yield '\n'
yield bold(underline(title + ':'))
yield '\n'

for action in actions:
prefix = ' ' * all(o[1] == '-' for o in action.option_strings)
opt = prefix + ' ' + ', '.join(map(bold, action.option_strings))
if action.nargs != 0:
opt += ' ' + self._format_metavar(
action.nargs, action.metavar, action.choices, action.dest,
)
yield opt
yield '\n'
if action_help := (action.help or '').strip():
yield from (f' {line}\n' for line in action_help.splitlines())

@staticmethod
def _format_metavar(
nargs: int | str | None,
metavar: str | tuple[str, ...] | None,
choices: Collection[str] | None,
dest: str,
) -> str:
if metavar is None:
if choices is not None:
metavar = '{' + ', '.join(sorted(choices)) + '}'
else:
metavar = dest.upper()
if nargs is None:
return f'{metavar}'
elif nargs == argparse.OPTIONAL:
return f'[{metavar}]'
elif nargs == argparse.ZERO_OR_MORE:
if len(metavar) == 2:
return f'[{metavar[0]} [{metavar[1]} ...]]'
else:
return f'[{metavar} ...]'
elif nargs == argparse.ONE_OR_MORE:
return f'{metavar} [{metavar} ...]'
elif nargs == argparse.REMAINDER:
return '...'
elif nargs == argparse.PARSER:
return f'{metavar} ...'
msg = 'invalid nargs value'
raise ValueError(msg)

def error(self, message: str) -> NoReturn:
sys.stderr.write(__(
'{0}: error: {1}\n'
"Run '{0} --help' for information" # NoQA: COM812
).format(self.prog, message))
raise SystemExit(2)


def _create_parser() -> _RootArgumentParser:
parser = _RootArgumentParser(
prog='sphinx',
description=__(' Manage documentation with Sphinx.'),
epilog=__('For more information, visit https://www.sphinx-doc.org/en/master/man/.'),
add_help=False,
allow_abbrev=False,
)
parser.add_argument(
'-V', '--version',
action='store_true',
default=argparse.SUPPRESS,
help=__('Show the version and exit.'),
)
parser.add_argument(
'-h', '-?', '--help',
action='store_true',
default=argparse.SUPPRESS,
help=__('Show this message and exit.'),
)

# logging control
log_control = parser.add_argument_group(__('Logging'))
log_control.add_argument(
'-v', '--verbose',
action='count',
dest='verbosity',
default=0,
help=__('Increase verbosity (can be repeated)'),
)
log_control.add_argument(
'-q', '--quiet',
action='store_const',
dest='verbosity',
const=-1,
help=__('Only print errors and warnings.'),
)
log_control.add_argument(
'--silent',
action='store_const',
dest='verbosity',
const=-2,
help=__('No output at all'),
)

parser.add_argument(
'COMMAND',
nargs=argparse.REMAINDER,
metavar=__('<command>'),
)
return parser


def _parse_command(argv: Sequence[str] = ()) -> tuple[str, Sequence[str]]:
parser = _create_parser()
args = parser.parse_args(argv)
command_name, *command_argv = args.COMMAND or ('help',)
command_name = command_name.lower()

if terminal_supports_colour():
enable_colour()
else:
disable_colour()

# Handle '--version' or '-V' passed to the main command or any subcommand
if 'version' in args or {'-V', '--version'}.intersection(command_argv):
from sphinx import __display_version__
sys.stderr.write(f'sphinx {__display_version__}\n')
raise SystemExit(0)

# Handle '--help' or '-h' passed to the main command (subcommands may have
# their own help text)
if 'help' in args or command_name == 'help':
sys.stderr.write(parser.format_help())
raise SystemExit(0)

if command_name not in _COMMANDS:
sys.stderr.write(__(f'sphinx: {command_name!r} is not a sphinx command. '
"See 'sphinx --help'.\n"))
raise SystemExit(2)

return command_name, command_argv


def _load_subcommand(command_name: str) -> tuple[str, _PARSER_SETUP, _RUNNER]:
try:
module: _SubcommandModule = importlib.import_module(_COMMANDS[command_name])
except KeyError:
msg = f'invalid command name {command_name!r}.'
raise ValueError(msg) from None
return module.parser_description, module.set_up_parser, module.run


def _create_sub_parser(
command_name: str,
description: str,
parser_setup: _PARSER_SETUP,
) -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog=f'sphinx {command_name}',
description=description,
formatter_class=argparse.RawDescriptionHelpFormatter,
allow_abbrev=False,
)
return parser_setup(parser)


def run(argv: Sequence[str] = (), /) -> int:
locale.setlocale(locale.LC_ALL, '')
init_console()

argv = argv or sys.argv[1:]
try:
cmd_name, cmd_argv = _parse_command(argv)
cmd_description, set_up_parser, runner = _load_subcommand(cmd_name)
cmd_parser = _create_sub_parser(cmd_name, cmd_description, set_up_parser)
cmd_args = cmd_parser.parse_args(cmd_argv)
return runner(cmd_args)
except SystemExit as exc:
return exc.code # type: ignore[return-value]
except (Exception, KeyboardInterrupt):
return 2


if __name__ == '__main__':
raise SystemExit(run())

0 comments on commit aaad0bd

Please sign in to comment.