Skip to content

Commit 363217b

Browse files
authored
Pytest: Improve doctest integration
1 parent c9dd1e8 commit 363217b

File tree

9 files changed

+400
-224
lines changed

9 files changed

+400
-224
lines changed

.gitignore

-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
/config.log
1919
/config.status
2020
/configure
21-
/conftest*
2221
/confdefs.h
2322

2423
/m4/sage_spkg_configures.m4

.vscode/settings.json

+1-3
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,7 @@
1818
},
1919
"python.testing.pytestEnabled": true,
2020
"python.testing.pytestArgs": [
21-
"--rootdir=src/sage",
22-
"-c=src/tox.ini",
23-
"--doctest-modules"
21+
"--doctest"
2422
],
2523
"python.testing.unittestEnabled": false,
2624
"cSpell.words": [

conftest.py

+347
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
# pyright: strict
2+
"""Configuration and fixtures for pytest.
3+
4+
This file configures pytest and provides some global fixtures.
5+
See https://docs.pytest.org/en/latest/index.html for more details.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import doctest
11+
import inspect
12+
import sys
13+
import warnings
14+
from pathlib import Path
15+
from typing import Any, Iterable, Optional
16+
17+
import pytest
18+
from _pytest.doctest import (
19+
DoctestItem,
20+
DoctestModule,
21+
_get_continue_on_failure,
22+
_get_runner,
23+
_is_mocked,
24+
_patch_unwrap_mock_aware,
25+
get_optionflags,
26+
)
27+
from _pytest.pathlib import ImportMode, import_path
28+
29+
from sage.doctest.forker import (
30+
init_sage,
31+
showwarning_with_traceback,
32+
)
33+
from sage.doctest.parsing import SageDocTestParser, SageOutputChecker
34+
35+
36+
class SageDoctestModule(DoctestModule):
37+
"""
38+
This is essentially a copy of `DoctestModule` from
39+
https://github.com/pytest-dev/pytest/blob/main/src/_pytest/doctest.py.
40+
The only change is that we use `SageDocTestParser` to extract the doctests
41+
and `SageOutputChecker` to verify the output.
42+
"""
43+
44+
def collect(self) -> Iterable[DoctestItem]:
45+
import doctest
46+
47+
class MockAwareDocTestFinder(doctest.DocTestFinder):
48+
"""A hackish doctest finder that overrides stdlib internals to fix a stdlib bug.
49+
https://github.com/pytest-dev/pytest/issues/3456
50+
https://bugs.python.org/issue25532
51+
"""
52+
53+
def __init__(self) -> None:
54+
super().__init__(parser=SageDocTestParser(set(["sage"])))
55+
56+
def _find_lineno(self, obj, source_lines):
57+
"""Doctest code does not take into account `@property`, this
58+
is a hackish way to fix it. https://bugs.python.org/issue17446
59+
Wrapped Doctests will need to be unwrapped so the correct
60+
line number is returned. This will be reported upstream. #8796
61+
"""
62+
if isinstance(obj, property):
63+
obj = getattr(obj, "fget", obj)
64+
65+
if hasattr(obj, "__wrapped__"):
66+
# Get the main obj in case of it being wrapped
67+
obj = inspect.unwrap(obj)
68+
69+
# Type ignored because this is a private function.
70+
return super()._find_lineno( # type:ignore[misc]
71+
obj,
72+
source_lines,
73+
)
74+
75+
def _find(
76+
self, tests, obj, name, module, source_lines, globs, seen
77+
) -> None:
78+
if _is_mocked(obj):
79+
return
80+
with _patch_unwrap_mock_aware():
81+
# Type ignored because this is a private function.
82+
super()._find( # type:ignore[misc]
83+
tests, obj, name, module, source_lines, globs, seen
84+
)
85+
86+
if self.path.name == "conftest.py":
87+
module = self.config.pluginmanager._importconftest(
88+
self.path,
89+
self.config.getoption("importmode"),
90+
rootpath=self.config.rootpath,
91+
consider_namespace_packages=True,
92+
)
93+
else:
94+
try:
95+
module = import_path(
96+
self.path,
97+
mode=ImportMode.importlib,
98+
root=self.config.rootpath,
99+
consider_namespace_packages=True,
100+
)
101+
except ImportError as exception:
102+
if self.config.getvalue("doctest_ignore_import_errors"):
103+
pytest.skip("unable to import module %r" % self.path)
104+
else:
105+
if isinstance(exception, ModuleNotFoundError):
106+
# Ignore some missing features/modules for now
107+
# TODO: Remove this once all optional things are using Features
108+
if exception.name in (
109+
"valgrind",
110+
"rpy2",
111+
"sage.libs.coxeter3.coxeter",
112+
):
113+
pytest.skip(
114+
f"unable to import module { self.path } due to missing feature { exception.name }"
115+
)
116+
raise
117+
# Uses internal doctest module parsing mechanism.
118+
finder = MockAwareDocTestFinder()
119+
optionflags = get_optionflags(self.config)
120+
from sage.features import FeatureNotPresentError
121+
122+
runner = _get_runner(
123+
verbose=False,
124+
optionflags=optionflags,
125+
checker=SageOutputChecker(),
126+
continue_on_failure=_get_continue_on_failure(self.config),
127+
)
128+
try:
129+
for test in finder.find(module, module.__name__):
130+
if test.examples: # skip empty doctests
131+
yield DoctestItem.from_parent(
132+
self, name=test.name, runner=runner, dtest=test
133+
)
134+
except FeatureNotPresentError as exception:
135+
pytest.skip(
136+
f"unable to import module { self.path } due to missing feature { exception.feature.name }"
137+
)
138+
except ModuleNotFoundError as exception:
139+
# TODO: Remove this once all optional things are using Features
140+
pytest.skip(
141+
f"unable to import module { self.path } due to missing module { exception.name }"
142+
)
143+
144+
145+
class IgnoreCollector(pytest.Collector):
146+
"""
147+
Ignore a file.
148+
"""
149+
150+
def __init__(self, parent: pytest.Collector) -> None:
151+
super().__init__("ignore", parent)
152+
153+
def collect(self) -> Iterable[pytest.Item | pytest.Collector]:
154+
return []
155+
156+
157+
def pytest_collect_file(
158+
file_path: Path, parent: pytest.Collector
159+
) -> pytest.Collector | None:
160+
"""
161+
This hook is called when collecting test files, and can be used to
162+
modify the file or test selection logic by returning a list of
163+
``pytest.Item`` objects which the ``pytest`` command will directly
164+
add to the list of test items.
165+
166+
See `pytest documentation <https://docs.pytest.org/en/latest/reference/reference.html#std-hook-pytest_collect_file>`_.
167+
"""
168+
if (
169+
file_path.parent.name == "combinat"
170+
or file_path.parent.parent.name == "combinat"
171+
):
172+
# Crashes CI for some reason
173+
return IgnoreCollector.from_parent(parent)
174+
if file_path.suffix == ".pyx":
175+
# We don't allow pytests to be defined in Cython files.
176+
# Normally, Cython files are filtered out already by pytest and we only
177+
# hit this here if someone explicitly runs `pytest some_file.pyx`.
178+
return IgnoreCollector.from_parent(parent)
179+
elif file_path.suffix == ".py":
180+
if parent.config.option.doctest:
181+
if file_path.name == "__main__.py" or file_path.name == "setup.py":
182+
# We don't allow tests to be defined in __main__.py/setup.py files (because their import will fail).
183+
return IgnoreCollector.from_parent(parent)
184+
if (
185+
(
186+
file_path.name == "postprocess.py"
187+
and file_path.parent.name == "nbconvert"
188+
)
189+
or (
190+
file_path.name == "giacpy-mkkeywords.py"
191+
and file_path.parent.name == "autogen"
192+
)
193+
or (
194+
file_path.name == "flint_autogen.py"
195+
and file_path.parent.name == "autogen"
196+
)
197+
):
198+
# This is an executable file.
199+
return IgnoreCollector.from_parent(parent)
200+
201+
if file_path.name == "conftest_inputtest.py":
202+
# This is an input file for testing the doctest machinery (and contains broken doctests).
203+
return IgnoreCollector.from_parent(parent)
204+
205+
if (
206+
(
207+
file_path.name == "finite_dimensional_lie_algebras_with_basis.py"
208+
and file_path.parent.name == "categories"
209+
)
210+
or (
211+
file_path.name == "__init__.py"
212+
and file_path.parent.name == "crypto"
213+
)
214+
or (file_path.name == "__init__.py" and file_path.parent.name == "mq")
215+
):
216+
# TODO: Fix these (import fails with "RuntimeError: dictionary changed size during iteration")
217+
return IgnoreCollector.from_parent(parent)
218+
219+
if (
220+
file_path.name in ("forker.py", "reporting.py")
221+
) and file_path.parent.name == "doctest":
222+
# Fails with many errors due to different testing framework
223+
return IgnoreCollector.from_parent(parent)
224+
225+
if (
226+
(
227+
file_path.name == "arithgroup_generic.py"
228+
and file_path.parent.name == "arithgroup"
229+
)
230+
or (
231+
file_path.name == "pari.py"
232+
and file_path.parent.name == "lfunctions"
233+
)
234+
or (
235+
file_path.name == "permgroup_named.py"
236+
and file_path.parent.name == "perm_gps"
237+
)
238+
or (
239+
file_path.name == "finitely_generated.py"
240+
and file_path.parent.name == "matrix_gps"
241+
)
242+
or (
243+
file_path.name == "libgap_mixin.py"
244+
and file_path.parent.name == "groups"
245+
)
246+
or (
247+
file_path.name == "finitely_presented.py"
248+
and file_path.parent.name == "groups"
249+
)
250+
or (
251+
file_path.name == "classical_geometries.py"
252+
and file_path.parent.name == "generators"
253+
)
254+
):
255+
# Fails with "Fatal Python error"
256+
return IgnoreCollector.from_parent(parent)
257+
258+
return SageDoctestModule.from_parent(parent, path=file_path)
259+
260+
261+
def pytest_addoption(parser):
262+
# Add a command line option to run doctests
263+
# (we don't use the built-in --doctest-modules option because then doctests are collected twice)
264+
group = parser.getgroup("collect")
265+
group.addoption(
266+
"--doctest",
267+
action="store_true",
268+
default=False,
269+
help="Run doctests in all .py modules",
270+
dest="doctest",
271+
)
272+
273+
274+
# Monkey patch exception printing to replace the full qualified name of the exception by its short name
275+
# TODO: Remove this hack once migration to pytest is complete
276+
import traceback
277+
278+
old_format_exception_only = traceback.format_exception_only
279+
280+
281+
def format_exception_only(etype: type, value: BaseException) -> list[str]:
282+
formatted_exception = old_format_exception_only(etype, value)
283+
exception_name = etype.__name__
284+
if etype.__module__:
285+
exception_full_name = etype.__module__ + "." + etype.__qualname__
286+
else:
287+
exception_full_name = etype.__qualname__
288+
289+
for i, line in enumerate(formatted_exception):
290+
if line.startswith(exception_full_name):
291+
formatted_exception[i] = line.replace(
292+
exception_full_name, exception_name, 1
293+
)
294+
return formatted_exception
295+
296+
297+
# Initialize Sage-specific doctest stuff
298+
init_sage()
299+
300+
# Monkey patch doctest to use our custom printer etc
301+
old_run = doctest.DocTestRunner.run
302+
303+
304+
def doctest_run(
305+
self: doctest.DocTestRunner,
306+
test: doctest.DocTest,
307+
compileflags: Optional[int] = None,
308+
out: Any = None,
309+
clear_globs: bool = True,
310+
) -> doctest.TestResults:
311+
from sage.repl.rich_output import get_display_manager
312+
from sage.repl.user_globals import set_globals
313+
314+
traceback.format_exception_only = format_exception_only
315+
316+
# Display warnings in doctests
317+
warnings.showwarning = showwarning_with_traceback
318+
setattr(sys, "__displayhook__", get_display_manager().displayhook)
319+
320+
# Ensure that injecting globals works as expected in doctests
321+
set_globals(test.globs)
322+
return old_run(self, test, compileflags, out, clear_globs)
323+
324+
325+
doctest.DocTestRunner.run = doctest_run
326+
327+
328+
@pytest.fixture(autouse=True, scope="session")
329+
def add_imports(doctest_namespace: dict[str, Any]):
330+
"""
331+
Add global imports for doctests.
332+
333+
See `pytest documentation <https://docs.pytest.org/en/stable/doctest.html#doctest-namespace-fixture>`.
334+
"""
335+
# Inject sage.all into each doctest
336+
import sage.repl.ipython_kernel.all_jupyter
337+
338+
dict_all = sage.repl.ipython_kernel.all_jupyter.__dict__
339+
340+
# Remove '__package__' item from the globals since it is not
341+
# always in the globals in an actual Sage session.
342+
dict_all.pop("__package__", None)
343+
344+
sage_namespace = dict(dict_all)
345+
sage_namespace["__name__"] = "__main__"
346+
347+
doctest_namespace.update(**sage_namespace)

pyproject.toml

+9
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,15 @@ platforms = [
9494
'osx-64', 'linux-64', 'linux-aarch64', 'osx-arm64'
9595
]
9696

97+
[tool.pytest.ini_options]
98+
python_files = "*_test.py"
99+
norecursedirs = "local prefix venv build builddir pkgs .git src/doc src/bin src/sage_setup/autogen/flint tools"
100+
# The "no:warnings" is to stop pytest from capturing warnings so that they are printed to the output of the doctest
101+
addopts = "--import-mode importlib -p no:warnings"
102+
doctest_optionflags = "NORMALIZE_WHITESPACE ELLIPSIS"
103+
# https://docs.pytest.org/en/stable/reference/reference.html#confval-consider_namespace_packages
104+
consider_namespace_packages = true
105+
97106
# External dependencies in the format proposed by https://peps.python.org/pep-0725
98107
[external]
99108
build-requires = [

0 commit comments

Comments
 (0)