diff --git a/.github/workflows/sympy-stub-retirement.yml b/.github/workflows/sympy-stub-retirement.yml new file mode 100644 index 00000000..5009bf21 --- /dev/null +++ b/.github/workflows/sympy-stub-retirement.yml @@ -0,0 +1,82 @@ +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 os + import pathlib + report = json.loads(pathlib.Path("artifacts/sympy-typing.json").read_text()) + stub_root = pathlib.Path(report["stub_root"]) + 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 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") + 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 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 " + "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 + if: always() + 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..901efc3a --- /dev/null +++ b/tests/sympy_analysis_smoke.py @@ -0,0 +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") +power = Pow(x, 2) +result = simplify(powsimp(power)) +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..86db1888 --- /dev/null +++ b/tests/test_compare_sympy_typing.py @@ -0,0 +1,92 @@ +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, 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)) + + +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 + + +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 + + +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/README.md b/utils/README.md index f6f198a7..1fc2fb48 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 provides value that structural comparison cannot detect. diff --git a/utils/compare_sympy_typing.py b/utils/compare_sympy_typing.py new file mode 100644 index 00000000..d4a65a1e --- /dev/null +++ b/utils/compare_sympy_typing.py @@ -0,0 +1,215 @@ +"""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, 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.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 != "*" + } | {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: + 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" + ) + 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} + + visit(tree.body) + return found, dynamic, reexports + + +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, 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 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": + 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: + 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) or 'No material declarations were found.'}") + 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") + 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 + + +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..53372b50 --- /dev/null +++ b/utils/run_sympy_analysis_smoke.py @@ -0,0 +1,31 @@ +"""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() + 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: + return result.returncode + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())