Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions .github/workflows/sympy-stub-retirement.yml
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions tests/sympy_analysis_smoke.py
Original file line number Diff line number Diff line change
@@ -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, {})
92 changes: 92 additions & 0 deletions tests/test_compare_sympy_typing.py
Original file line number Diff line number Diff line change
@@ -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
33 changes: 33 additions & 0 deletions utils/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading
Loading