Skip to content

Commit 334e69f

Browse files
authored
Test command line argument parsing (sphinx-doc#12795)
1 parent 2e1415c commit 334e69f

File tree

3 files changed

+261
-37
lines changed

3 files changed

+261
-37
lines changed

sphinx/cmd/build.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,9 @@ def get_parser() -> argparse.ArgumentParser:
210210
dest='exception_on_warning',
211211
help=__('raise an exception on warnings'))
212212

213+
if parser.prog == '__main__.py':
214+
parser.prog = 'sphinx-build'
215+
213216
return parser
214217

215218

@@ -386,7 +389,8 @@ def main(argv: Sequence[str] = (), /) -> int:
386389
if argv[:1] == ['--bug-report']:
387390
return _bug_report_info()
388391
if argv[:1] == ['-M']:
389-
return make_main(argv)
392+
from sphinx.cmd import make_mode
393+
return make_mode.run_make_mode(argv[1:])
390394
else:
391395
return build_main(argv)
392396

sphinx/cmd/make_mode.py

+39-36
Original file line numberDiff line numberDiff line change
@@ -58,31 +58,31 @@
5858

5959

6060
class Make:
61-
def __init__(self, srcdir: str, builddir: str, opts: Sequence[str]) -> None:
62-
self.srcdir = srcdir
63-
self.builddir = builddir
61+
def __init__(self, *, source_dir: str, build_dir: str, opts: Sequence[str]) -> None:
62+
self.source_dir = source_dir
63+
self.build_dir = build_dir
6464
self.opts = [*opts]
6565

66-
def builddir_join(self, *comps: str) -> str:
67-
return path.join(self.builddir, *comps)
66+
def build_dir_join(self, *comps: str) -> str:
67+
return path.join(self.build_dir, *comps)
6868

6969
def build_clean(self) -> int:
70-
srcdir = path.abspath(self.srcdir)
71-
builddir = path.abspath(self.builddir)
72-
if not path.exists(self.builddir):
70+
source_dir = path.abspath(self.source_dir)
71+
build_dir = path.abspath(self.build_dir)
72+
if not path.exists(self.build_dir):
7373
return 0
74-
elif not path.isdir(self.builddir):
75-
print("Error: %r is not a directory!" % self.builddir)
74+
elif not path.isdir(self.build_dir):
75+
print("Error: %r is not a directory!" % self.build_dir)
7676
return 1
77-
elif srcdir == builddir:
78-
print("Error: %r is same as source directory!" % self.builddir)
77+
elif source_dir == build_dir:
78+
print("Error: %r is same as source directory!" % self.build_dir)
7979
return 1
80-
elif path.commonpath([srcdir, builddir]) == builddir:
81-
print("Error: %r directory contains source directory!" % self.builddir)
80+
elif path.commonpath([source_dir, build_dir]) == build_dir:
81+
print("Error: %r directory contains source directory!" % self.build_dir)
8282
return 1
83-
print("Removing everything under %r..." % self.builddir)
84-
for item in os.listdir(self.builddir):
85-
rmtree(self.builddir_join(item))
83+
print("Removing everything under %r..." % self.build_dir)
84+
for item in os.listdir(self.build_dir):
85+
rmtree(self.build_dir_join(item))
8686
return 0
8787

