From 8d1029c6f83aafed27c77527f9bb5b78d9b16a15 Mon Sep 17 00:00:00 2001 From: 5iri Date: Fri, 8 Aug 2025 15:28:48 +0530 Subject: [PATCH 1/5] cayley bug resolved --- conf36956.file | 1 + conf37000.file | 1 + conf37044.file | 1 + conf37088.file | 1 + conf37132.file | 1 + conf37178.file | 1 + conf37222 | 1 + conf37222.file | 1 + conf37267.file | 1 + conf37310.file | 1 + conf37354.file | 1 + conf37398 | 1 + conf37398.file | 1 + conf37442 | 1 + conf37442.file | 1 + conftest.py | 343 -------------------------- src/sage/groups/finitely_presented.py | 32 +++ 17 files changed, 47 insertions(+), 343 deletions(-) create mode 100644 conf36956.file create mode 100644 conf37000.file create mode 100644 conf37044.file create mode 100644 conf37088.file create mode 100644 conf37132.file create mode 100644 conf37178.file create mode 120000 conf37222 create mode 100644 conf37222.file create mode 100644 conf37267.file create mode 100644 conf37310.file create mode 100644 conf37354.file create mode 120000 conf37398 create mode 100644 conf37398.file create mode 120000 conf37442 create mode 100644 conf37442.file delete mode 100644 conftest.py diff --git a/conf36956.file b/conf36956.file new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/conf36956.file @@ -0,0 +1 @@ + diff --git a/conf37000.file b/conf37000.file new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/conf37000.file @@ -0,0 +1 @@ + diff --git a/conf37044.file b/conf37044.file new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/conf37044.file @@ -0,0 +1 @@ + diff --git a/conf37088.file b/conf37088.file new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/conf37088.file @@ -0,0 +1 @@ + diff --git a/conf37132.file b/conf37132.file new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/conf37132.file @@ -0,0 +1 @@ + diff --git a/conf37178.file b/conf37178.file new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/conf37178.file @@ -0,0 +1 @@ + diff --git a/conf37222 b/conf37222 new file mode 120000 index 00000000000..31b38be0b1f --- /dev/null +++ b/conf37222 @@ -0,0 +1 @@ +conf37222.file \ No newline at end of file diff --git a/conf37222.file b/conf37222.file new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/conf37222.file @@ -0,0 +1 @@ + diff --git a/conf37267.file b/conf37267.file new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/conf37267.file @@ -0,0 +1 @@ + diff --git a/conf37310.file b/conf37310.file new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/conf37310.file @@ -0,0 +1 @@ + diff --git a/conf37354.file b/conf37354.file new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/conf37354.file @@ -0,0 +1 @@ + diff --git a/conf37398 b/conf37398 new file mode 120000 index 00000000000..b785131a234 --- /dev/null +++ b/conf37398 @@ -0,0 +1 @@ +conf37398.file \ No newline at end of file diff --git a/conf37398.file b/conf37398.file new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/conf37398.file @@ -0,0 +1 @@ + diff --git a/conf37442 b/conf37442 new file mode 120000 index 00000000000..9d97f9c1832 --- /dev/null +++ b/conf37442 @@ -0,0 +1 @@ +conf37442.file \ No newline at end of file diff --git a/conf37442.file b/conf37442.file new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/conf37442.file @@ -0,0 +1 @@ + diff --git a/conftest.py b/conftest.py deleted file mode 100644 index 98fc948afa7..00000000000 --- a/conftest.py +++ /dev/null @@ -1,343 +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 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 == "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/src/sage/groups/finitely_presented.py b/src/sage/groups/finitely_presented.py index b65566ebeb4..f998d631856 100644 --- a/src/sage/groups/finitely_presented.py +++ b/src/sage/groups/finitely_presented.py @@ -313,6 +313,38 @@ def Tietze(self): tl = self.gap().UnderlyingElement().TietzeWordAbstractWord() return tuple(tl.sage()) + def __hash__(self): + """ + Return the hash of the element. + + This uses a canonical form obtained from a confluent rewriting system + to ensure that equal elements have equal hashes, which is required + by the Python hash contract. + + EXAMPLES:: + + sage: F. = FreeGroup() + sage: G = F / [x^4, y^13, x*y*x^-1*y^-5] + sage: a, b = G.gens() + sage: elem1 = b^3 + sage: elem2 = b^-10 # This equals b^3 since b^13 = 1 + sage: elem1 == elem2 + True + sage: hash(elem1) == hash(elem2) + True + """ + try: + # Try to get cached confluent rewriting system from parent + if not hasattr(self.parent(), '_confluent_rewriting_system'): + rs = self.parent().rewriting_system() + rs.make_confluent() + self.parent()._confluent_rewriting_system = rs + canonical_form = self.parent()._confluent_rewriting_system.reduce(self) + return hash(str(canonical_form)) + except Exception: + # Fallback to the parent class hash if rewriting system fails + return super().__hash__() + def __call__(self, *values, **kwds): """ Replace the generators of the free group with ``values``. From 6ab25a0eb93381532d3ce3f05fc6ebe21b6fdc33 Mon Sep 17 00:00:00 2001 From: 5iri Date: Sun, 10 Aug 2025 17:10:09 +0530 Subject: [PATCH 2/5] added conftest.py again --- conf36956.file | 1 - conf37000.file | 1 - conf37044.file | 1 - conf37088.file | 1 - conf37132.file | 1 - conf37178.file | 1 - conf37222 | 1 - conf37222.file | 1 - conf37267.file | 1 - conf37310.file | 1 - conf37354.file | 1 - conf37398 | 1 - conf37398.file | 1 - conf37442 | 1 - conf37442.file | 1 - conftest.py | 343 +++++++++++++++++++++++++++++++++++++++++++++++++ 16 files changed, 343 insertions(+), 15 deletions(-) delete mode 100644 conf36956.file delete mode 100644 conf37000.file delete mode 100644 conf37044.file delete mode 100644 conf37088.file delete mode 100644 conf37132.file delete mode 100644 conf37178.file delete mode 120000 conf37222 delete mode 100644 conf37222.file delete mode 100644 conf37267.file delete mode 100644 conf37310.file delete mode 100644 conf37354.file delete mode 120000 conf37398 delete mode 100644 conf37398.file delete mode 120000 conf37442 delete mode 100644 conf37442.file create mode 100644 conftest.py diff --git a/conf36956.file b/conf36956.file deleted file mode 100644 index 8b137891791..00000000000 --- a/conf36956.file +++ /dev/null @@ -1 +0,0 @@ - diff --git a/conf37000.file b/conf37000.file deleted file mode 100644 index 8b137891791..00000000000 --- a/conf37000.file +++ /dev/null @@ -1 +0,0 @@ - diff --git a/conf37044.file b/conf37044.file deleted file mode 100644 index 8b137891791..00000000000 --- a/conf37044.file +++ /dev/null @@ -1 +0,0 @@ - diff --git a/conf37088.file b/conf37088.file deleted file mode 100644 index 8b137891791..00000000000 --- a/conf37088.file +++ /dev/null @@ -1 +0,0 @@ - diff --git a/conf37132.file b/conf37132.file deleted file mode 100644 index 8b137891791..00000000000 --- a/conf37132.file +++ /dev/null @@ -1 +0,0 @@ - diff --git a/conf37178.file b/conf37178.file deleted file mode 100644 index 8b137891791..00000000000 --- a/conf37178.file +++ /dev/null @@ -1 +0,0 @@ - diff --git a/conf37222 b/conf37222 deleted file mode 120000 index 31b38be0b1f..00000000000 --- a/conf37222 +++ /dev/null @@ -1 +0,0 @@ -conf37222.file \ No newline at end of file diff --git a/conf37222.file b/conf37222.file deleted file mode 100644 index 8b137891791..00000000000 --- a/conf37222.file +++ /dev/null @@ -1 +0,0 @@ - diff --git a/conf37267.file b/conf37267.file deleted file mode 100644 index 8b137891791..00000000000 --- a/conf37267.file +++ /dev/null @@ -1 +0,0 @@ - diff --git a/conf37310.file b/conf37310.file deleted file mode 100644 index 8b137891791..00000000000 --- a/conf37310.file +++ /dev/null @@ -1 +0,0 @@ - diff --git a/conf37354.file b/conf37354.file deleted file mode 100644 index 8b137891791..00000000000 --- a/conf37354.file +++ /dev/null @@ -1 +0,0 @@ - diff --git a/conf37398 b/conf37398 deleted file mode 120000 index b785131a234..00000000000 --- a/conf37398 +++ /dev/null @@ -1 +0,0 @@ -conf37398.file \ No newline at end of file diff --git a/conf37398.file b/conf37398.file deleted file mode 100644 index 8b137891791..00000000000 --- a/conf37398.file +++ /dev/null @@ -1 +0,0 @@ - diff --git a/conf37442 b/conf37442 deleted file mode 120000 index 9d97f9c1832..00000000000 --- a/conf37442 +++ /dev/null @@ -1 +0,0 @@ -conf37442.file \ No newline at end of file diff --git a/conf37442.file b/conf37442.file deleted file mode 100644 index 8b137891791..00000000000 --- a/conf37442.file +++ /dev/null @@ -1 +0,0 @@ - diff --git a/conftest.py b/conftest.py new file mode 100644 index 00000000000..98fc948afa7 --- /dev/null +++ b/conftest.py @@ -0,0 +1,343 @@ +# 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 == "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) From 6e759eec647ea6b992675d725eaf8c68ab2996fa Mon Sep 17 00:00:00 2001 From: 5iri Date: Tue, 12 Aug 2025 12:27:10 +0530 Subject: [PATCH 3/5] added doctests --- src/sage/groups/finitely_presented.py | 70 +++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/src/sage/groups/finitely_presented.py b/src/sage/groups/finitely_presented.py index f998d631856..2abcbb3ad72 100644 --- a/src/sage/groups/finitely_presented.py +++ b/src/sage/groups/finitely_presented.py @@ -332,6 +332,76 @@ def __hash__(self): True sage: hash(elem1) == hash(elem2) True + + Test that the hash is consistent with Cayley graph construction:: + + sage: F. = FreeGroup() + sage: G = F / [x^2, y^3, (x*y)^4] + sage: a, b = G.gens() + sage: # Test that equal elements have equal hashes + sage: elem1 = a * b * a + sage: elem2 = b^2 # Should be equal due to relations + sage: if elem1 == elem2: + ....: assert hash(elem1) == hash(elem2), "Equal elements must have equal hashes" + + sage: # Test with a simpler group to ensure Cayley graph works + sage: F. = FreeGroup() + sage: H = F / [a^4] + sage: CG_simple = H.cayley_graph() + sage: len(CG_simple.vertices(sort=False)) == H.order() + True + + Test hash consistency for the identity and inverses:: + + sage: F. = FreeGroup() + sage: G = F / [a^3, b^2, (a*b)^2] + sage: # Identity element + sage: id1 = G.one() + sage: id2 = G([]) + sage: hash(id1) == hash(id2) + True + + TESTS:: + + Test that hash works with various group presentations:: + + sage: # Dihedral group D_4 + sage: F. = FreeGroup() + sage: D4 = F / [r^4, s^2, s*r*s*r] + sage: elements = [D4.one(), D4([1]), D4([2]), D4([1,2])] + sage: hashes = [hash(e) for e in elements] + sage: len(set(hashes)) == len(set(elements)) # Distinct elements should have distinct hashes when possible + True + + Test hash consistency with group operations:: + + sage: F. = FreeGroup() + sage: G = F / [x^2, y^2, (x*y)^3] + sage: a, b = G.gens() + sage: # Test that mathematically equal elements have same hash + sage: elem1 = a * b * a * b * a * b # This should equal identity due to (ab)^3 = 1 + sage: elem2 = G.one() + sage: if elem1 == elem2: # Only test hash equality if elements are actually equal + ....: assert hash(elem1) == hash(elem2) + + Test specific Cayley graph bug with semidirect product Z_4 ⋊ Z_13:: + + sage: F. = FreeGroup() + sage: G = F / [x^4, y^13, x*y*x^-1*y^-5] + sage: a, b = G.gens() + sage: G.order() == 52 + True + sage: a.order() == 4 + True + sage: b.order() == 13 + True + sage: a*b*a^-1 == b^5 # isomorphic to semidirect product of Z_4 and Z_13 + True + sage: # Test that Cayley graph has correct number of vertices + sage: gr = G.cayley_graph(generators=[a,b]).to_undirected() + sage: gr.num_verts() == G.order() # Should be 52, not 109 + True + """ try: # Try to get cached confluent rewriting system from parent From 445374d456b46653c4124857429c760937a19027 Mon Sep 17 00:00:00 2001 From: 5iri Date: Fri, 15 Aug 2025 21:04:32 +0530 Subject: [PATCH 4/5] reverted change from `finitely_presented.py` moved the changes to `free_group.py` --- src/sage/groups/finitely_presented.py | 102 -------------------------- src/sage/groups/free_group.py | 95 ++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 102 deletions(-) diff --git a/src/sage/groups/finitely_presented.py b/src/sage/groups/finitely_presented.py index 2abcbb3ad72..b65566ebeb4 100644 --- a/src/sage/groups/finitely_presented.py +++ b/src/sage/groups/finitely_presented.py @@ -313,108 +313,6 @@ def Tietze(self): tl = self.gap().UnderlyingElement().TietzeWordAbstractWord() return tuple(tl.sage()) - def __hash__(self): - """ - Return the hash of the element. - - This uses a canonical form obtained from a confluent rewriting system - to ensure that equal elements have equal hashes, which is required - by the Python hash contract. - - EXAMPLES:: - - sage: F. = FreeGroup() - sage: G = F / [x^4, y^13, x*y*x^-1*y^-5] - sage: a, b = G.gens() - sage: elem1 = b^3 - sage: elem2 = b^-10 # This equals b^3 since b^13 = 1 - sage: elem1 == elem2 - True - sage: hash(elem1) == hash(elem2) - True - - Test that the hash is consistent with Cayley graph construction:: - - sage: F. = FreeGroup() - sage: G = F / [x^2, y^3, (x*y)^4] - sage: a, b = G.gens() - sage: # Test that equal elements have equal hashes - sage: elem1 = a * b * a - sage: elem2 = b^2 # Should be equal due to relations - sage: if elem1 == elem2: - ....: assert hash(elem1) == hash(elem2), "Equal elements must have equal hashes" - - sage: # Test with a simpler group to ensure Cayley graph works - sage: F. = FreeGroup() - sage: H = F / [a^4] - sage: CG_simple = H.cayley_graph() - sage: len(CG_simple.vertices(sort=False)) == H.order() - True - - Test hash consistency for the identity and inverses:: - - sage: F. = FreeGroup() - sage: G = F / [a^3, b^2, (a*b)^2] - sage: # Identity element - sage: id1 = G.one() - sage: id2 = G([]) - sage: hash(id1) == hash(id2) - True - - TESTS:: - - Test that hash works with various group presentations:: - - sage: # Dihedral group D_4 - sage: F. = FreeGroup() - sage: D4 = F / [r^4, s^2, s*r*s*r] - sage: elements = [D4.one(), D4([1]), D4([2]), D4([1,2])] - sage: hashes = [hash(e) for e in elements] - sage: len(set(hashes)) == len(set(elements)) # Distinct elements should have distinct hashes when possible - True - - Test hash consistency with group operations:: - - sage: F. = FreeGroup() - sage: G = F / [x^2, y^2, (x*y)^3] - sage: a, b = G.gens() - sage: # Test that mathematically equal elements have same hash - sage: elem1 = a * b * a * b * a * b # This should equal identity due to (ab)^3 = 1 - sage: elem2 = G.one() - sage: if elem1 == elem2: # Only test hash equality if elements are actually equal - ....: assert hash(elem1) == hash(elem2) - - Test specific Cayley graph bug with semidirect product Z_4 ⋊ Z_13:: - - sage: F. = FreeGroup() - sage: G = F / [x^4, y^13, x*y*x^-1*y^-5] - sage: a, b = G.gens() - sage: G.order() == 52 - True - sage: a.order() == 4 - True - sage: b.order() == 13 - True - sage: a*b*a^-1 == b^5 # isomorphic to semidirect product of Z_4 and Z_13 - True - sage: # Test that Cayley graph has correct number of vertices - sage: gr = G.cayley_graph(generators=[a,b]).to_undirected() - sage: gr.num_verts() == G.order() # Should be 52, not 109 - True - - """ - try: - # Try to get cached confluent rewriting system from parent - if not hasattr(self.parent(), '_confluent_rewriting_system'): - rs = self.parent().rewriting_system() - rs.make_confluent() - self.parent()._confluent_rewriting_system = rs - canonical_form = self.parent()._confluent_rewriting_system.reduce(self) - return hash(str(canonical_form)) - except Exception: - # Fallback to the parent class hash if rewriting system fails - return super().__hash__() - def __call__(self, *values, **kwds): """ Replace the generators of the free group with ``values``. diff --git a/src/sage/groups/free_group.py b/src/sage/groups/free_group.py index 2324d81538e..d4c490e146b 100644 --- a/src/sage/groups/free_group.py +++ b/src/sage/groups/free_group.py @@ -231,12 +231,107 @@ def __init__(self, parent, x): def __hash__(self): r""" + Return the hash of the element. + + For free group elements, this uses the Tietze representation. + For quotient group elements (finitely presented groups), this uses + a canonical form to ensure equal elements have equal hashes. + TESTS:: sage: G. = FreeGroup() sage: hash(a*b*b*~a) == hash((1, 2, 2, -1)) True + + sage: # Test quotient group hash consistency + sage: F. = FreeGroup() + sage: G = F / [x^4, y^13, x*y*x^-1*y^-5] + sage: a, b = G.gens() + sage: elem1 = b^3 + sage: elem2 = b^-10 # This equals b^3 since b^13 = 1 + sage: elem1 == elem2 + True + sage: hash(elem1) == hash(elem2) + True + + Test that the hash is consistent with Cayley graph construction:: + + sage: F. = FreeGroup() + sage: G = F / [x^2, y^3, (x*y)^4] + sage: a, b = G.gens() + sage: # Test that equal elements have equal hashes + sage: elem1 = a * b * a + sage: elem2 = b^2 # Should be equal due to relations + sage: if elem1 == elem2: + ....: assert hash(elem1) == hash(elem2), "Equal elements must have equal hashes" + + sage: # Test with a simpler group to ensure Cayley graph works + sage: F. = FreeGroup() + sage: H = F / [a^4] + sage: CG_simple = H.cayley_graph() + sage: len(CG_simple.vertices(sort=False)) == H.order() + True + + Test hash consistency for the identity and inverses:: + + sage: F. = FreeGroup() + sage: G = F / [a^3, b^2, (a*b)^2] + sage: # Identity element + sage: id1 = G.one() + sage: id2 = G([]) + sage: hash(id1) == hash(id2) + True + + Test that hash works with various group presentations:: + + sage: # Dihedral group D_4 + sage: F. = FreeGroup() + sage: D4 = F / [r^4, s^2, s*r*s*r] + sage: elements = [D4.one(), D4([1]), D4([2]), D4([1,2])] + sage: hashes = [hash(e) for e in elements] + sage: len(set(hashes)) == len(set(elements)) # Distinct elements should have distinct hashes when possible + True + + Test hash consistency with group operations:: + + sage: F. = FreeGroup() + sage: G = F / [x^2, y^2, (x*y)^3] + sage: a, b = G.gens() + sage: # Test that mathematically equal elements have same hash + sage: elem1 = a * b * a * b * a * b # This should equal identity due to (ab)^3 = 1 + sage: elem2 = G.one() + sage: if elem1 == elem2: # Only test hash equality if elements are actually equal + ....: assert hash(elem1) == hash(elem2) + + Test specific Cayley graph bug with semidirect product `\mathbb{Z}_4 \rtimes \mathbb{Z}_{13}`:: + + sage: F. = FreeGroup() + sage: G = F / [x^4, y^13, x*y*x^-1*y^-5] + sage: a, b = G.gens() + sage: G.order() == 52 + True + sage: a.order() == 4 + True + sage: b.order() == 13 + True + sage: a*b*a^-1 == b^5 # isomorphic to semidirect product of Z_4 and Z_13 + True + sage: # Test that Cayley graph has correct number of vertices + sage: gr = G.cayley_graph(generators=[a,b]).to_undirected() + sage: gr.num_verts() == G.order() # Should be 52, not 109 + True """ + if hasattr(self.parent(), 'relations') and self.parent().relations(): + try: + if not hasattr(self.parent(), '_confluent_rewriting_system'): + rs = self.parent().rewriting_system() + rs.make_confluent() + self.parent()._confluent_rewriting_system = rs + canonical_form = self.parent()._confluent_rewriting_system.reduce(self) + return hash(str(canonical_form)) + except Exception: + pass + return hash(self.Tietze()) def _latex_(self): From 4a06d56ae530149a9cc3c0dd669e0a6748fb3134 Mon Sep 17 00:00:00 2001 From: 5iri Date: Mon, 18 Aug 2025 18:49:20 +0530 Subject: [PATCH 5/5] add specific Exceptions throws to catch interrupts --- src/sage/groups/free_group.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/sage/groups/free_group.py b/src/sage/groups/free_group.py index d4c490e146b..e70566c96d6 100644 --- a/src/sage/groups/free_group.py +++ b/src/sage/groups/free_group.py @@ -302,9 +302,9 @@ def __hash__(self): sage: elem2 = G.one() sage: if elem1 == elem2: # Only test hash equality if elements are actually equal ....: assert hash(elem1) == hash(elem2) - + Test specific Cayley graph bug with semidirect product `\mathbb{Z}_4 \rtimes \mathbb{Z}_{13}`:: - + sage: F. = FreeGroup() sage: G = F / [x^4, y^13, x*y*x^-1*y^-5] sage: a, b = G.gens() @@ -329,7 +329,7 @@ def __hash__(self): self.parent()._confluent_rewriting_system = rs canonical_form = self.parent()._confluent_rewriting_system.reduce(self) return hash(str(canonical_form)) - except Exception: + except (AttributeError, ValueError, RuntimeError, NotImplementedError): pass return hash(self.Tietze())