Skip to content

Commit 8047bfb

Browse files
authored
Merge pull request #1 from timrid/speedup-tests
Call mypy manually and speedup tests
2 parents c18512f + be6f145 commit 8047bfb

27 files changed

Lines changed: 787 additions & 312 deletions

.github/workflows/python-package.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ on:
1010
jobs:
1111
build:
1212

13-
runs-on: windows-latest
13+
runs-on: ubuntu-latest
1414
strategy:
1515
fail-fast: false
1616
matrix:
@@ -41,3 +41,5 @@ jobs:
4141
- name: Test with pytest
4242
run: |
4343
uv run pytest -v
44+
env:
45+
FORCE_COLOR: "1"

.vscode/settings.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,7 @@
44
"tests"
55
],
66
"python.testing.unittestEnabled": false,
7-
"python.testing.pytestEnabled": true
7+
"python.testing.pytestEnabled": true,
8+
"python-envs.defaultEnvManager": "ms-python.python:venv",
9+
"python-envs.pythonProjects": []
810
}

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ dynamic = ["version"]
3535
[dependency-groups]
3636
dev = [
3737
"pytest",
38-
"mypy==1.15.0",
38+
"mypy==1.19.1",
3939
]
4040

4141
[tool.setuptools_scm]

tests/conftest.py

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,69 @@
11
# Note that this conftest file exists at the package level as it is needed to configure
22
# the JVM for docstrings at the package level.
3-
import os
4-
import pathlib
3+
import logging
4+
import tempfile
5+
import textwrap
6+
from collections.abc import Generator
7+
from pathlib import Path
58

69
import pytest
710

11+
import chaquopy_stubgen
812

9-
pytest_plugins = [
10-
"mypy.test.data",
11-
]
12-
os.environ["MYPY_TEST_PREFIX"] = str(pathlib.Path(__file__).parent / "stubtest")
13+
logger = logging.getLogger(__name__)
1314

14-
ANDROID_JAR = pathlib.Path(__file__).parent / "android-35.jar"
1515

16+
ANDROID_JAR = Path(__file__).parent / "android-35.jar"
1617

17-
@pytest.fixture(autouse=True, scope="session")
18+
19+
@pytest.fixture(scope="session")
1820
def jvm():
1921
import jpype # type: ignore
2022

21-
if not jpype.isJVMStarted():
22-
jpype.startJVM(None, classpath=[ANDROID_JAR], convertStrings=True) # type: ignore
23+
jpype.startJVM(None, classpath=[ANDROID_JAR], convertStrings=True) # type: ignore
2324
import jpype.imports # type: ignore
2425

25-
yield jpype
26+
try:
27+
yield jpype
28+
finally:
29+
jpype.shutdownJVM()
30+
31+
32+
@pytest.fixture(scope="session")
33+
def stub_dir(jvm) -> Generator[Path, None, None]:
34+
# logging.basicConfig(level="DEBUG")
35+
with tempfile.TemporaryDirectory() as tmpdir:
36+
tmpdir = Path(tmpdir)
37+
import java # type: ignore
38+
39+
logger.debug(f"Generating stubs in {tmpdir}...")
40+
41+
chaquopy_stubgen.generate_java_stubs(
42+
[java], # type: ignore
43+
output_dir=tmpdir,
44+
)
45+
46+
# Rename "java-stubs" to "java"
47+
(tmpdir / "java-stubs").rename(tmpdir / "java")
48+
49+
yield tmpdir
50+
51+
52+
@pytest.fixture(scope="session")
53+
def mypy_project_dir(stub_dir: Path) -> Generator[Path, None, None]:
54+
with tempfile.TemporaryDirectory() as tmp:
55+
project_dir = Path(tmp)
56+
with project_dir.joinpath("pyproject.toml").open("w") as f:
57+
f.write(
58+
textwrap.dedent(
59+
f"""\
60+
[tool.mypy]
61+
mypy_path = "{stub_dir.absolute()}"
62+
63+
[[tool.mypy.overrides]]
64+
module = "java.*"
65+
ignore_errors = true
66+
"""
67+
)
68+
)
69+
yield project_dir

