Enable automatic formatting for `sphinx/ext/doctest.py`

This commit is contained in:
Adam Turner 2024-12-17 20:59:32 +00:00
parent aecb60c057
commit 01d993b359
2 changed files with 146 additions and 82 deletions

View File

@ -415,7 +415,6 @@ exclude = [
"sphinx/domains/python/_object.py", "sphinx/domains/python/_object.py",
"sphinx/domains/rst.py", "sphinx/domains/rst.py",
"sphinx/domains/std/__init__.py", "sphinx/domains/std/__init__.py",
"sphinx/ext/doctest.py",
"sphinx/ext/duration.py", "sphinx/ext/duration.py",
"sphinx/ext/extlinks.py", "sphinx/ext/extlinks.py",
"sphinx/ext/githubpages.py", "sphinx/ext/githubpages.py",

View File

@ -61,6 +61,7 @@ def is_allowed_version(spec: str, version: str) -> bool:
# set up the necessary directives # set up the necessary directives
class TestDirective(SphinxDirective): class TestDirective(SphinxDirective):
""" """
Base class for doctest-related directives. Base class for doctest-related directives.
@ -81,7 +82,10 @@ class TestDirective(SphinxDirective):
# convert <BLANKLINE>s to ordinary blank lines for presentation # convert <BLANKLINE>s to ordinary blank lines for presentation
test = code test = code
code = blankline_re.sub('', code) code = blankline_re.sub('', code)
if doctestopt_re.search(code) and 'no-trim-doctest-flags' not in self.options: if (
doctestopt_re.search(code)
and 'no-trim-doctest-flags' not in self.options
):
if not test: if not test:
test = code test = code
code = doctestopt_re.sub('', code) code = doctestopt_re.sub('', code)
@ -113,15 +117,17 @@ class TestDirective(SphinxDirective):
if prefix not in '+-': if prefix not in '+-':
self.state.document.reporter.warning( self.state.document.reporter.warning(
__("missing '+' or '-' in '%s' option.") % option, __("missing '+' or '-' in '%s' option.") % option,
line=self.lineno) line=self.lineno,
)
continue continue
if option_name not in doctest.OPTIONFLAGS_BY_NAME: if option_name not in doctest.OPTIONFLAGS_BY_NAME:
self.state.document.reporter.warning( self.state.document.reporter.warning(
__("'%s' is not a valid option.") % option_name, __("'%s' is not a valid option.") % option_name,
line=self.lineno) line=self.lineno,
)
continue continue
flag = doctest.OPTIONFLAGS_BY_NAME[option[1:]] flag = doctest.OPTIONFLAGS_BY_NAME[option[1:]]
node['options'][flag] = (option[0] == '+') node['options'][flag] = option[0] == '+'
if self.name == 'doctest' and 'pyversion' in self.options: if self.name == 'doctest' and 'pyversion' in self.options:
try: try:
spec = self.options['pyversion'] spec = self.options['pyversion']
@ -131,8 +137,8 @@ class TestDirective(SphinxDirective):
node['options'][flag] = True # Skip the test node['options'][flag] = True # Skip the test
except InvalidSpecifier: except InvalidSpecifier:
self.state.document.reporter.warning( self.state.document.reporter.warning(
__("'%s' is not a valid pyversion option") % spec, __("'%s' is not a valid pyversion option") % spec, line=self.lineno
line=self.lineno) )
if 'skipif' in self.options: if 'skipif' in self.options:
node['skipif'] = self.options['skipif'] node['skipif'] = self.options['skipif']
if 'trim-doctest-flags' in self.options: if 'trim-doctest-flags' in self.options:
@ -191,6 +197,7 @@ parser = doctest.DocTestParser()
# helper classes # helper classes
class TestGroup: class TestGroup:
def __init__(self, name: str) -> None: def __init__(self, name: str) -> None:
self.name = name self.name = name
@ -220,13 +227,21 @@ class TestGroup:
raise RuntimeError(__('invalid TestCode type')) raise RuntimeError(__('invalid TestCode type'))
def __repr__(self) -> str: def __repr__(self) -> str:
return (f'TestGroup(name={self.name!r}, setup={self.setup!r}, ' return (
f'cleanup={self.cleanup!r}, tests={self.tests!r})') f'TestGroup(name={self.name!r}, setup={self.setup!r}, '
f'cleanup={self.cleanup!r}, tests={self.tests!r})'
)
class TestCode: class TestCode:
def __init__(self, code: str, type: str, filename: str, def __init__(
lineno: int, options: dict | None = None) -> None: self,
code: str,
type: str,
filename: str,
lineno: int,
options: dict | None = None,
) -> None:
self.code = code self.code = code
self.type = type self.type = type
self.filename = filename self.filename = filename
@ -234,12 +249,15 @@ class TestCode:
self.options = options or {} self.options = options or {}
def __repr__(self) -> str: def __repr__(self) -> str:
return (f'TestCode({self.code!r}, {self.type!r}, filename={self.filename!r}, ' return (
f'lineno={self.lineno!r}, options={self.options!r})') f'TestCode({self.code!r}, {self.type!r}, filename={self.filename!r}, '
f'lineno={self.lineno!r}, options={self.options!r})'
)
class SphinxDocTestRunner(doctest.DocTestRunner): class SphinxDocTestRunner(doctest.DocTestRunner):
def summarize(self, out: Callable, verbose: bool | None = None, # type: ignore[override] def summarize( # type: ignore[override]
self, out: Callable, verbose: bool | None = None
) -> tuple[int, int]: ) -> tuple[int, int]:
string_io = StringIO() string_io = StringIO()
old_stdout = sys.stdout old_stdout = sys.stdout
@ -251,11 +269,11 @@ class SphinxDocTestRunner(doctest.DocTestRunner):
out(string_io.getvalue()) out(string_io.getvalue())
return res return res
def _DocTestRunner__patched_linecache_getlines(self, filename: str, def _DocTestRunner__patched_linecache_getlines(
module_globals: Any = None) -> Any: self, filename: str, module_globals: Any = None
) -> Any:
# this is overridden from DocTestRunner adding the try-except below # this is overridden from DocTestRunner adding the try-except below
m = self._DocTestRunner__LINECACHE_FILENAME_RE.match( # type: ignore[attr-defined] m = self._DocTestRunner__LINECACHE_FILENAME_RE.match(filename) # type: ignore[attr-defined]
filename)
if m and m.group('name') == self.test.name: if m and m.group('name') == self.test.name:
try: try:
example = self.test.examples[int(m.group('examplenum'))] example = self.test.examples[int(m.group('examplenum'))]
@ -266,20 +284,22 @@ class SphinxDocTestRunner(doctest.DocTestRunner):
pass pass
else: else:
return example.source.splitlines(True) return example.source.splitlines(True)
return self.save_linecache_getlines( # type: ignore[attr-defined] return self.save_linecache_getlines(filename, module_globals) # type: ignore[attr-defined]
filename, module_globals)
# the new builder -- use sphinx-build.py -b doctest to run # the new builder -- use sphinx-build.py -b doctest to run
class DocTestBuilder(Builder): class DocTestBuilder(Builder):
""" """
Runs test snippets in the documentation. Runs test snippets in the documentation.
""" """
name = 'doctest' name = 'doctest'
epilog = __('Testing of doctests in the sources finished, look at the ' epilog = __(
'results in %(outdir)s/output.txt.') 'Testing of doctests in the sources finished, look at the '
'results in %(outdir)s/output.txt.'
)
def init(self) -> None: def init(self) -> None:
# default options # default options
@ -307,9 +327,11 @@ class DocTestBuilder(Builder):
outpath = self.outdir.joinpath('output.txt') outpath = self.outdir.joinpath('output.txt')
self.outfile = outpath.open('w', encoding='utf-8') # NoQA: SIM115 self.outfile = outpath.open('w', encoding='utf-8') # NoQA: SIM115
self.outfile.write(('Results of doctest builder run on %s\n' line = '=' * len(date)
'==================================%s\n') % self.outfile.write(
(date, '=' * len(date))) f'Results of doctest builder run on {date}\n'
f'=================================={line}\n'
)
def __del__(self) -> None: def __del__(self) -> None:
# free resources upon destruction (the file handler might not be # free resources upon destruction (the file handler might not be
@ -338,18 +360,17 @@ class DocTestBuilder(Builder):
# write executive summary # write executive summary
def s(v: int) -> str: def s(v: int) -> str:
return 's' if v != 1 else '' return 's' if v != 1 else ''
repl = (self.total_tries, s(self.total_tries),
self.total_failures, s(self.total_failures), self._out(
self.setup_failures, s(self.setup_failures), f"""
self.cleanup_failures, s(self.cleanup_failures))
self._out('''
Doctest summary Doctest summary
=============== ===============
%5d test%s {self.total_tries:5} test{s(self.total_tries)}
%5d failure%s in tests {self.total_failures:5} failure{s(self.total_failures)} in tests
%5d failure%s in setup code {self.setup_failures:5} failure{s(self.setup_failures)} in setup code
%5d failure%s in cleanup code {self.cleanup_failures:5} failure{s(self.cleanup_failures)} in cleanup code
''' % repl) """
)
self.outfile.close() self.outfile.close()
if self.total_failures or self.setup_failures or self.cleanup_failures: if self.total_failures or self.setup_failures or self.cleanup_failures:
@ -367,10 +388,10 @@ Doctest summary
filename of the document it's included in. filename of the document it's included in.
""" """
try: try:
filename = relpath(node.source, self.env.srcdir).rsplit(':docstring of ', maxsplit=1)[0] # type: ignore[arg-type] # noqa: E501 filename = relpath(node.source, self.env.srcdir) # type: ignore[arg-type]
return filename.rsplit(':docstring of ', maxsplit=1)[0]
except Exception: except Exception:
filename = str(self.env.doc2path(docname, False)) return str(self.env.doc2path(docname, False))
return filename
@staticmethod @staticmethod
def get_line_number(node: Node) -> int | None: def get_line_number(node: Node) -> int | None:
@ -404,25 +425,29 @@ Doctest summary
def test_doc(self, docname: str, doctree: Node) -> None: def test_doc(self, docname: str, doctree: Node) -> None:
groups: dict[str, TestGroup] = {} groups: dict[str, TestGroup] = {}
add_to_all_groups = [] add_to_all_groups = []
self.setup_runner = SphinxDocTestRunner(verbose=False, self.setup_runner = SphinxDocTestRunner(verbose=False, optionflags=self.opt)
optionflags=self.opt) self.test_runner = SphinxDocTestRunner(verbose=False, optionflags=self.opt)
self.test_runner = SphinxDocTestRunner(verbose=False, self.cleanup_runner = SphinxDocTestRunner(verbose=False, optionflags=self.opt)
optionflags=self.opt)
self.cleanup_runner = SphinxDocTestRunner(verbose=False,
optionflags=self.opt)
self.test_runner._fakeout = self.setup_runner._fakeout # type: ignore[attr-defined] self.test_runner._fakeout = self.setup_runner._fakeout # type: ignore[attr-defined]
self.cleanup_runner._fakeout = self.setup_runner._fakeout # type: ignore[attr-defined] self.cleanup_runner._fakeout = self.setup_runner._fakeout # type: ignore[attr-defined]
if self.config.doctest_test_doctest_blocks: if self.config.doctest_test_doctest_blocks:
def condition(node: Node) -> bool: def condition(node: Node) -> bool:
return (isinstance(node, nodes.literal_block | nodes.comment) and return (
'testnodetype' in node) or \ isinstance(node, nodes.literal_block | nodes.comment)
isinstance(node, nodes.doctest_block)
else:
def condition(node: Node) -> bool:
return isinstance(node, nodes.literal_block | nodes.comment) \
and 'testnodetype' in node and 'testnodetype' in node
) or isinstance(node, nodes.doctest_block)
else:
def condition(node: Node) -> bool:
return (
isinstance(node, nodes.literal_block | nodes.comment)
and 'testnodetype' in node
)
for node in doctree.findall(condition): for node in doctree.findall(condition):
if self.skipped(node): # type: ignore[arg-type] if self.skipped(node): # type: ignore[arg-type]
continue continue
@ -431,12 +456,19 @@ Doctest summary
filename = self.get_filename_for_node(node, docname) filename = self.get_filename_for_node(node, docname)
line_number = self.get_line_number(node) line_number = self.get_line_number(node)
if not source: if not source:
logger.warning(__('no code/output in %s block at %s:%s'), logger.warning(
__('no code/output in %s block at %s:%s'),
node.get('testnodetype', 'doctest'), # type: ignore[attr-defined] node.get('testnodetype', 'doctest'), # type: ignore[attr-defined]
filename, line_number) filename,
code = TestCode(source, type=node.get('testnodetype', 'doctest'), # type: ignore[attr-defined] line_number,
filename=filename, lineno=line_number, # type: ignore[arg-type] )
options=node.get('options')) # type: ignore[attr-defined] code = TestCode(
source,
type=node.get('testnodetype', 'doctest'), # type: ignore[attr-defined]
filename=filename,
lineno=line_number, # type: ignore[arg-type]
options=node.get('options'), # type: ignore[attr-defined]
)
node_groups = node.get('groups', ['default']) # type: ignore[attr-defined] node_groups = node.get('groups', ['default']) # type: ignore[attr-defined]
if '*' in node_groups: if '*' in node_groups:
add_to_all_groups.append(code) add_to_all_groups.append(code)
@ -449,13 +481,21 @@ Doctest summary
for group in groups.values(): for group in groups.values():
group.add_code(code) group.add_code(code)
if self.config.doctest_global_setup: if self.config.doctest_global_setup:
code = TestCode(self.config.doctest_global_setup, code = TestCode(
'testsetup', filename='<global_setup>', lineno=0) self.config.doctest_global_setup,
'testsetup',
filename='<global_setup>',
lineno=0,
)
for group in groups.values(): for group in groups.values():
group.add_code(code, prepend=True) group.add_code(code, prepend=True)
if self.config.doctest_global_cleanup: if self.config.doctest_global_cleanup:
code = TestCode(self.config.doctest_global_cleanup, code = TestCode(
'testcleanup', filename='<global_cleanup>', lineno=0) self.config.doctest_global_cleanup,
'testcleanup',
filename='<global_cleanup>',
lineno=0,
)
for group in groups.values(): for group in groups.values():
group.add_code(code) group.add_code(code)
if not groups: if not groups:
@ -463,9 +503,7 @@ Doctest summary
show_successes = self.config.doctest_show_successes show_successes = self.config.doctest_show_successes
if show_successes: if show_successes:
self._out('\n' self._out(f'\nDocument: {docname}\n----------{"-" * len(docname)}\n')
f'Document: {docname}\n'
f'----------{"-" * len(docname)}\n')
for group in groups.values(): for group in groups.values():
self.test_group(group) self.test_group(group)
# Separately count results from setup code # Separately count results from setup code
@ -473,23 +511,27 @@ Doctest summary
self.setup_failures += res_f self.setup_failures += res_f
self.setup_tries += res_t self.setup_tries += res_t
if self.test_runner.tries: if self.test_runner.tries:
res_f, res_t = self.test_runner.summarize( res_f, res_t = self.test_runner.summarize(self._out, verbose=show_successes)
self._out, verbose=show_successes)
self.total_failures += res_f self.total_failures += res_f
self.total_tries += res_t self.total_tries += res_t
if self.cleanup_runner.tries: if self.cleanup_runner.tries:
res_f, res_t = self.cleanup_runner.summarize( res_f, res_t = self.cleanup_runner.summarize(
self._out, verbose=show_successes) self._out, verbose=show_successes
)
self.cleanup_failures += res_f self.cleanup_failures += res_f
self.cleanup_tries += res_t self.cleanup_tries += res_t
def compile(self, code: str, name: str, type: str, flags: Any, dont_inherit: bool) -> Any: def compile(
self, code: str, name: str, type: str, flags: Any, dont_inherit: bool
) -> Any:
return compile(code, name, self.type, flags, dont_inherit) return compile(code, name, self.type, flags, dont_inherit)
def test_group(self, group: TestGroup) -> None: def test_group(self, group: TestGroup) -> None:
ns: dict = {} ns: dict = {}
def run_setup_cleanup(runner: Any, testcodes: list[TestCode], what: Any) -> bool: def run_setup_cleanup(
runner: Any, testcodes: list[TestCode], what: Any
) -> bool:
examples = [] examples = []
for testcode in testcodes: for testcode in testcodes:
example = doctest.Example(testcode.code, '', lineno=testcode.lineno) example = doctest.Example(testcode.code, '', lineno=testcode.lineno)
@ -497,9 +539,14 @@ Doctest summary
if not examples: if not examples:
return True return True
# simulate a doctest with the code # simulate a doctest with the code
sim_doctest = doctest.DocTest(examples, {}, sim_doctest = doctest.DocTest(
examples,
{},
f'{group.name} ({what} code)', f'{group.name} ({what} code)',
testcodes[0].filename, 0, None) testcodes[0].filename,
0,
None,
)
sim_doctest.globs = ns sim_doctest.globs = ns
old_f = runner.failures old_f = runner.failures
self.type = 'exec' # the snippet may contain multiple statements self.type = 'exec' # the snippet may contain multiple statements
@ -516,11 +563,15 @@ Doctest summary
if len(code) == 1: if len(code) == 1:
# ordinary doctests (code/output interleaved) # ordinary doctests (code/output interleaved)
try: try:
test = parser.get_doctest(code[0].code, {}, group.name, test = parser.get_doctest(
code[0].filename, code[0].lineno) code[0].code, {}, group.name, code[0].filename, code[0].lineno
)
except Exception: except Exception:
logger.warning(__('ignoring invalid doctest code: %r'), code[0].code, logger.warning(
location=(code[0].filename, code[0].lineno)) __('ignoring invalid doctest code: %r'),
code[0].code,
location=(code[0].filename, code[0].lineno),
)
continue continue
if not test.examples: if not test.examples:
continue continue
@ -542,10 +593,21 @@ Doctest summary
exc_msg = m.group('msg') exc_msg = m.group('msg')
else: else:
exc_msg = None exc_msg = None
example = doctest.Example(code[0].code, output, exc_msg=exc_msg, example = doctest.Example(
lineno=code[0].lineno, options=options) code[0].code,
test = doctest.DocTest([example], {}, group.name, output,
code[0].filename, code[0].lineno, None) exc_msg=exc_msg,
lineno=code[0].lineno,
options=options,
)
test = doctest.DocTest(
[example],
{},
group.name,
code[0].filename,
code[0].lineno,
None,
)
self.type = 'exec' # multiple statements again self.type = 'exec' # multiple statements again
# DocTest.__init__ copies the globs namespace, which we don't want # DocTest.__init__ copies the globs namespace, which we don't want
test.globs = ns test.globs = ns
@ -571,6 +633,9 @@ def setup(app: Sphinx) -> ExtensionMetadata:
app.add_config_value('doctest_global_cleanup', '', '') app.add_config_value('doctest_global_cleanup', '', '')
app.add_config_value( app.add_config_value(
'doctest_default_flags', 'doctest_default_flags',
doctest.DONT_ACCEPT_TRUE_FOR_1 | doctest.ELLIPSIS | doctest.IGNORE_EXCEPTION_DETAIL, doctest.DONT_ACCEPT_TRUE_FOR_1
'') | doctest.ELLIPSIS
| doctest.IGNORE_EXCEPTION_DETAIL,
'',
)
return {'version': sphinx.__display_version__, 'parallel_read_safe': True} return {'version': sphinx.__display_version__, 'parallel_read_safe': True}