8888
def build_help(self) -> None:
@@ -105,7 +105,7 @@ def build_latexpdf(self) -> int:
105105
if not makecmd.lower().startswith('make'):
106106
raise RuntimeError('Invalid $MAKE command: %r' % makecmd)
107107
try:
108-
with chdir(self.builddir_join('latex')):
108+
with chdir(self.build_dir_join('latex')):
109109
if '-Q' in self.opts:
110110
with open('__LATEXSTDOUT__', 'w') as outfile:
111111
returncode = subprocess.call([makecmd,
@@ -117,7 +117,7 @@ def build_latexpdf(self) -> int:
117117
)
118118
if returncode:
119119
print('Latex error: check %s' %
120-
self.builddir_join('latex', '__LATEXSTDOUT__')
120+
self.build_dir_join('latex', '__LATEXSTDOUT__')
121121
)
122122
elif '-q' in self.opts:
123123
returncode = subprocess.call(
@@ -129,7 +129,7 @@ def build_latexpdf(self) -> int:
129129
)
130130
if returncode:
131131
print('Latex error: check .log file in %s' %
132-
self.builddir_join('latex')
132+
self.build_dir_join('latex')
133133
)
134134
else:
135135
returncode = subprocess.call([makecmd, 'all-pdf'])
@@ -148,7 +148,7 @@ def build_latexpdfja(self) -> int:
148148
if not makecmd.lower().startswith('make'):
149149
raise RuntimeError('Invalid $MAKE command: %r' % makecmd)
150150
try:
151-
with chdir(self.builddir_join('latex')):
151+
with chdir(self.build_dir_join('latex')):
152152
return subprocess.call([makecmd, 'all-pdf'])
153153
except OSError:
154154
print('Error: Failed to run: %s' % makecmd)
@@ -163,41 +163,44 @@ def build_info(self) -> int:
163163
if not makecmd.lower().startswith('make'):
164164
raise RuntimeError('Invalid $MAKE command: %r' % makecmd)
165165
try:
166-
with chdir(self.builddir_join('texinfo')):
166+
with chdir(self.build_dir_join('texinfo')):
167167
return subprocess.call([makecmd, 'info'])
168168
except OSError:
169169
print('Error: Failed to run: %s' % makecmd)
170170
return 1
171171

172172
def build_gettext(self) -> int:
173-
dtdir = self.builddir_join('gettext', '.doctrees')
173+
dtdir = self.build_dir_join('gettext', '.doctrees')
174174
if self.run_generic_build('gettext', doctreedir=dtdir) > 0:
175175
return 1
176176
return 0
177177

178178
def run_generic_build(self, builder: str, doctreedir: str | None = None) -> int:
179179
# compatibility with old Makefile
180-
papersize = os.getenv('PAPER', '')
181-
opts = self.opts
182-
if papersize in ('a4', 'letter'):
183-
opts.extend(['-D', 'latex_elements.papersize=' + papersize + 'paper'])
180+
paper_size = os.getenv('PAPER', '')
181+
if paper_size in {'a4', 'letter'}:
182+
self.opts.extend(['-D', f'latex_elements.papersize={paper_size}paper'])
184183
if doctreedir is None:
185-
doctreedir = self.builddir_join('doctrees')
184+
doctreedir = self.build_dir_join('doctrees')
186185

187-
args = ['-b', builder,
188-
'-d', doctreedir,
189-
self.srcdir,
190-
self.builddir_join(builder)]
191-
return build_main(args + opts)
186+
args = [
187+
'--builder', builder,
188+
'--doctree-dir', doctreedir,
189+
self.source_dir,
190+
self.build_dir_join(builder),
191+
]
192+
return build_main(args + self.opts)
192193

193194

194195
def run_make_mode(args: Sequence[str]) -> int:
195196
if len(args) < 3:
196197
print('Error: at least 3 arguments (builder, source '
197198
'dir, build dir) are required.', file=sys.stderr)
198199
return 1
199-
make = Make(args[1], args[2], args[3:])
200-
run_method = 'build_' + args[0]
200+
201+
builder_name = args[0]
202+
make = Make(source_dir=args[1], build_dir=args[2], opts=args[3:])
203+
run_method = f'build_{builder_name}'
201204
if hasattr(make, run_method):
202205
return getattr(make, run_method)()
203-
return make.run_generic_build(args[0])
206+
return make.run_generic_build(builder_name)

tests/test_command_line.py

+217
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
from __future__ import annotations
2+
3+
import os.path
4+
from typing import Any
5+
6+
import pytest
7+
8+
from sphinx.cmd import make_mode
9+
from sphinx.cmd.build import get_parser
10+
from sphinx.cmd.make_mode import run_make_mode
11+
12+
DEFAULTS = {
13+
'filenames': [],
14+
'jobs': 1,
15+
'force_all': False,
16+
'freshenv': False,
17+
'doctreedir': None,
18+
'confdir': None,
19+
'noconfig': False,
20+
'define': [],
21+
'htmldefine': [],
22+
'tags': [],
23+
'nitpicky': False,
24+
'verbosity': 0,
25+
'quiet': False,
26+
'really_quiet': False,
27+
'color': 'auto',
28+
'warnfile': None,
29+
'warningiserror': False,
30+
'keep_going': False,
31+
'traceback': False,
32+
'pdb': False,
33+
'exception_on_warning': False,
34+
}
35+
36+
EXPECTED_BUILD_MAIN = {
37+
'builder': 'html',
38+
'sourcedir': 'source_dir',
39+
'outputdir': 'build_dir',
40+
'filenames': ['filename1', 'filename2'],
41+
'freshenv': True,
42+
'noconfig': True,
43+
'quiet': True,
44+
}
45+
46+
EXPECTED_MAKE_MODE = {
47+
'builder': 'html',
48+
'sourcedir': 'source_dir',
49+
'outputdir': os.path.join('build_dir', 'html'),
50+
'doctreedir': os.path.join('build_dir', 'doctrees'),
51+
'filenames': ['filename1', 'filename2'],
52+
'freshenv': True,
53+
'noconfig': True,
54+
'quiet': True,
55+
}
56+
57+
BUILDER_BUILD_MAIN = [
58+
'--builder',
59+
'html',
60+
]
61+
BUILDER_MAKE_MODE = [
62+
'html',
63+
]
64+
POSITIONAL_DIRS = [
65+
'source_dir',
66+
'build_dir',
67+
]
68+
POSITIONAL_FILENAMES = [
69+
'filename1',
70+
'filename2',
71+
]
72+
POSITIONAL = POSITIONAL_DIRS + POSITIONAL_FILENAMES
73+
POSITIONAL_MAKE_MODE = BUILDER_MAKE_MODE + POSITIONAL
74+
EARLY_OPTS = [
75+
'--quiet',
76+
]
77+
LATE_OPTS = [
78+
'-E',
79+
'--isolated',
80+
]
81+
OPTS = EARLY_OPTS + LATE_OPTS
82+
OPTS_BUILD_MAIN = BUILDER_BUILD_MAIN + OPTS
83+
84+
85+
def parse_arguments(args: list[str]) -> dict[str, Any]:
86+
parsed = vars(get_parser().parse_args(args))
87+
return {k: v for k, v in parsed.items() if k not in DEFAULTS or v != DEFAULTS[k]}
88+
89+
90+
def test_build_main_parse_arguments_pos_first() -> None:
91+
# <positional...> <opts>
92+
args = [
93+
*POSITIONAL,
94+
*OPTS,
95+
]
96+
assert parse_arguments(args) == EXPECTED_BUILD_MAIN
97+
98+
99+
def test_build_main_parse_arguments_pos_last() -> None:
100+
# <opts> <positional...>
101+
args = [
102+
*OPTS,
103+
*POSITIONAL,
104+
]
105+
assert parse_arguments(args) == EXPECTED_BUILD_MAIN
106+
107+
108+
def test_build_main_parse_arguments_pos_middle() -> None:
109+
# <opts> <positional...> <opts>
110+
args = [
111+
*EARLY_OPTS,
112+
*BUILDER_BUILD_MAIN,
113+
*POSITIONAL,
114+
*LATE_OPTS,
115+
]
116+
assert parse_arguments(args) == EXPECTED_BUILD_MAIN
117+
118+
119+
@pytest.mark.xfail(reason='sphinx-build does not yet support filenames after options')
120+
def test_build_main_parse_arguments_filenames_last() -> None:
121+
args = [
122+
*POSITIONAL_DIRS,
123+
*OPTS,
124+
*POSITIONAL_FILENAMES,
125+
]
126+
assert parse_arguments(args) == EXPECTED_BUILD_MAIN
127+
128+
129+
def test_build_main_parse_arguments_pos_intermixed(
130+
capsys: pytest.CaptureFixture[str],
131+
) -> None:
132+
args = [
133+
*EARLY_OPTS,
134+
*BUILDER_BUILD_MAIN,
135+
*POSITIONAL_DIRS,
136+
*LATE_OPTS,
137+
*POSITIONAL_FILENAMES,
138+
]
139+
with pytest.raises(SystemExit):
140+
parse_arguments(args)
141+
stderr = capsys.readouterr().err.splitlines()
142+
assert stderr[-1].endswith('error: unrecognized arguments: filename1 filename2')
143+
144+
145+
def test_make_mode_parse_arguments_pos_first(monkeypatch: pytest.MonkeyPatch) -> None:
146+
# -M <positional...> <opts>
147+
monkeypatch.setattr(make_mode, 'build_main', parse_arguments)
148+
args = [
149+
*POSITIONAL_MAKE_MODE,
150+
*OPTS,
151+
]
152+
assert run_make_mode(args) == EXPECTED_MAKE_MODE
153+
154+
155+
def test_make_mode_parse_arguments_pos_last(
156+
monkeypatch: pytest.MonkeyPatch,
157+
capsys: pytest.CaptureFixture[str],
158+
) -> None:
159+
# -M <opts> <positional...>
160+
monkeypatch.setattr(make_mode, 'build_main', parse_arguments)
161+
args = [
162+
*OPTS,
163+
*POSITIONAL_MAKE_MODE,
164+
]
165+
with pytest.raises(SystemExit):
166+
run_make_mode(args)
167+
stderr = capsys.readouterr().err.splitlines()
168+
assert stderr[-1].endswith('error: argument --builder/-b: expected one argument')
169+
170+
171+
def test_make_mode_parse_arguments_pos_middle(
172+
monkeypatch: pytest.MonkeyPatch,
173+
capsys: pytest.CaptureFixture[str],
174+
) -> None:
175+
# -M <opts> <positional...> <opts>
176+
monkeypatch.setattr(make_mode, 'build_main', parse_arguments)
177+
args = [
178+
*EARLY_OPTS,
179+
*POSITIONAL_MAKE_MODE,
180+
*LATE_OPTS,
181+
]
182+
with pytest.raises(SystemExit):
183+
run_make_mode(args)
184+
stderr = capsys.readouterr().err.splitlines()
185+
assert stderr[-1].endswith('error: argument --builder/-b: expected one argument')
186+
187+
188+
@pytest.mark.xfail(reason='sphinx-build does not yet support filenames after options')
189+
def test_make_mode_parse_arguments_filenames_last(
190+
monkeypatch: pytest.MonkeyPatch,
191+
) -> None:
192+
monkeypatch.setattr(make_mode, 'build_main', parse_arguments)
193+
args = [
194+
*BUILDER_MAKE_MODE,
195+
*POSITIONAL_DIRS,
196+
*OPTS,
197+
*POSITIONAL_FILENAMES,
198+
]
199+
assert run_make_mode(args) == EXPECTED_MAKE_MODE
200+
201+
202+
def test_make_mode_parse_arguments_pos_intermixed(
203+
monkeypatch: pytest.MonkeyPatch,
204+
capsys: pytest.CaptureFixture[str],
205+
) -> None:
206+
monkeypatch.setattr(make_mode, 'build_main', parse_arguments)
207+
args = [
208+
*EARLY_OPTS,
209+
*BUILDER_MAKE_MODE,
210+
*POSITIONAL_DIRS,
211+
*LATE_OPTS,
212+
*POSITIONAL_FILENAMES,
213+
]
214+
with pytest.raises(SystemExit):
215+
run_make_mode(args)
216+
stderr = capsys.readouterr().err.splitlines()
217+
assert stderr[-1].endswith('error: argument --builder/-b: expected one argument')

0 commit comments

Comments
 (0)