From 79ea4ed67b610c72d6d8443141a8b4c132498a72 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:02:37 +0000 Subject: [PATCH 1/6] Initial plan From 3b7893de9ffe82a07f4cbc26f1a359dc8443123f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:08:13 +0000 Subject: [PATCH 2/6] Add SymPy stub retirement automation Co-authored-by: bschnurr <1946977+bschnurr@users.noreply.github.com> --- .github/workflows/sympy-stub-retirement.yml | 79 ++++++++ tests/sympy_analysis_smoke.py | 10 + tests/test_compare_sympy_typing.py | 76 ++++++++ utils/README.md | 33 ++++ utils/compare_sympy_typing.py | 192 ++++++++++++++++++++ utils/run_sympy_analysis_smoke.py | 28 +++ 6 files changed, 418 insertions(+) create mode 100644 .github/workflows/sympy-stub-retirement.yml create mode 100644 tests/sympy_analysis_smoke.py create mode 100644 tests/test_compare_sympy_typing.py create mode 100644 utils/compare_sympy_typing.py create mode 100644 utils/run_sympy_analysis_smoke.py diff --git a/.github/workflows/sympy-stub-retirement.yml b/.github/workflows/sympy-stub-retirement.yml new file mode 100644 index 00000000..8cb297a5 --- /dev/null +++ b/.github/workflows/sympy-stub-retirement.yml @@ -0,0 +1,79 @@ +name: SymPy stub retirement + +on: + schedule: + - cron: "23 4 1 * *" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + compare: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + - name: Install comparison dependencies + run: python -m pip install --group tests --upgrade sympy + - name: Compare inline SymPy typing + run: | + python utils/compare_sympy_typing.py \ + --sympy-root "$(python -c 'import sympy; print(sympy.__path__[0])')" \ + --json-out artifacts/sympy-typing.json \ + --markdown-out artifacts/sympy-typing.md + - name: Run protected-stub Pyright smoke test + run: python utils/run_sympy_analysis_smoke.py --max-seconds 60 + - name: Prepare safe removals + id: removals + run: | + python - <<'PY' + import json + import pathlib + report = json.loads(pathlib.Path("artifacts/sympy-typing.json").read_text()) + paths = [pathlib.Path("stubs/sympy-stubs") / path for path in report["candidates"]] + for path in paths: + path.unlink() + for directory in sorted({path.parent for path in paths}, reverse=True): + if directory.exists() and not any(directory.iterdir()): + directory.rmdir() + pathlib.Path("artifacts/removed-files.txt").write_text( + "\n".join(str(path) for path in paths) + ("\n" if paths else "") + ) + with pathlib.Path(__import__("os").environ["GITHUB_OUTPUT"]).open("a") as output: + output.write(f"count={len(paths)}\n") + pathlib.Path("artifacts/pull-request-body.md").write_text( + "Automated monthly SymPy typing comparison.\n\n" + f"SymPy version: `{report['sympy_version']}`.\n" + "Removed files:\n" + + "".join(f"- `{path}`\n" for path in paths) + + f"\nWorkflow run: {__import__('os').environ['GITHUB_SERVER_URL']}/" + f"{__import__('os').environ['GITHUB_REPOSITORY']}/actions/runs/" + f"{__import__('os').environ['GITHUB_RUN_ID']}.\n\n" + "A file is removed only when every material declaration maps by name and kind " + "to an upstream declaration with annotations on every non-`self`/`cls` parameter " + "and its return value. Classes are structurally matched and their explicit members " + "are independently checked. Dynamic and re-exported declarations are retained.\n\n" + "**Warning:** static annotation coverage does not prove equivalent Pyright/Pylance " + "analysis performance. The historical performance-workaround files are excluded " + "from automated deletion.\n" + ) + PY + - uses: actions/upload-artifact@v4 + with: + name: sympy-typing-report + path: artifacts/ + - name: Create or update retirement pull request + if: steps.removals.outputs.count != '0' + uses: peter-evans/create-pull-request@v7 + with: + branch: automation/sympy-stub-retirement + delete-branch: true + commit-message: Retire covered SymPy partial stubs + title: Retire covered SymPy partial stubs + body-path: artifacts/pull-request-body.md diff --git a/tests/sympy_analysis_smoke.py b/tests/sympy_analysis_smoke.py new file mode 100644 index 00000000..e16c86a0 --- /dev/null +++ b/tests/sympy_analysis_smoke.py @@ -0,0 +1,10 @@ +from sympy import Symbol +from sympy.core.evalf import evalf +from sympy.core.power import Pow +from sympy.simplify.powsimp import powsimp +from sympy.simplify.simplify import simplify + +x = Symbol("x") # type: ignore[no-untyped-call] +power = Pow(x, 2) +result = simplify(powsimp(x)) # type: ignore[no-untyped-call] +evalf(result, 53, {}) diff --git a/tests/test_compare_sympy_typing.py b/tests/test_compare_sympy_typing.py new file mode 100644 index 00000000..e594b056 --- /dev/null +++ b/tests/test_compare_sympy_typing.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +from typing import Any, cast + +SCRIPT = Path(__file__).parent.parent / "utils" / "compare_sympy_typing.py" +SPEC = importlib.util.spec_from_file_location("compare_sympy_typing", SCRIPT) +assert SPEC and SPEC.loader +comparison: Any = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(comparison) + + +def _compare(tmp_path: Path, stub: str, source: str) -> dict[str, Any]: + stub_root = tmp_path / "stubs" + sympy_root = tmp_path / "sympy" + (stub_root / "sample.pyi").parent.mkdir(parents=True) + sympy_root.mkdir() + (stub_root / "sample.pyi").write_text(stub) + (sympy_root / "sample.py").write_text(source) + return cast("dict[str, Any]", comparison.compare_file(stub_root / "sample.pyi", stub_root, sympy_root)) + + +def test_overloads_methods_properties_classes_and_attributes_are_covered(tmp_path: Path) -> None: + result = _compare( + tmp_path, + """\ +from typing import overload +x: int +@overload +def parse(value: int) -> int: ... +@overload +def parse(value: str) -> str: ... +class Item: + def method(self, value: int) -> str: ... + @property + def name(self) -> str: ... +""", + """\ +x: int = 1 +def parse(value: int | str) -> int | str: + return value +class Item: + def method(self, value: int) -> str: + return str(value) + @property + def name(self) -> str: + return "item" +""", + ) + assert result["candidate"] is True + assert {item["status"] for item in result["declarations"]} == {comparison.TYPED} + + +def test_missing_annotations_and_names_retain_file(tmp_path: Path) -> None: + result = _compare( + tmp_path, + "def typed(value: int) -> str: ...\ndef absent(value: int) -> str: ...\n", + "def typed(value):\n return str(value)\n", + ) + statuses = {item["name"]: item["status"] for item in result["declarations"]} + assert result["candidate"] is False + assert statuses == { + "typed": comparison.UNTYPED, + "absent": comparison.MISSING, + } + + +def test_dynamic_or_reexported_source_is_not_a_candidate(tmp_path: Path) -> None: + result = _compare( + tmp_path, + "def exported(value: int) -> str: ...\n", + "from somewhere import exported\n", + ) + assert result["candidate"] is False + assert result["declarations"][0]["status"] == comparison.DYNAMIC diff --git a/utils/README.md b/utils/README.md index f6f198a7..afd295a0 100644 --- a/utils/README.md +++ b/utils/README.md @@ -12,3 +12,36 @@ This folder contains scripts that may be useful when creating stubs. Normally `useless-import-alias` only applies to `.py`. So this is achieved by temporarily renaming all private module type stubs. You can run a script with --help for more detailed argument help. + +## SymPy partial-stub retirement + +`compare_sympy_typing.py` compares the partial SymPy `.pyi` files with an installed +SymPy release without importing the modules: + +```shell +python utils/compare_sympy_typing.py \ + --sympy-root "$(python -c 'import sympy; print(sympy.__path__[0])')" \ + --json-out artifacts/sympy-typing.json \ + --markdown-out artifacts/sympy-typing.md +``` + +A file is a candidate only if every material function, async function, property, +class member, and explicitly annotated module attribute has a same-named, +same-kind source declaration. Functions and properties must annotate every +non-`self`/`cls` parameter (including variadics) and their return. Classes are +structural declarations; their explicit members are checked independently. +Imports, `__getattr__`, missing source modules, and declaration-kind mismatches +are conservatively retained. This deliberately does not assert semantic type +equivalence from matching names alone. `--check` makes candidates fail CI; +ordinary reports exit successfully when stubs still need to be retained. + +The monthly **SymPy stub retirement** workflow uploads both reports and runs +`python utils/run_sympy_analysis_smoke.py --max-seconds 60`. The 60-second +threshold is deliberately generous: it detects gross analyzer regressions, not +small timing changes. It never +automatically removes `core/evalf.pyi`, `core/power.pyi`, `simplify/powsimp.pyi`, +or `simplify/simplify.pyi`, which were performance workarounds. To assess those, +run the smoke command with and without the relevant stub and compare its reported +elapsed time manually; timing is intentionally not an automatic deletion gate. +Add another relative `.pyi` path to +`PROTECTED_FILES` when a stub has value structural comparison cannot detect. diff --git a/utils/compare_sympy_typing.py b/utils/compare_sympy_typing.py new file mode 100644 index 00000000..c9e431d5 --- /dev/null +++ b/utils/compare_sympy_typing.py @@ -0,0 +1,192 @@ +"""Conservatively compare the partial SymPy stubs with inline SymPy annotations.""" + +from __future__ import annotations + +import argparse +import ast +import importlib.metadata +import json +import sys +from pathlib import Path +from typing import Any + +PROTECTED_FILES = { + "core/evalf.pyi", + "core/power.pyi", + "simplify/powsimp.pyi", + "simplify/simplify.pyi", +} +TYPED = "first_party_typed" +UNTYPED = "present_but_insufficiently_annotated" +MISSING = "missing_or_unmappable" +DYNAMIC = "dynamic_or_reexported" + + +def _is_overload(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: + return any( + (isinstance(decorator, ast.Name) and decorator.id == "overload") + or (isinstance(decorator, ast.Attribute) and decorator.attr == "overload") + for decorator in node.decorator_list + ) + + +def _function_is_typed(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: + arguments = (*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs) + if arguments and arguments[0].arg in {"self", "cls"}: + arguments = arguments[1:] + return ( + node.returns is not None + and all(argument.annotation is not None for argument in arguments) + and (node.args.vararg is None or node.args.vararg.annotation is not None) + and (node.args.kwarg is None or node.args.kwarg.annotation is not None) + ) + + +def _declaration_name(node: ast.AST, prefix: str = "") -> str | None: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + return f"{prefix}{node.name}" + if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): + return f"{prefix}{node.target.id}" + return None + + +def declarations(tree: ast.Module) -> tuple[dict[str, dict[str, Any]], bool]: + """Return material declarations and whether the module has dynamic exports.""" + found: dict[str, dict[str, Any]] = {} + dynamic = any( + (isinstance(node, ast.FunctionDef) and node.name == "__getattr__") or isinstance(node, (ast.Import, ast.ImportFrom)) + for node in tree.body + ) + + def visit(nodes: list[ast.stmt], prefix: str = "") -> None: + for node in nodes: + name = _declaration_name(node, prefix) + if name is None: + continue + if isinstance(node, ast.ClassDef): + found[name] = {"kind": "class", "typed": True, "overload": False} + visit(node.body, f"{name}.") + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + kind = ( + "property" + if any(isinstance(decorator, ast.Name) and decorator.id == "property" for decorator in node.decorator_list) + else "async_function" + if isinstance(node, ast.AsyncFunctionDef) + else "function" + ) + found[name] = {"kind": kind, "typed": _function_is_typed(node), "overload": _is_overload(node)} + else: + found[name] = {"kind": "attribute", "typed": True, "overload": False} + + visit(tree.body) + return found, dynamic + + +def _source_path(sympy_root: Path, relative_stub: Path) -> Path | None: + relative = relative_stub.with_suffix(".py") + candidates = (sympy_root / relative, sympy_root / relative.parent / relative.stem / "__init__.py") + return next((path for path in candidates if path.is_file()), None) + + +def compare_file(stub_path: Path, stub_root: Path, sympy_root: Path) -> dict[str, Any]: + relative = stub_path.relative_to(stub_root) + source_path = _source_path(sympy_root, relative) + stub_declarations, _ = declarations(ast.parse(stub_path.read_text(encoding="utf-8"), filename=str(stub_path))) + result: dict[str, Any] = { + "stub_file": relative.as_posix(), + "source_file": str(source_path) if source_path else None, + "protected": relative.as_posix() in PROTECTED_FILES, + "declarations": [], + } + if source_path is None: + result["declarations"] = [ + {"name": name, **declaration, "status": MISSING, "reason": "No corresponding source module was found."} + for name, declaration in stub_declarations.items() + ] + result["candidate"] = False + return result + + source_declarations, source_dynamic = declarations( + ast.parse(source_path.read_text(encoding="utf-8"), filename=str(source_path)) + ) + for name, declaration in stub_declarations.items(): + source = source_declarations.get(name) + if source is None: + status = DYNAMIC if source_dynamic else MISSING + reason = "Source module has dynamic exports." if source_dynamic else "No same-named source declaration was found." + elif source["kind"] != declaration["kind"]: + status, reason = MISSING, "The same name has a different declaration kind upstream." + elif declaration["kind"] == "class": + status, reason = TYPED, "The class exists; its explicit members are compared separately." + elif source["typed"]: + status, reason = TYPED, "The same declaration has annotations for every parameter and return value." + else: + status, reason = UNTYPED, "The same declaration lacks a parameter or return annotation." + result["declarations"].append({"name": name, **declaration, "status": status, "reason": reason}) + result["candidate"] = bool(result["declarations"]) and all( + declaration["status"] == TYPED for declaration in result["declarations"] + ) + return result + + +def compare_tree(stub_root: Path, sympy_root: Path) -> dict[str, Any]: + files = [compare_file(path, stub_root, sympy_root) for path in sorted(stub_root.rglob("*.pyi"))] + return { + "stub_root": str(stub_root.resolve()), + "sympy_root": str(sympy_root.resolve()), + "sympy_version": importlib.metadata.version("sympy"), + "protected_files": sorted(PROTECTED_FILES), + "files": files, + "candidates": [file["stub_file"] for file in files if file["candidate"] and not file["protected"]], + "protected_candidates": [file["stub_file"] for file in files if file["candidate"] and file["protected"]], + } + + +def markdown(report: dict[str, Any]) -> str: + lines = [ + "# SymPy partial-stub comparison", + "", + f"* SymPy: `{report['sympy_version']}`", + f"* Stub root: `{report['stub_root']}`", + f"* Installed SymPy root: `{report['sympy_root']}`", + "", + "## Automated removal candidates", + "", + ] + candidates = report["candidates"] + lines.extend([f"- `{path}`" for path in candidates] or ["None."]) + lines.extend(["", "## Protected performance files", ""]) + for file in report["files"]: + if file["protected"]: + lines.append(f"- `{file['stub_file']}`: Protected performance workaround; never deleted automatically.") + lines.extend(["", "## Other retained files", ""]) + for file in report["files"]: + if file["candidate"] or file["protected"]: + continue + reasons = sorted({declaration["reason"] for declaration in file["declarations"] if declaration["status"] != TYPED}) + lines.append(f"- `{file['stub_file']}`: {' '.join(reasons)}") + return "\n".join(lines) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--stub-root", type=Path, default=Path("stubs/sympy-stubs")) + parser.add_argument("--sympy-root", type=Path, required=True) + parser.add_argument("--json-out", type=Path, required=True) + parser.add_argument("--markdown-out", type=Path, required=True) + parser.add_argument("--check", action="store_true", help="Fail when a non-protected candidate is found.") + args = parser.parse_args() + try: + report = compare_tree(args.stub_root, args.sympy_root) + args.json_out.parent.mkdir(parents=True, exist_ok=True) + args.markdown_out.parent.mkdir(parents=True, exist_ok=True) + args.json_out.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + args.markdown_out.write_text(markdown(report), encoding="utf-8") + except (OSError, SyntaxError, importlib.metadata.PackageNotFoundError) as error: + print(f"comparison failed: {error}", file=sys.stderr) + return 2 + return int(args.check and bool(report["candidates"])) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/utils/run_sympy_analysis_smoke.py b/utils/run_sympy_analysis_smoke.py new file mode 100644 index 00000000..070c4d45 --- /dev/null +++ b/utils/run_sympy_analysis_smoke.py @@ -0,0 +1,28 @@ +"""Run the protected SymPy Pyright analysis smoke test with a generous timeout.""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +import time +from pathlib import Path + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--max-seconds", type=float, default=60.0) + parser.add_argument("--fixture", type=Path, default=Path("tests/sympy_analysis_smoke.py")) + args = parser.parse_args() + started = time.monotonic() + result = subprocess.run((sys.executable, "-m", "pyright", str(args.fixture)), check=False) + elapsed = time.monotonic() - started + print(f"Pyright smoke test completed in {elapsed:.1f}s (limit: {args.max_seconds:.1f}s).") + if elapsed > args.max_seconds: + print("Pyright smoke test exceeded the generous gross-regression threshold.", file=sys.stderr) + return 1 + return result.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) From 74dd4153bf2531b2cdd84a41b216b7cc4daea126 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:09:45 +0000 Subject: [PATCH 3/6] Address SymPy automation review feedback Co-authored-by: bschnurr <1946977+bschnurr@users.noreply.github.com> --- .github/workflows/sympy-stub-retirement.yml | 8 ++++---- utils/README.md | 2 +- utils/compare_sympy_typing.py | 2 +- utils/run_sympy_analysis_smoke.py | 4 +++- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/sympy-stub-retirement.yml b/.github/workflows/sympy-stub-retirement.yml index 8cb297a5..705063f0 100644 --- a/.github/workflows/sympy-stub-retirement.yml +++ b/.github/workflows/sympy-stub-retirement.yml @@ -34,6 +34,7 @@ jobs: run: | python - <<'PY' import json + import os import pathlib report = json.loads(pathlib.Path("artifacts/sympy-typing.json").read_text()) paths = [pathlib.Path("stubs/sympy-stubs") / path for path in report["candidates"]] @@ -45,16 +46,15 @@ jobs: pathlib.Path("artifacts/removed-files.txt").write_text( "\n".join(str(path) for path in paths) + ("\n" if paths else "") ) - with pathlib.Path(__import__("os").environ["GITHUB_OUTPUT"]).open("a") as output: + with pathlib.Path(os.environ["GITHUB_OUTPUT"]).open("a") as output: output.write(f"count={len(paths)}\n") pathlib.Path("artifacts/pull-request-body.md").write_text( "Automated monthly SymPy typing comparison.\n\n" f"SymPy version: `{report['sympy_version']}`.\n" "Removed files:\n" + "".join(f"- `{path}`\n" for path in paths) - + f"\nWorkflow run: {__import__('os').environ['GITHUB_SERVER_URL']}/" - f"{__import__('os').environ['GITHUB_REPOSITORY']}/actions/runs/" - f"{__import__('os').environ['GITHUB_RUN_ID']}.\n\n" + + f"\nWorkflow run: {os.environ['GITHUB_SERVER_URL']}/" + f"{os.environ['GITHUB_REPOSITORY']}/actions/runs/{os.environ['GITHUB_RUN_ID']}.\n\n" "A file is removed only when every material declaration maps by name and kind " "to an upstream declaration with annotations on every non-`self`/`cls` parameter " "and its return value. Classes are structurally matched and their explicit members " diff --git a/utils/README.md b/utils/README.md index afd295a0..e2c324f7 100644 --- a/utils/README.md +++ b/utils/README.md @@ -44,4 +44,4 @@ or `simplify/simplify.pyi`, which were performance workarounds. To assess those, run the smoke command with and without the relevant stub and compare its reported elapsed time manually; timing is intentionally not an automatic deletion gate. Add another relative `.pyi` path to -`PROTECTED_FILES` when a stub has value structural comparison cannot detect. +`PROTECTED_FILES` when a stub provides value structural comparison cannot detect. diff --git a/utils/compare_sympy_typing.py b/utils/compare_sympy_typing.py index c9e431d5..5e9901da 100644 --- a/utils/compare_sympy_typing.py +++ b/utils/compare_sympy_typing.py @@ -164,7 +164,7 @@ def markdown(report: dict[str, Any]) -> str: if file["candidate"] or file["protected"]: continue reasons = sorted({declaration["reason"] for declaration in file["declarations"] if declaration["status"] != TYPED}) - lines.append(f"- `{file['stub_file']}`: {' '.join(reasons)}") + lines.append(f"- `{file['stub_file']}`: {' '.join(reasons) or 'No material declarations were found.'}") return "\n".join(lines) + "\n" diff --git a/utils/run_sympy_analysis_smoke.py b/utils/run_sympy_analysis_smoke.py index 070c4d45..d1467d23 100644 --- a/utils/run_sympy_analysis_smoke.py +++ b/utils/run_sympy_analysis_smoke.py @@ -18,10 +18,12 @@ def main() -> int: result = subprocess.run((sys.executable, "-m", "pyright", str(args.fixture)), check=False) elapsed = time.monotonic() - started print(f"Pyright smoke test completed in {elapsed:.1f}s (limit: {args.max_seconds:.1f}s).") + if result.returncode: + return result.returncode if elapsed > args.max_seconds: print("Pyright smoke test exceeded the generous gross-regression threshold.", file=sys.stderr) return 1 - return result.returncode + return 0 if __name__ == "__main__": From f7bb5099cf2e7853a990e7670f850a4d1e88d4cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:11:41 +0000 Subject: [PATCH 4/6] Refine SymPy comparison conservatism Co-authored-by: bschnurr <1946977+bschnurr@users.noreply.github.com> --- .github/workflows/sympy-stub-retirement.yml | 3 +- tests/sympy_analysis_smoke.py | 6 ++-- tests/test_compare_sympy_typing.py | 6 ++++ utils/README.md | 2 +- utils/compare_sympy_typing.py | 38 +++++++++++++++------ 5 files changed, 41 insertions(+), 14 deletions(-) diff --git a/.github/workflows/sympy-stub-retirement.yml b/.github/workflows/sympy-stub-retirement.yml index 705063f0..d54f8818 100644 --- a/.github/workflows/sympy-stub-retirement.yml +++ b/.github/workflows/sympy-stub-retirement.yml @@ -37,7 +37,8 @@ jobs: import os import pathlib report = json.loads(pathlib.Path("artifacts/sympy-typing.json").read_text()) - paths = [pathlib.Path("stubs/sympy-stubs") / path for path in report["candidates"]] + stub_root = pathlib.Path(report["stub_root"]) + paths = [stub_root / path for path in report["candidates"]] for path in paths: path.unlink() for directory in sorted({path.parent for path in paths}, reverse=True): diff --git a/tests/sympy_analysis_smoke.py b/tests/sympy_analysis_smoke.py index e16c86a0..bfbb8255 100644 --- a/tests/sympy_analysis_smoke.py +++ b/tests/sympy_analysis_smoke.py @@ -1,10 +1,12 @@ +# mypy: disable-error-code=no-untyped-call + from sympy import Symbol from sympy.core.evalf import evalf from sympy.core.power import Pow from sympy.simplify.powsimp import powsimp from sympy.simplify.simplify import simplify -x = Symbol("x") # type: ignore[no-untyped-call] +x = Symbol("x") power = Pow(x, 2) -result = simplify(powsimp(x)) # type: ignore[no-untyped-call] +result = simplify(powsimp(x)) evalf(result, 53, {}) diff --git a/tests/test_compare_sympy_typing.py b/tests/test_compare_sympy_typing.py index e594b056..4a69344c 100644 --- a/tests/test_compare_sympy_typing.py +++ b/tests/test_compare_sympy_typing.py @@ -74,3 +74,9 @@ def test_dynamic_or_reexported_source_is_not_a_candidate(tmp_path: Path) -> None ) assert result["candidate"] is False assert result["declarations"][0]["status"] == comparison.DYNAMIC + + +def test_class_without_explicit_stub_members_is_not_covered(tmp_path: Path) -> None: + result = _compare(tmp_path, "class Item: ...\n", "class Item:\n pass\n") + assert result["candidate"] is False + assert result["declarations"][0]["status"] == comparison.UNTYPED diff --git a/utils/README.md b/utils/README.md index e2c324f7..1fc2fb48 100644 --- a/utils/README.md +++ b/utils/README.md @@ -44,4 +44,4 @@ or `simplify/simplify.pyi`, which were performance workarounds. To assess those, run the smoke command with and without the relevant stub and compare its reported elapsed time manually; timing is intentionally not an automatic deletion gate. Add another relative `.pyi` path to -`PROTECTED_FILES` when a stub provides value structural comparison cannot detect. +`PROTECTED_FILES` when a stub provides value that structural comparison cannot detect. diff --git a/utils/compare_sympy_typing.py b/utils/compare_sympy_typing.py index 5e9901da..88d05d38 100644 --- a/utils/compare_sympy_typing.py +++ b/utils/compare_sympy_typing.py @@ -50,13 +50,21 @@ def _declaration_name(node: ast.AST, prefix: str = "") -> str | None: return None -def declarations(tree: ast.Module) -> tuple[dict[str, dict[str, Any]], bool]: - """Return material declarations and whether the module has dynamic exports.""" +def declarations(tree: ast.Module) -> tuple[dict[str, dict[str, Any]], bool, set[str]]: + """Return material declarations, dynamic-export state, and direct re-exports.""" found: dict[str, dict[str, Any]] = {} dynamic = any( - (isinstance(node, ast.FunctionDef) and node.name == "__getattr__") or isinstance(node, (ast.Import, ast.ImportFrom)) + (isinstance(node, ast.FunctionDef) and node.name == "__getattr__") + or (isinstance(node, ast.ImportFrom) and any(name.name == "*" for name in node.names)) for node in tree.body ) + reexports = { + name.asname or name.name + for node in tree.body + if isinstance(node, ast.ImportFrom) + for name in node.names + if name.name != "*" + } def visit(nodes: list[ast.stmt], prefix: str = "") -> None: for node in nodes: @@ -79,7 +87,7 @@ def visit(nodes: list[ast.stmt], prefix: str = "") -> None: found[name] = {"kind": "attribute", "typed": True, "overload": False} visit(tree.body) - return found, dynamic + return found, dynamic, reexports def _source_path(sympy_root: Path, relative_stub: Path) -> Path | None: @@ -91,7 +99,7 @@ def _source_path(sympy_root: Path, relative_stub: Path) -> Path | None: def compare_file(stub_path: Path, stub_root: Path, sympy_root: Path) -> dict[str, Any]: relative = stub_path.relative_to(stub_root) source_path = _source_path(sympy_root, relative) - stub_declarations, _ = declarations(ast.parse(stub_path.read_text(encoding="utf-8"), filename=str(stub_path))) + stub_declarations, _, _ = declarations(ast.parse(stub_path.read_text(encoding="utf-8"), filename=str(stub_path))) result: dict[str, Any] = { "stub_file": relative.as_posix(), "source_file": str(source_path) if source_path else None, @@ -106,18 +114,28 @@ def compare_file(stub_path: Path, stub_root: Path, sympy_root: Path) -> dict[str result["candidate"] = False return result - source_declarations, source_dynamic = declarations( + source_declarations, source_dynamic, source_reexports = declarations( ast.parse(source_path.read_text(encoding="utf-8"), filename=str(source_path)) ) for name, declaration in stub_declarations.items(): source = source_declarations.get(name) if source is None: - status = DYNAMIC if source_dynamic else MISSING - reason = "Source module has dynamic exports." if source_dynamic else "No same-named source declaration was found." + status = DYNAMIC if source_dynamic or name.split(".", 1)[0] in source_reexports else MISSING + reason = ( + "Source module dynamically exports or directly re-exports this name." + if status == DYNAMIC + else "No same-named source declaration was found." + ) elif source["kind"] != declaration["kind"]: status, reason = MISSING, "The same name has a different declaration kind upstream." elif declaration["kind"] == "class": - status, reason = TYPED, "The class exists; its explicit members are compared separately." + has_members = any(other_name.startswith(f"{name}.") for other_name in stub_declarations) + status = TYPED if has_members else UNTYPED + reason = ( + "The class exists; its explicit members are compared separately." + if has_members + else "A class without explicit stub members cannot establish annotation coverage." + ) elif source["typed"]: status, reason = TYPED, "The same declaration has annotations for every parameter and return value." else: @@ -182,10 +200,10 @@ def main() -> int: args.markdown_out.parent.mkdir(parents=True, exist_ok=True) args.json_out.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") args.markdown_out.write_text(markdown(report), encoding="utf-8") + return int(args.check and bool(report["candidates"])) except (OSError, SyntaxError, importlib.metadata.PackageNotFoundError) as error: print(f"comparison failed: {error}", file=sys.stderr) return 2 - return int(args.check and bool(report["candidates"])) if __name__ == "__main__": From d8201cf8820d2c853a92d96f19542017175d9955 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:13:09 +0000 Subject: [PATCH 5/6] Harden SymPy retirement automation Co-authored-by: bschnurr <1946977+bschnurr@users.noreply.github.com> --- .github/workflows/sympy-stub-retirement.yml | 7 ++++--- tests/test_compare_sympy_typing.py | 14 ++++++++++++-- utils/compare_sympy_typing.py | 9 +++++++-- utils/run_sympy_analysis_smoke.py | 6 +++++- 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/.github/workflows/sympy-stub-retirement.yml b/.github/workflows/sympy-stub-retirement.yml index d54f8818..cf7d3e98 100644 --- a/.github/workflows/sympy-stub-retirement.yml +++ b/.github/workflows/sympy-stub-retirement.yml @@ -38,14 +38,15 @@ jobs: import pathlib report = json.loads(pathlib.Path("artifacts/sympy-typing.json").read_text()) stub_root = pathlib.Path(report["stub_root"]) - paths = [stub_root / path for path in report["candidates"]] + relative_paths = [pathlib.Path(path) for path in report["candidates"]] + paths = [stub_root / path for path in relative_paths] for path in paths: path.unlink() for directory in sorted({path.parent for path in paths}, reverse=True): if directory.exists() and not any(directory.iterdir()): directory.rmdir() pathlib.Path("artifacts/removed-files.txt").write_text( - "\n".join(str(path) for path in paths) + ("\n" if paths else "") + "\n".join(str(path) for path in relative_paths) + ("\n" if relative_paths else "") ) with pathlib.Path(os.environ["GITHUB_OUTPUT"]).open("a") as output: output.write(f"count={len(paths)}\n") @@ -53,7 +54,7 @@ jobs: "Automated monthly SymPy typing comparison.\n\n" f"SymPy version: `{report['sympy_version']}`.\n" "Removed files:\n" - + "".join(f"- `{path}`\n" for path in paths) + + "".join(f"- `{path}`\n" for path in relative_paths) + f"\nWorkflow run: {os.environ['GITHUB_SERVER_URL']}/" f"{os.environ['GITHUB_REPOSITORY']}/actions/runs/{os.environ['GITHUB_RUN_ID']}.\n\n" "A file is removed only when every material declaration maps by name and kind " diff --git a/tests/test_compare_sympy_typing.py b/tests/test_compare_sympy_typing.py index 4a69344c..86db1888 100644 --- a/tests/test_compare_sympy_typing.py +++ b/tests/test_compare_sympy_typing.py @@ -16,8 +16,8 @@ def _compare(tmp_path: Path, stub: str, source: str) -> dict[str, Any]: sympy_root = tmp_path / "sympy" (stub_root / "sample.pyi").parent.mkdir(parents=True) sympy_root.mkdir() - (stub_root / "sample.pyi").write_text(stub) - (sympy_root / "sample.py").write_text(source) + (stub_root / "sample.pyi").write_text(stub, encoding="utf-8") + (sympy_root / "sample.py").write_text(source, encoding="utf-8") return cast("dict[str, Any]", comparison.compare_file(stub_root / "sample.pyi", stub_root, sympy_root)) @@ -80,3 +80,13 @@ def test_class_without_explicit_stub_members_is_not_covered(tmp_path: Path) -> N result = _compare(tmp_path, "class Item: ...\n", "class Item:\n pass\n") assert result["candidate"] is False assert result["declarations"][0]["status"] == comparison.UNTYPED + + +def test_each_overload_must_be_typed(tmp_path: Path) -> None: + result = _compare( + tmp_path, + "from typing import overload\n@overload\ndef parse(value: int) -> int: ...\n@overload\ndef parse(value: str) -> str: ...\n", + "from typing import overload\n@overload\ndef parse(value) -> int: ...\n@overload\ndef parse(value: str) -> str: ...\n", + ) + assert result["candidate"] is False + assert result["declarations"][0]["status"] == comparison.UNTYPED diff --git a/utils/compare_sympy_typing.py b/utils/compare_sympy_typing.py index 88d05d38..d4a65a1e 100644 --- a/utils/compare_sympy_typing.py +++ b/utils/compare_sympy_typing.py @@ -64,7 +64,7 @@ def declarations(tree: ast.Module) -> tuple[dict[str, dict[str, Any]], bool, set if isinstance(node, ast.ImportFrom) for name in node.names if name.name != "*" - } + } | {name.asname or name.name.split(".", 1)[0] for node in tree.body if isinstance(node, ast.Import) for name in node.names} def visit(nodes: list[ast.stmt], prefix: str = "") -> None: for node in nodes: @@ -82,7 +82,12 @@ def visit(nodes: list[ast.stmt], prefix: str = "") -> None: if isinstance(node, ast.AsyncFunctionDef) else "function" ) - found[name] = {"kind": kind, "typed": _function_is_typed(node), "overload": _is_overload(node)} + previous = found.get(name) + found[name] = { + "kind": kind, + "typed": _function_is_typed(node) and (previous is None or previous["typed"]), + "overload": _is_overload(node) or (previous is not None and previous["overload"]), + } else: found[name] = {"kind": "attribute", "typed": True, "overload": False} diff --git a/utils/run_sympy_analysis_smoke.py b/utils/run_sympy_analysis_smoke.py index d1467d23..11fdf4e8 100644 --- a/utils/run_sympy_analysis_smoke.py +++ b/utils/run_sympy_analysis_smoke.py @@ -15,7 +15,11 @@ def main() -> int: parser.add_argument("--fixture", type=Path, default=Path("tests/sympy_analysis_smoke.py")) args = parser.parse_args() started = time.monotonic() - result = subprocess.run((sys.executable, "-m", "pyright", str(args.fixture)), check=False) + try: + result = subprocess.run((sys.executable, "-m", "pyright", str(args.fixture)), check=False, timeout=args.max_seconds) + except subprocess.TimeoutExpired: + print("Pyright smoke test exceeded the generous gross-regression threshold.", file=sys.stderr) + return 1 elapsed = time.monotonic() - started print(f"Pyright smoke test completed in {elapsed:.1f}s (limit: {args.max_seconds:.1f}s).") if result.returncode: From a60361928c71ef205706491dd218dbf4d9b62f94 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:14:20 +0000 Subject: [PATCH 6/6] Finalize SymPy retirement validation Co-authored-by: bschnurr <1946977+bschnurr@users.noreply.github.com> --- .github/workflows/sympy-stub-retirement.yml | 1 + tests/sympy_analysis_smoke.py | 2 +- utils/run_sympy_analysis_smoke.py | 3 --- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/sympy-stub-retirement.yml b/.github/workflows/sympy-stub-retirement.yml index cf7d3e98..5009bf21 100644 --- a/.github/workflows/sympy-stub-retirement.yml +++ b/.github/workflows/sympy-stub-retirement.yml @@ -67,6 +67,7 @@ jobs: ) PY - uses: actions/upload-artifact@v4 + if: always() with: name: sympy-typing-report path: artifacts/ diff --git a/tests/sympy_analysis_smoke.py b/tests/sympy_analysis_smoke.py index bfbb8255..901efc3a 100644 --- a/tests/sympy_analysis_smoke.py +++ b/tests/sympy_analysis_smoke.py @@ -8,5 +8,5 @@ x = Symbol("x") power = Pow(x, 2) -result = simplify(powsimp(x)) +result = simplify(powsimp(power)) evalf(result, 53, {}) diff --git a/utils/run_sympy_analysis_smoke.py b/utils/run_sympy_analysis_smoke.py index 11fdf4e8..53372b50 100644 --- a/utils/run_sympy_analysis_smoke.py +++ b/utils/run_sympy_analysis_smoke.py @@ -24,9 +24,6 @@ def main() -> int: print(f"Pyright smoke test completed in {elapsed:.1f}s (limit: {args.max_seconds:.1f}s).") if result.returncode: return result.returncode - if elapsed > args.max_seconds: - print("Pyright smoke test exceeded the generous gross-regression threshold.", file=sys.stderr) - return 1 return 0