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