tests/mypy_helper.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import io
2+
import json
3+
import logging
4+
import os
5+
import re
6+
import tokenize
7+
from collections.abc import Generator
8+
from contextlib import contextmanager
9+
from pathlib import Path
10+
from typing import Literal, Optional, TypedDict
11+
12+
import mypy.api
13+
14+
logger = logging.getLogger(__name__)
15+
16+
17+
class MypyMessage(TypedDict):
18+
file: str
19+
line: int
20+
column: int
21+
message: str
22+
hint: Optional[str]
23+
code: Optional[str]
24+
severity: Literal["error", "note", "warning"]
25+
26+
27+
@contextmanager
28+
def change_dir(path: Path) -> Generator[None, None, None]:
29+
"""Temporary change the current working directory."""
30+
old = Path.cwd()
31+
try:
32+
os.chdir(path)
33+
yield
34+
finally:
35+
os.chdir(old)
36+
37+
38+
def _parse_mypy_jsonl_output(mypy_stdout: str) -> list[MypyMessage]:
39+
"""Parse mypy's JSONL output into a list of MypyMessage objects."""
40+
messages = []
41+
for line in mypy_stdout.strip().split("\n"):
42+
if line: # Skip empty lines
43+
messages.append(json.loads(line))
44+
return messages
45+
46+
47+
def run_mypy(
48+
project_dir: Path,
49+
test_code: str,
50+
) -> str:
51+
"""Run mypy on the given test code and return the mypy stdout."""
52+
testfile_name = "testfile.py"
53+
with project_dir.joinpath(testfile_name).open("w") as f:
54+
f.write(test_code)
55+
56+
with change_dir(project_dir):
57+
result = mypy.api.run([testfile_name, "--output", "json"])
58+
59+
mypy_stdout, mypy_stderr, mypy_returncode = result
60+
logger.debug(f"mypy stdout: {mypy_stdout}")
61+
logger.debug(f"mypy stderr: {mypy_stderr}")
62+
logger.debug(f"mypy returncode: {mypy_returncode}")
63+
64+
return mypy_stdout
65+
66+
67+
def run_and_assert_mypy(
68+
project_dir: Path,
69+
test_code: str,
70+
expected_output: dict[str, str] | str,
71+
):
72+
"""
73+
Run mypy on the given test code and assert that the output matches the expected output.
74+
75+
The expected_output can be either:
76+
- A dictionary mapping markers like "*1" to expected mypy messages. In this case,
77+
the function will parse the mypy JSON output and compare the messages for each marked line.
78+
- A raw string containing the expected mypy output. In this case, the function will compare
79+
the raw mypy stdout with the expected string.
80+
"""
81+
mypy_stdout = run_mypy(project_dir, test_code)
82+
if isinstance(expected_output, dict):
83+
mypy_output = _parse_mypy_jsonl_output(mypy_stdout)
84+
assert_mypy_json_output(test_code, mypy_output, expected_output)
85+
else:
86+
# some errors like syntax errors are not returned as json message. In
87+
# this case, we just check the raw stdout for the expected error message.
88+
assert expected_output.strip() == mypy_stdout.strip()
89+
90+
91+
def _format_mypy_msg(msg: MypyMessage) -> str:
92+
"""Format a mypy message into a human-readable string, including hints if present."""
93+
formatted_msg = f"{msg['severity']}: {msg['message']}"
94+
if hints := msg.get("hint"):
95+
for hint in hints.splitlines():
96+
formatted_msg += f"\nhint: {hint}"
97+
return formatted_msg
98+
99+
100+
def _parse_marked_lines_with_tokenize(code: str) -> dict[str, int]:
101+
"""Parse the code to find all lines with markers like *1, *2, etc. in comments."""
102+
marked_lines = {}
103+
104+
# Tokenize the code
105+
tokens = tokenize.generate_tokens(io.StringIO(code).readline)
106+
107+
for token in tokens:
108+
if token.type == tokenize.COMMENT:
109+
# token.string contains the comment including the #
110+
match = re.search(r"#\s*\*(\d+)", token.string)
111+
if match:
112+
marker = f"*{match.group(1)}"
113+
marked_lines[marker] = token.start[0] # Line number
114+
115+
return marked_lines
116+
117+
118+
def assert_mypy_json_output(
119+
code: str,
120+
mypy_output: list[MypyMessage],
121+
expected_output: dict[str, str],
122+
):
123+
"""Assert that the mypy output matches the expected output for the given code."""
124+
125+
# Parse the code to find the marked lines
126+
marked_lines: dict[str, int] = _parse_marked_lines_with_tokenize(code)
127+
128+
# Validate that all expected markers are present in the code
129+
missing_markers = set(expected_output.keys()) - set(marked_lines.keys())
130+
if missing_markers:
131+
assert False, f"Expected markers not found in code: {', '.join(sorted(missing_markers))}"
132+
133+
# Group messages by line number
134+
result_by_line: dict[int, list[MypyMessage]] = {}
135+
for msg in mypy_output:
136+
line = msg["line"]
137+
if line not in result_by_line:
138+
result_by_line[line] = []
139+
result_by_line[line].append(msg)
140+
141+
# Compare the expected output with the actual mypy messages for each marked line
142+
for marker, line in marked_lines.items():
143+
expected_msgs = expected_output[marker]
144+
mypy_msgs: list[MypyMessage] | None = result_by_line.pop(line)
145+
assert mypy_msgs is not None and len(mypy_msgs) > 0
146+
147+
if isinstance(expected_msgs, str):
148+
expected_msgs = [expected_msgs]
149+
150+
for expected_msg, msg in zip(expected_msgs, mypy_msgs, strict=True):
151+
actual_msg = _format_mypy_msg(msg)
152+
assert expected_msg.strip() == actual_msg.strip()
153+
154+
if len(result_by_line) > 0:
155+
unexpected_msgs = []
156+
for line, msgs in result_by_line.items():
157+
for msg in msgs:
158+
unexpected_msgs.append(f"line {line}: {_format_mypy_msg(msg)}")
159+
assert False, "Unexpected mypy messages:\n" + "\n".join(unexpected_msgs)

tests/stubtest/README.md

Lines changed: 0 additions & 5 deletions
This file was deleted.

tests/stubtest/test-data/unit/arraylist.test

Lines changed: 0 additions & 42 deletions
This file was deleted.

tests/stubtest/test-data/unit/enummap.test

Lines changed: 0 additions & 21 deletions
This file was deleted.

tests/stubtest/test-data/unit/exception.test

Lines changed: 0 additions & 7 deletions
This file was deleted.

tests/stubtest/test-data/unit/forward_declaration.test

Lines changed: 0 additions & 9 deletions
This file was deleted.

0 commit comments

Comments
 (0)