Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemath
GitHub Repository: sagemath/sage
Path: blob/develop/conftest.py
4052 views
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 (
202
(
203
file_path.name == "finite_dimensional_lie_algebras_with_basis.py"
204
and file_path.parent.name == "categories"
205
)
206
or (
207
file_path.name == "__init__.py"
208
and file_path.parent.name == "crypto"
209
)
210
or (file_path.name == "__init__.py" and file_path.parent.name == "mq")
211
):
212
# TODO: Fix these (import fails with "RuntimeError: dictionary changed size during iteration")
213
return IgnoreCollector.from_parent(parent)
214
215
if (
216
file_path.name in ("forker.py", "reporting.py")
217
) and file_path.parent.name == "doctest":
218
# Fails with many errors due to different testing framework
219
return IgnoreCollector.from_parent(parent)
220
221
if (
222
(
223
file_path.name == "arithgroup_generic.py"
224
and file_path.parent.name == "arithgroup"
225
)
226
or (
227
file_path.name == "pari.py"
228
and file_path.parent.name == "lfunctions"
229
)
230
or (
231
file_path.name == "permgroup_named.py"
232
and file_path.parent.name == "perm_gps"
233
)
234
or (
235
file_path.name == "finitely_generated.py"
236
and file_path.parent.name == "matrix_gps"
237
)
238
or (
239
file_path.name == "libgap_mixin.py"
240
and file_path.parent.name == "groups"
241
)
242
or (
243
file_path.name == "finitely_presented.py"
244
and file_path.parent.name == "groups"
245
)
246
or (
247
file_path.name == "classical_geometries.py"
248
and file_path.parent.name == "generators"
249
)
250
):
251
# Fails with "Fatal Python error"
252
return IgnoreCollector.from_parent(parent)
253
254
return SageDoctestModule.from_parent(parent, path=file_path)
255
256
257
def pytest_addoption(parser):
258
# Add a command line option to run doctests
259
# (we don't use the built-in --doctest-modules option because then doctests are collected twice)
260
group = parser.getgroup("collect")
261
group.addoption(
262
"--doctest",
263
action="store_true",
264
default=False,
265
help="Run doctests in all .py modules",
266
dest="doctest",
267
)
268
269
270
# Monkey patch exception printing to replace the full qualified name of the exception by its short name
271
# TODO: Remove this hack once migration to pytest is complete
272
import traceback
273
274
old_format_exception_only = traceback.format_exception_only
275
276
277
def format_exception_only(etype: type, value: BaseException) -> list[str]:
278
formatted_exception = old_format_exception_only(etype, value)
279
exception_name = etype.__name__
280
if etype.__module__:
281
exception_full_name = etype.__module__ + "." + etype.__qualname__
282
else:
283
exception_full_name = etype.__qualname__
284
285
for i, line in enumerate(formatted_exception):
286
if line.startswith(exception_full_name):
287
formatted_exception[i] = line.replace(
288
exception_full_name, exception_name, 1
289
)
290
return formatted_exception
291
292
293
# Initialize Sage-specific doctest stuff
294
init_sage()
295
296
# Monkey patch doctest to use our custom printer etc
297
old_run = doctest.DocTestRunner.run
298
299
300
def doctest_run(
301
self: doctest.DocTestRunner,
302
test: doctest.DocTest,
303
compileflags: Optional[int] = None,
304
out: Any = None,
305
clear_globs: bool = True,
306
) -> doctest.TestResults:
307
from sage.repl.rich_output import get_display_manager
308
from sage.repl.user_globals import set_globals
309
310
traceback.format_exception_only = format_exception_only
311
312
# Display warnings in doctests
313
warnings.showwarning = showwarning_with_traceback
314
setattr(sys, "__displayhook__", get_display_manager().displayhook)
315
316
# Ensure that injecting globals works as expected in doctests
317
set_globals(test.globs)
318
return old_run(self, test, compileflags, out, clear_globs)
319
320
321
doctest.DocTestRunner.run = doctest_run
322
323
324
@pytest.fixture(autouse=True, scope="session")
325
def add_imports(doctest_namespace: dict[str, Any]):
326
"""
327
Add global imports for doctests.
328
329
See `pytest documentation <https://docs.pytest.org/en/stable/doctest.html#doctest-namespace-fixture>`.
330
"""
331
# Inject sage.all into each doctest
332
import sage.repl.ipython_kernel.all_jupyter
333
334
dict_all = sage.repl.ipython_kernel.all_jupyter.__dict__
335
336
# Remove '__package__' item from the globals since it is not
337
# always in the globals in an actual Sage session.
338
dict_all.pop("__package__", None)
339
340
sage_namespace = dict(dict_all)
341
sage_namespace["__name__"] = "__main__"
342
343
doctest_namespace.update(**sage_namespace)
344
345