diff --git a/.gitignore b/.gitignore index 323d81b557b..60ea5f51490 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,6 @@ /config.log /config.status /configure -/conftest* /confdefs.h /m4/sage_spkg_configures.m4 diff --git a/.vscode/settings.json b/.vscode/settings.json index c38aafb376d..b4a993ff7f3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,9 +18,7 @@ }, "python.testing.pytestEnabled": true, "python.testing.pytestArgs": [ - "--rootdir=src/sage", - "-c=src/tox.ini", - "--doctest-modules" + "--doctest" ], "python.testing.unittestEnabled": false, "cSpell.words": [ diff --git a/build/pkgs/sagemath_doc_html/dependencies b/build/pkgs/sagemath_doc_html/dependencies index 40717629a77..ef59cbb539b 100644 --- a/build/pkgs/sagemath_doc_html/dependencies +++ b/build/pkgs/sagemath_doc_html/dependencies @@ -1,4 +1,4 @@ -sagelib sphinx sphinx_copybutton sphinx_inline_tabs pplpy_doc | $(SAGERUNTIME) maxima networkx scipy sympy matplotlib pillow mathjax mpmath ipykernel jupyter_client conway_polynomials tachyon ipywidgets sage_docbuild elliptic_curves furo fpylll graphs +sagelib sphinx sphinx_copybutton sphinx_inline_tabs pplpy_doc | $(SAGERUNTIME) maxima networkx scipy sympy matplotlib pillow mathjax mpmath ipykernel jupyter_client conway_polynomials tachyon ipywidgets sage_docbuild elliptic_curves furo fpylll graphs typing_extensions # Building the documentation has many dependencies, because all # documented modules are imported and because we use matplotlib to diff --git a/conftest.py b/conftest.py new file mode 100644 index 00000000000..5307d7f6233 --- /dev/null +++ b/conftest.py @@ -0,0 +1,347 @@ +# pyright: strict +"""Configuration and fixtures for pytest. + +This file configures pytest and provides some global fixtures. +See https://docs.pytest.org/en/latest/index.html for more details. +""" + +from __future__ import annotations + +import doctest +import inspect +import sys +import warnings +from pathlib import Path +from typing import Any, Iterable, Optional + +import pytest +from _pytest.doctest import ( + DoctestItem, + DoctestModule, + _get_continue_on_failure, + _get_runner, + _is_mocked, + _patch_unwrap_mock_aware, + get_optionflags, +) +from _pytest.pathlib import ImportMode, import_path + +from sage.doctest.forker import ( + init_sage, + showwarning_with_traceback, +) +from sage.doctest.parsing import SageDocTestParser, SageOutputChecker + + +class SageDoctestModule(DoctestModule): + """ + This is essentially a copy of `DoctestModule` from + https://github.com/pytest-dev/pytest/blob/main/src/_pytest/doctest.py. + The only change is that we use `SageDocTestParser` to extract the doctests + and `SageOutputChecker` to verify the output. + """ + + def collect(self) -> Iterable[DoctestItem]: + import doctest + + class MockAwareDocTestFinder(doctest.DocTestFinder): + """A hackish doctest finder that overrides stdlib internals to fix a stdlib bug. + https://github.com/pytest-dev/pytest/issues/3456 + https://bugs.python.org/issue25532 + """ + + def __init__(self) -> None: + super().__init__(parser=SageDocTestParser(set(["sage"]))) + + def _find_lineno(self, obj, source_lines): + """Doctest code does not take into account `@property`, this + is a hackish way to fix it. https://bugs.python.org/issue17446 + Wrapped Doctests will need to be unwrapped so the correct + line number is returned. This will be reported upstream. #8796 + """ + if isinstance(obj, property): + obj = getattr(obj, "fget", obj) + + if hasattr(obj, "__wrapped__"): + # Get the main obj in case of it being wrapped + obj = inspect.unwrap(obj) + + # Type ignored because this is a private function. + return super()._find_lineno( # type:ignore[misc] + obj, + source_lines, + ) + + def _find( + self, tests, obj, name, module, source_lines, globs, seen + ) -> None: + if _is_mocked(obj): + return + with _patch_unwrap_mock_aware(): + # Type ignored because this is a private function. + super()._find( # type:ignore[misc] + tests, obj, name, module, source_lines, globs, seen + ) + + if self.path.name == "conftest.py": + module = self.config.pluginmanager._importconftest( + self.path, + self.config.getoption("importmode"), + rootpath=self.config.rootpath, + consider_namespace_packages=True, + ) + else: + try: + module = import_path( + self.path, + mode=ImportMode.importlib, + root=self.config.rootpath, + consider_namespace_packages=True, + ) + except ImportError as exception: + if self.config.getvalue("doctest_ignore_import_errors"): + pytest.skip("unable to import module %r" % self.path) + else: + if isinstance(exception, ModuleNotFoundError): + # Ignore some missing features/modules for now + # TODO: Remove this once all optional things are using Features + if exception.name in ( + "valgrind", + "rpy2", + "sage.libs.coxeter3.coxeter", + ): + pytest.skip( + f"unable to import module { self.path } due to missing feature { exception.name }" + ) + raise + # Uses internal doctest module parsing mechanism. + finder = MockAwareDocTestFinder() + optionflags = get_optionflags(self.config) + from sage.features import FeatureNotPresentError + + runner = _get_runner( + verbose=False, + optionflags=optionflags, + checker=SageOutputChecker(), + continue_on_failure=_get_continue_on_failure(self.config), + ) + try: + for test in finder.find(module, module.__name__): + if test.examples: # skip empty doctests + yield DoctestItem.from_parent( + self, name=test.name, runner=runner, dtest=test + ) + except FeatureNotPresentError as exception: + pytest.skip( + f"unable to import module { self.path } due to missing feature { exception.feature.name }" + ) + except ModuleNotFoundError as exception: + # TODO: Remove this once all optional things are using Features + pytest.skip( + f"unable to import module { self.path } due to missing module { exception.name }" + ) + + +class IgnoreCollector(pytest.Collector): + """ + Ignore a file. + """ + + def __init__(self, parent: pytest.Collector) -> None: + super().__init__("ignore", parent) + + def collect(self) -> Iterable[pytest.Item | pytest.Collector]: + return [] + + +def pytest_collect_file( + file_path: Path, parent: pytest.Collector +) -> pytest.Collector | None: + """ + This hook is called when collecting test files, and can be used to + modify the file or test selection logic by returning a list of + ``pytest.Item`` objects which the ``pytest`` command will directly + add to the list of test items. + + See `pytest documentation `_. + """ + if ( + file_path.parent.name == "combinat" + or file_path.parent.parent.name == "combinat" + ): + # Crashes CI for some reason + return IgnoreCollector.from_parent(parent) + if file_path.suffix == ".pyx": + # We don't allow pytests to be defined in Cython files. + # Normally, Cython files are filtered out already by pytest and we only + # hit this here if someone explicitly runs `pytest some_file.pyx`. + return IgnoreCollector.from_parent(parent) + elif file_path.suffix == ".py": + if parent.config.option.doctest: + if file_path.name == "__main__.py" or file_path.name == "setup.py": + # We don't allow tests to be defined in __main__.py/setup.py files (because their import will fail). + return IgnoreCollector.from_parent(parent) + if ( + ( + file_path.name == "postprocess.py" + and file_path.parent.name == "nbconvert" + ) + or ( + file_path.name == "giacpy-mkkeywords.py" + and file_path.parent.name == "autogen" + ) + or ( + file_path.name == "flint_autogen.py" + and file_path.parent.name == "autogen" + ) + ): + # This is an executable file. + return IgnoreCollector.from_parent(parent) + + if file_path.name == "conftest_inputtest.py": + # This is an input file for testing the doctest machinery (and contains broken doctests). + return IgnoreCollector.from_parent(parent) + + if ( + ( + file_path.name == "finite_dimensional_lie_algebras_with_basis.py" + and file_path.parent.name == "categories" + ) + or ( + file_path.name == "__init__.py" + and file_path.parent.name == "crypto" + ) + or (file_path.name == "__init__.py" and file_path.parent.name == "mq") + ): + # TODO: Fix these (import fails with "RuntimeError: dictionary changed size during iteration") + return IgnoreCollector.from_parent(parent) + + if ( + file_path.name in ("forker.py", "reporting.py") + ) and file_path.parent.name == "doctest": + # Fails with many errors due to different testing framework + return IgnoreCollector.from_parent(parent) + + if ( + ( + file_path.name == "arithgroup_generic.py" + and file_path.parent.name == "arithgroup" + ) + or ( + file_path.name == "pari.py" + and file_path.parent.name == "lfunctions" + ) + or ( + file_path.name == "permgroup_named.py" + and file_path.parent.name == "perm_gps" + ) + or ( + file_path.name == "finitely_generated.py" + and file_path.parent.name == "matrix_gps" + ) + or ( + file_path.name == "libgap_mixin.py" + and file_path.parent.name == "groups" + ) + or ( + file_path.name == "finitely_presented.py" + and file_path.parent.name == "groups" + ) + or ( + file_path.name == "classical_geometries.py" + and file_path.parent.name == "generators" + ) + ): + # Fails with "Fatal Python error" + return IgnoreCollector.from_parent(parent) + + return SageDoctestModule.from_parent(parent, path=file_path) + + +def pytest_addoption(parser): + # Add a command line option to run doctests + # (we don't use the built-in --doctest-modules option because then doctests are collected twice) + group = parser.getgroup("collect") + group.addoption( + "--doctest", + action="store_true", + default=False, + help="Run doctests in all .py modules", + dest="doctest", + ) + + +# Monkey patch exception printing to replace the full qualified name of the exception by its short name +# TODO: Remove this hack once migration to pytest is complete +import traceback + +old_format_exception_only = traceback.format_exception_only + + +def format_exception_only(etype: type, value: BaseException) -> list[str]: + formatted_exception = old_format_exception_only(etype, value) + exception_name = etype.__name__ + if etype.__module__: + exception_full_name = etype.__module__ + "." + etype.__qualname__ + else: + exception_full_name = etype.__qualname__ + + for i, line in enumerate(formatted_exception): + if line.startswith(exception_full_name): + formatted_exception[i] = line.replace( + exception_full_name, exception_name, 1 + ) + return formatted_exception + + +# Initialize Sage-specific doctest stuff +init_sage() + +# Monkey patch doctest to use our custom printer etc +old_run = doctest.DocTestRunner.run + + +def doctest_run( + self: doctest.DocTestRunner, + test: doctest.DocTest, + compileflags: Optional[int] = None, + out: Any = None, + clear_globs: bool = True, +) -> doctest.TestResults: + from sage.repl.rich_output import get_display_manager + from sage.repl.user_globals import set_globals + + traceback.format_exception_only = format_exception_only + + # Display warnings in doctests + warnings.showwarning = showwarning_with_traceback + setattr(sys, "__displayhook__", get_display_manager().displayhook) + + # Ensure that injecting globals works as expected in doctests + set_globals(test.globs) + return old_run(self, test, compileflags, out, clear_globs) + + +doctest.DocTestRunner.run = doctest_run + + +@pytest.fixture(autouse=True, scope="session") +def add_imports(doctest_namespace: dict[str, Any]): + """ + Add global imports for doctests. + + See `pytest documentation `. + """ + # Inject sage.all into each doctest + import sage.repl.ipython_kernel.all_jupyter + + dict_all = sage.repl.ipython_kernel.all_jupyter.__dict__ + + # Remove '__package__' item from the globals since it is not + # always in the globals in an actual Sage session. + dict_all.pop("__package__", None) + + sage_namespace = dict(dict_all) + sage_namespace["__name__"] = "__main__" + + doctest_namespace.update(**sage_namespace) diff --git a/pyproject.toml b/pyproject.toml index da06db03649..e2b17964399 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,6 +94,15 @@ platforms = [ 'osx-64', 'linux-64', 'linux-aarch64', 'osx-arm64' ] +[tool.pytest.ini_options] +python_files = "*_test.py" +norecursedirs = "local prefix venv build builddir pkgs .git src/doc src/bin src/sage_setup/autogen/flint tools" +# The "no:warnings" is to stop pytest from capturing warnings so that they are printed to the output of the doctest +addopts = "--import-mode importlib -p no:warnings" +doctest_optionflags = "NORMALIZE_WHITESPACE ELLIPSIS" +# https://docs.pytest.org/en/stable/reference/reference.html#confval-consider_namespace_packages +consider_namespace_packages = true + # External dependencies in the format proposed by https://peps.python.org/pep-0725 [external] build-requires = [ diff --git a/src/conftest.py b/src/conftest.py deleted file mode 100644 index 951d2fddfad..00000000000 --- a/src/conftest.py +++ /dev/null @@ -1,184 +0,0 @@ -# pyright: strict -"""Configuration and fixtures for pytest. - -This file configures pytest and provides some global fixtures. -See https://docs.pytest.org/en/latest/index.html for more details. -""" - -from __future__ import annotations - -import inspect -from pathlib import Path -from typing import Any, Iterable - -import pytest -from _pytest.doctest import ( - DoctestItem, - DoctestModule, - _get_continue_on_failure, - _get_runner, - _is_mocked, - _patch_unwrap_mock_aware, - get_optionflags, -) -from _pytest.pathlib import ImportMode, import_path -from sage.doctest.parsing import SageDocTestParser, SageOutputChecker - - -class SageDoctestModule(DoctestModule): - """ - This is essentially a copy of `DoctestModule` from - https://github.com/pytest-dev/pytest/blob/main/src/_pytest/doctest.py. - The only change is that we use `SageDocTestParser` to extract the doctests - and `SageOutputChecker` to verify the output. - """ - - def collect(self) -> Iterable[DoctestItem]: - import doctest - - class MockAwareDocTestFinder(doctest.DocTestFinder): - """A hackish doctest finder that overrides stdlib internals to fix a stdlib bug. - https://github.com/pytest-dev/pytest/issues/3456 - https://bugs.python.org/issue25532 - """ - - def __init__(self) -> None: - super().__init__(parser=SageDocTestParser(set(["sage"]))) - - def _find_lineno(self, obj, source_lines): - """Doctest code does not take into account `@property`, this - is a hackish way to fix it. https://bugs.python.org/issue17446 - Wrapped Doctests will need to be unwrapped so the correct - line number is returned. This will be reported upstream. #8796 - """ - if isinstance(obj, property): - obj = getattr(obj, "fget", obj) - - if hasattr(obj, "__wrapped__"): - # Get the main obj in case of it being wrapped - obj = inspect.unwrap(obj) - - # Type ignored because this is a private function. - return super()._find_lineno( # type:ignore[misc] - obj, - source_lines, - ) - - def _find( - self, tests, obj, name, module, source_lines, globs, seen - ) -> None: - if _is_mocked(obj): - return - with _patch_unwrap_mock_aware(): - - # Type ignored because this is a private function. - super()._find( # type:ignore[misc] - tests, obj, name, module, source_lines, globs, seen - ) - - if self.path.name == "conftest.py": - module = self.config.pluginmanager._importconftest( - self.path, - self.config.getoption("importmode"), - rootpath=self.config.rootpath, - ) - else: - try: - module = import_path( - self.path, - mode=ImportMode.importlib, - root=self.config.rootpath, - consider_namespace_packages=True, - ) - except ImportError: - if self.config.getvalue("doctest_ignore_import_errors"): - pytest.skip("unable to import module %r" % self.path) - else: - raise - # Uses internal doctest module parsing mechanism. - finder = MockAwareDocTestFinder() - optionflags = get_optionflags(self.config) - runner = _get_runner( - verbose=False, - optionflags=optionflags, - checker=SageOutputChecker(), - continue_on_failure=_get_continue_on_failure(self.config), - ) - - for test in finder.find(module, module.__name__): - if test.examples: # skip empty doctests - yield DoctestItem.from_parent( - self, name=test.name, runner=runner, dtest=test - ) - - -class IgnoreCollector(pytest.Collector): - """ - Ignore a file. - """ - def __init__(self, parent: pytest.Collector) -> None: - super().__init__('ignore', parent) - - def collect(self) -> Iterable[pytest.Item | pytest.Collector]: - return [] - - -def pytest_collect_file( - file_path: Path, parent: pytest.Collector -) -> pytest.Collector | None: - """ - This hook is called when collecting test files, and can be used to - modify the file or test selection logic by returning a list of - ``pytest.Item`` objects which the ``pytest`` command will directly - add to the list of test items. - - See `pytest documentation `_. - """ - if file_path.suffix == ".pyx": - # We don't allow pytests to be defined in Cython files. - # Normally, Cython files are filtered out already by pytest and we only - # hit this here if someone explicitly runs `pytest some_file.pyx`. - return IgnoreCollector.from_parent(parent) - elif file_path.suffix == ".py": - if parent.config.option.doctest: - if file_path.name == "__main__.py": - # We don't allow tests to be defined in __main__.py files (because their import will fail). - return IgnoreCollector.from_parent(parent) - if file_path.name == "postprocess.py" and file_path.parent.name == "nbconvert": - # This is an executable file. - return IgnoreCollector.from_parent(parent) - return SageDoctestModule.from_parent(parent, path=file_path) - - -def pytest_addoption(parser): - # Add a command line option to run doctests - # (we don't use the built-in --doctest-modules option because then doctests are collected twice) - group = parser.getgroup("collect") - group.addoption( - "--doctest", - action="store_true", - default=False, - help="Run doctests in all .py modules", - dest="doctest", - ) - - -@pytest.fixture(autouse=True, scope="session") -def add_imports(doctest_namespace: dict[str, Any]): - """ - Add global imports for doctests. - - See `pytest documentation `. - """ - # Inject sage.all into each doctest - import sage.all - dict_all = sage.all.__dict__ - - # Remove '__package__' item from the globals since it is not - # always in the globals in an actual Sage session. - dict_all.pop("__package__", None) - - sage_namespace = dict(dict_all) - sage_namespace["__name__"] = "__main__" - - doctest_namespace.update(**sage_namespace) diff --git a/src/sage/doctest/forker.py b/src/sage/doctest/forker.py index bf6d49906de..ded98333f41 100644 --- a/src/sage/doctest/forker.py +++ b/src/sage/doctest/forker.py @@ -45,38 +45,48 @@ # the License, or (at your option) any later version. # https://www.gnu.org/licenses/ # **************************************************************************** +from __future__ import annotations - +import doctest +import errno +import gc +import hashlib +import linecache +import multiprocessing import os import platform +import re +import signal import sys +import tempfile import time -import signal -import linecache -import hashlib -import multiprocessing -import warnings -import re -import errno -import doctest import traceback -import tempfile +import typing +import warnings from collections import defaultdict from dis import findlinestarts from queue import Empty -import gc + import IPython.lib.pretty -import sage.misc.randstate as randstate -from sage.misc.timing import walltime -from .util import Timer, RecordingDict, count_noun -from .sources import DictAsObject -from .parsing import OriginalSource, reduce_hex -from sage.structure.sage_object import SageObject -from .parsing import SageOutputChecker, pre_hash, get_source, unparse_optional_tags -from sage.repl.user_globals import set_globals from sage.cpython.atexit import restore_atexit from sage.cpython.string import bytes_to_str, str_to_bytes +from sage.doctest.parsing import ( + OriginalSource, + SageOutputChecker, + get_source, + pre_hash, + reduce_hex, + unparse_optional_tags, +) +from sage.doctest.sources import DictAsObject +from sage.doctest.util import RecordingDict, Timer, count_noun +from sage.misc import randstate +from sage.repl.user_globals import set_globals +from sage.structure.sage_object import SageObject + +if typing.TYPE_CHECKING: + from sage.doctest.control import DocTestController # With OS X, Python 3.8 defaults to use 'spawn' instead of 'fork' in # multiprocessing, and Sage doctesting doesn't work with 'spawn'. See @@ -113,7 +123,7 @@ def inner(obj, p, cycle): return inner -def init_sage(controller=None): +def init_sage(controller: DocTestController | None = None) -> None: """ Import the Sage library. @@ -1710,7 +1720,7 @@ class DocTestDispatcher(SageObject): Create parallel :class:`DocTestWorker` processes and dispatches doctesting tasks. """ - def __init__(self, controller): + def __init__(self, controller: DocTestController): """ INPUT: diff --git a/src/sage/repl/rich_output/display_manager.py b/src/sage/repl/rich_output/display_manager.py index 2d1be377cad..6e282f8aee5 100644 --- a/src/sage/repl/rich_output/display_manager.py +++ b/src/sage/repl/rich_output/display_manager.py @@ -32,18 +32,26 @@ # the License, or (at your option) any later version. # https://www.gnu.org/licenses/ # **************************************************************************** - +from __future__ import annotations import warnings +from typing import Any + +try: + from typing import Self # type: ignore (Python >= 3.11) +except ImportError: + from typing_extensions import Self # type: ignore (Python 3.9, 3.10) -from sage.structure.sage_object import SageObject from sage.repl.rich_output.output_basic import ( - OutputPlainText, OutputAsciiArt, OutputUnicodeArt, OutputLatex, + OutputAsciiArt, + OutputPlainText, + OutputUnicodeArt, ) from sage.repl.rich_output.output_browser import ( OutputHtml, ) from sage.repl.rich_output.preferences import DisplayPreferences +from sage.structure.sage_object import SageObject class DisplayException(Exception): @@ -185,7 +193,7 @@ def __exit__(self, exception_type, value, traceback): class DisplayManager(SageObject): - _instance = None + _instance: Self | None = None def __init__(self): """ @@ -205,7 +213,7 @@ def __init__(self): self.switch_backend(BackendSimple()) @classmethod - def get_instance(cls): + def get_instance(cls) -> Self: """ Get the singleton instance. @@ -771,7 +779,7 @@ def supported_output(self): """ return self._supported_output - def displayhook(self, obj): + def displayhook(self, obj: Any) -> None | Any: """ Implementation of the displayhook. diff --git a/src/sage/repl/user_globals.py b/src/sage/repl/user_globals.py index 444c0af1405..9e2090b8224 100644 --- a/src/sage/repl/user_globals.py +++ b/src/sage/repl/user_globals.py @@ -102,7 +102,7 @@ def get_globals(): return user_globals -def set_globals(g): +def set_globals(g: dict) -> None: """ Set the dictionary of all user globals to ``g``. diff --git a/src/tox.ini b/src/tox.ini index 68a64b498d5..f7b20398939 100644 --- a/src/tox.ini +++ b/src/tox.ini @@ -376,14 +376,6 @@ exclude = sage/graphs/graph_plot.py sage/misc/sagedoc.py -[pytest] -python_files = *_test.py -norecursedirs = local prefix venv build builddir pkgs .git src/doc src/bin tools -addopts = --import-mode importlib -doctest_optionflags = NORMALIZE_WHITESPACE ELLIPSIS -# https://docs.pytest.org/en/stable/reference/reference.html#confval-consider_namespace_packages -consider_namespace_packages = True - [coverage:run] source = sage concurrency = multiprocessing,thread