Skip to content
Merged
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
165 changes: 96 additions & 69 deletions scripts/fix_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,20 @@

How to use:
1. Copy a specific test from the CPython repository to the RustPython repository.
2. Remove all unexpected failures from the test and skip the tests that hang
3. Run python ./scripts/fix_test.py --test test_venv --path ./Lib/test/test_venv.py or equivalent for the test from the project root.
4. Ensure that there are no unexpected successes in the test.
5. Actually fix the test.
2. Remove all unexpected failures from the test and skip the tests that hang.
3. Build RustPython: cargo build --release
4. Run from the project root:
- For single-file tests: python ./scripts/fix_test.py --path ./Lib/test/test_venv.py
- For package tests: python ./scripts/fix_test.py --path ./Lib/test/test_inspect/test_inspect.py
5. Verify: cargo run --release -- -m test test_venv (should pass with expected failures)
6. Actually fix the tests marked with # TODO: RUSTPYTHON
"""

import argparse
import ast
import itertools
import platform
import sys
Comment on lines 19 to +21
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove unused imports (flake8 F401) to keep the script clean.
itertools (Line 19) and platform (Line 20) are unused per static analysis; sys (Line 21) is used.

 import argparse
 import ast
-import itertools
-import platform
 import sys
 from pathlib import Path
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import itertools
import platform
import sys
import argparse
import ast
import sys
from pathlib import Path
🧰 Tools
🪛 Flake8 (7.3.0)

[error] 19-19: 'itertools' imported but unused

(F401)


[error] 20-20: 'platform' imported but unused

(F401)

🤖 Prompt for AI Agents
In scripts/fix_test.py around lines 19 to 21, the imports include unused modules
causing flake8 F401; remove the unused imports itertools and platform and keep
only the used import sys (i.e., replace the three-import block with a single
import for sys), ensuring any references to those removed modules are also
removed or updated if present elsewhere in the file.

from pathlib import Path


Expand Down Expand Up @@ -58,85 +62,87 @@ def parse_results(result):
in_test_results = True
elif line.startswith("-----------"):
in_test_results = False
if (
in_test_results
and not line.startswith("tests")
and not line.startswith("[")
):
line = line.split(" ")
if line != [] and len(line) > 3:
test = Test()
test.name = line[0]
test.path = line[1].strip("(").strip(")")
test.result = " ".join(line[3:]).lower()
test_results.tests.append(test)
else:
if "== Tests result: " in line:
res = line.split("== Tests result: ")[1]
res = res.split(" ")[0]
test_results.tests_result = res
if in_test_results and " ... " in line:
line = line.strip()
# Skip lines that don't look like test results
if line.startswith("tests") or line.startswith("["):
continue
# Parse: "test_name (path) [subtest] ... RESULT"
parts = line.split(" ... ")
if len(parts) >= 2:
test_info = parts[0]
result_str = parts[-1].lower()
# Only process FAIL or ERROR
if result_str not in ("fail", "error"):
continue
# Extract test name (first word)
first_space = test_info.find(" ")
if first_space > 0:
test = Test()
test.name = test_info[:first_space]
# Extract path from (path)
rest = test_info[first_space:].strip()
if rest.startswith("("):
end_paren = rest.find(")")
if end_paren > 0:
test.path = rest[1:end_paren]
test.result = result_str
test_results.tests.append(test)
elif "== Tests result: " in line:
res = line.split("== Tests result: ")[1]
res = res.split(" ")[0]
test_results.tests_result = res
return test_results


def path_to_test(path) -> list[str]:
return path.split(".")[2:]
# path format: test.module_name[.submodule].ClassName.test_method
# We need [ClassName, test_method] - always the last 2 elements
parts = path.split(".")
return parts[-2:] # Get class name and method name

Comment on lines 98 to 103
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's look at the structure of the repository and locate the file
find . -name "fix_test.py" -type f

Repository: RustPython/RustPython

Length of output: 86


🏁 Script executed:

# Get the file size to see if we can read it entirely
wc -l scripts/fix_test.py

Repository: RustPython/RustPython

Length of output: 88


🏁 Script executed:

# Read the file around the mentioned lines 98-103 and 169-182
cat -n scripts/fix_test.py | head -200

Repository: RustPython/RustPython

Length of output: 8340


🏁 Script executed:

# Search for all calls to path_to_test in the file
rg "path_to_test" scripts/fix_test.py -n

Repository: RustPython/RustPython

Length of output: 157


🏁 Script executed:

# Let's also verify the unittest output format assumption by checking if there are any tests or documentation
find . -name "*.py" -type f -exec grep -l "Run tests sequentially" {} \; 2>/dev/null | head -5

Repository: RustPython/RustPython

Length of output: 86


Bug confirmed: path_to_test() misidentifies test class and method names.

The function discards test.name (the actual method name) and returns the wrong class name. Given unittest's output format test_method (package.module.ClassName) ... RESULT, the parsing at lines 78-88 correctly extracts test.name = "test_method" and test.path = "package.module.ClassName". However, path_to_test(test.path) splits the path and returns parts[-2:] (e.g., ["module", "ClassName"] instead of ["ClassName", "test_method"]), causing find_test_lineno() to search for the wrong class name and use the class name as the method name. This prevents test discovery and causes marked tests to be skipped.

The suggested fix—passing both test.path and test.name to derive [class_name, method_name]—correctly addresses the root cause.

🤖 Prompt for AI Agents
In scripts/fix_test.py around lines 98 to 103, path_to_test currently only takes
the dotted path and returns parts[-2:], which misidentifies class and method;
update path_to_test to accept both the dotted path and the test.name (method
name) and return [ClassName, method_name] (extract the last element of the path
as ClassName and use the provided test.name for method_name), then update all
callers to pass both values so find_test_lineno searches for the correct class
and method.


def modify_test(file: str, test: list[str], for_platform: bool = False) -> str:
def find_test_lineno(file: str, test: list[str]) -> tuple[int, int] | None:
"""Find the line number and column offset of a test function.
Returns (lineno, col_offset) or None if not found.
"""
a = ast.parse(file)
lines = file.splitlines()
fixture = "@unittest.expectedFailure"
for node in ast.walk(a):
if isinstance(node, ast.FunctionDef):
if node.name == test[-1]:
assert not for_platform
indent = " " * node.col_offset
lines.insert(node.lineno - 1, indent + fixture)
lines.insert(node.lineno - 1, indent + "# TODO: RUSTPYTHON")
break
return "\n".join(lines)


def modify_test_v2(file: str, test: list[str], for_platform: bool = False) -> str:
a = ast.parse(file)
lines = file.splitlines()
fixture = "@unittest.expectedFailure"
for key, node in ast.iter_fields(a):
if key == "body":
for i, n in enumerate(node):
for n in node:
match n:
case ast.ClassDef():
if len(test) == 2 and test[0] == n.name:
# look through body for function def
for i, fn in enumerate(n.body):
for fn in n.body:
match fn:
case ast.FunctionDef():
case ast.FunctionDef() | ast.AsyncFunctionDef():
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should consider to make this script to generate the json payload lib_updater is expecting, and (and use its python API to patch the files). both are doing the same thing (ish)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea

if fn.name == test[-1]:
assert not for_platform
indent = " " * fn.col_offset
lines.insert(
fn.lineno - 1, indent + fixture
)
lines.insert(
fn.lineno - 1,
indent + "# TODO: RUSTPYTHON",
)
break
case ast.FunctionDef():
return (fn.lineno, fn.col_offset)
case ast.FunctionDef() | ast.AsyncFunctionDef():
if n.name == test[0] and len(test) == 1:
assert not for_platform
indent = " " * n.col_offset
lines.insert(n.lineno - 1, indent + fixture)
lines.insert(n.lineno - 1, indent + "# TODO: RUSTPYTHON")
break
if i > 500:
exit()
return (n.lineno, n.col_offset)
return None


def apply_modifications(file: str, modifications: list[tuple[int, int]]) -> str:
"""Apply all modifications in reverse order to avoid line number offset issues."""
lines = file.splitlines()
fixture = "@unittest.expectedFailure"
# Sort by line number in descending order
modifications.sort(key=lambda x: x[0], reverse=True)
for lineno, col_offset in modifications:
indent = " " * col_offset
lines.insert(lineno - 1, indent + fixture)
lines.insert(lineno - 1, indent + "# TODO: RUSTPYTHON")
return "\n".join(lines)
Comment on lines +127 to 137
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

apply_modifications() should avoid double-inserting when a test is already marked.
Re-running the script will currently stack # TODO: RUSTPYTHON and @unittest.expectedFailure repeatedly. Consider checking the preceding lines before inserting.

 def apply_modifications(file: str, modifications: list[tuple[int, int]]) -> str:
@@
     for lineno, col_offset in modifications:
         indent = " " * col_offset
-        lines.insert(lineno - 1, indent + fixture)
-        lines.insert(lineno - 1, indent + "# TODO: RUSTPYTHON")
+        insert_at = lineno - 1
+        prev = lines[max(0, insert_at - 3):insert_at]
+        if any(l.strip() == fixture for l in prev):
+            continue
+        lines.insert(insert_at, indent + fixture)
+        lines.insert(insert_at, indent + "# TODO: RUSTPYTHON")
     return "\n".join(lines)
🤖 Prompt for AI Agents
In scripts/fix_test.py around lines 127 to 137, apply_modifications currently
inserts the fixture and TODO unconditionally which causes duplicate lines on
repeated runs; modify the loop to check the existing surrounding lines before
inserting (for example examine lines[lineno-2] and lines[lineno-3] safely) and
only insert the "@unittest.expectedFailure" and the "# TODO: RUSTPYTHON" if they
are not already present (use trimmed comparisons and guard against index
errors), preserving indentation and original ordering so rerunning the script is
idempotent.



def run_test(test_name):
print(f"Running test: {test_name}")
rustpython_location = "./target/release/rustpython"
if sys.platform == "win32":
rustpython_location += ".exe"

import subprocess

result = subprocess.run(
Expand All @@ -149,13 +155,34 @@ def run_test(test_name):

if __name__ == "__main__":
args = parse_args()
test_name = args.path.stem
test_path = args.path.resolve()
if not test_path.exists():
print(f"Error: File not found: {test_path}")
sys.exit(1)
test_name = test_path.stem
tests = run_test(test_name)
f = open(args.path).read()
f = test_path.read_text(encoding="utf-8")

# Collect all modifications first (with deduplication for subtests)
modifications = []
seen_tests = set() # Track (class_name, method_name) to avoid duplicates
for test in tests.tests:
if test.result == "fail" or test.result == "error":
print("Modifying test:", test.name)
f = modify_test_v2(f, path_to_test(test.path), args.platform)
with open(args.path, "w") as file:
# TODO: Find validation method, and make --force override it
file.write(f)
test_parts = path_to_test(test.path)
test_key = tuple(test_parts)
if test_key in seen_tests:
continue # Skip duplicate (same test, different subtest)
seen_tests.add(test_key)
location = find_test_lineno(f, test_parts)
if location:
print(f"Modifying test: {test.name} at line {location[0]}")
modifications.append(location)
else:
print(f"Warning: Could not find test: {test.name} ({test_parts})")

# Apply all modifications in reverse order
if modifications:
f = apply_modifications(f, modifications)
test_path.write_text(f, encoding="utf-8")

print(f"Modified {len(modifications)} tests")