Skip to content

Commit 89bade9

Browse files
committed
[GR-68746] Fix loading delvewheel wheels
PullRequest: graalpython/3958
2 parents 6860e4a + 81f5a1d commit 89bade9

File tree

33 files changed

+1647
-125
lines changed

33 files changed

+1647
-125
lines changed

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ workingsets.xml
2121
GRAALPYTHON.dist
2222
GRAALPYTHON_UNIT_TESTS.dist
2323
mx.graalpython/eclipse-launches
24-
*.json
2524
!jbang-catalog.json
2625
!**/resources/*.json
2726
!**/META-INF/**/*.json

graalpython/com.oracle.graal.python.shell/src/com/oracle/graal/python/shell/GraalPythonMain.java

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1167,11 +1167,6 @@ protected void printHelp(OptionCategory maxCategory) {
11671167
"\nEnvironment variables specific to the Graal Python launcher:\n" : ""));
11681168
}
11691169

1170-
@Override
1171-
protected String[] getDefaultLanguages() {
1172-
return new String[]{getLanguageId(), "llvm", "regex"};
1173-
}
1174-
11751170
@Override
11761171
protected void collectArguments(Set<String> options) {
11771172
// This list of arguments is used when we are launched through the Polyglot

graalpython/com.oracle.graal.python.test/src/runner.py

Lines changed: 97 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@
4141
import enum
4242
import fnmatch
4343
import json
44-
import math
4544
import os
4645
import pickle
4746
import platform
@@ -60,6 +59,7 @@
6059
import typing
6160
import unittest
6261
import unittest.loader
62+
import urllib.request
6363
from abc import abstractmethod
6464
from collections import defaultdict
6565
from dataclasses import dataclass, field
@@ -81,6 +81,18 @@
8181
CURRENT_PLATFORM = f'{sys.platform}-{platform.machine()}'
8282
CURRENT_PLATFORM_KEYS = frozenset({CURRENT_PLATFORM})
8383

84+
RUNNER_ENV = {}
85+
DISABLE_JIT_ENV = {'GRAAL_PYTHON_VM_ARGS': '--experimental-options --engine.Compilation=false'}
86+
87+
# We leave the JIT enabled for the tests themselves, but disable it for subprocesses
88+
# noinspection PyUnresolvedReferences
89+
if IS_GRAALPY and __graalpython__.is_native and 'GRAAL_PYTHON_VM_ARGS' not in os.environ:
90+
try:
91+
subprocess.check_output([sys.executable, '--version'], env={**os.environ, **DISABLE_JIT_ENV})
92+
RUNNER_ENV = DISABLE_JIT_ENV
93+
except subprocess.CalledProcessError:
94+
pass
95+
8496

8597
class Logger:
8698
report_incomplete = sys.stdout.isatty()
@@ -335,16 +347,16 @@ def __init__(self, *, failfast: bool, report_durations: int | None):
335347
self.total_duration = 0.0
336348

337349
@staticmethod
338-
def report_start(test_id: TestId):
339-
log(f"{test_id} ... ", incomplete=True)
350+
def report_start(test_id: TestId, prefix=''):
351+
log(f"{prefix}{test_id} ... ", incomplete=True)
340352

341-
def report_result(self, result: TestResult):
353+
def report_result(self, result: TestResult, prefix=''):
342354
self.results.append(result)
343355
message = f"{result.test_id} ... {result.status}"
344356
if result.status == TestStatus.SKIPPED and result.param:
345-
message = f"{message} {result.param!r}"
357+
message = f"{prefix}{message} {result.param!r}"
346358
else:
347-
message = f"{message} ({result.duration:.2f}s)"
359+
message = f"{prefix}{message} ({result.duration:.2f}s)"
348360
log(message)
349361

350362
def tests_failed(self):
@@ -532,10 +544,10 @@ def __init__(self, *, num_processes, subprocess_args, separate_workers, timeout_
532544
self.crashes = []
533545
self.default_test_timeout = 600
534546

535-
def report_result(self, result: TestResult):
547+
def report_result(self, result: TestResult, prefix=''):
536548
if self.failfast and result.status in FAILED_STATES:
537549
self.stop_event.set()
538-
super().report_result(result)
550+
super().report_result(result, prefix=prefix)
539551

540552
def tests_failed(self):
541553
return super().tests_failed() or bool(self.crashes)
@@ -550,10 +562,36 @@ def partition_tests_into_processes(self, suites: list['TestSuite']) -> list[list
550562
lambda suite: suite.test_file.config.new_worker_per_file,
551563
)
552564
partitions = [suite.collected_tests for suite in per_file_suites]
553-
per_partition = int(math.ceil(len(unpartitioned) / max(1, self.num_processes)))
554-
while unpartitioned:
555-
partitions.append([test for suite in unpartitioned[:per_partition] for test in suite.collected_tests])
556-
unpartitioned = unpartitioned[per_partition:]
565+
566+
# Use timings if available to partition unpartitioned optimally
567+
timings = {}
568+
if unpartitioned and self.num_processes:
569+
configdir = unpartitioned[0].test_file.config.configdir if unpartitioned else None
570+
if configdir:
571+
timing_path = configdir / f"timings-{sys.platform.lower()}.json"
572+
if timing_path.exists():
573+
with open(timing_path, "r", encoding="utf-8") as f:
574+
timings = json.load(f)
575+
576+
timed_files = []
577+
for suite in unpartitioned:
578+
file_path = str(suite.test_file.path).replace("\\", "/")
579+
total = timings.get(file_path, 20.0)
580+
timed_files.append((total, suite))
581+
582+
# Sort descending by expected time
583+
timed_files.sort(reverse=True, key=lambda x: x[0])
584+
585+
# Greedily assign to balance by timing sum
586+
process_loads = [[] for _ in range(self.num_processes)]
587+
process_times = [0.0] * self.num_processes
588+
for t, suite in timed_files:
589+
i = process_times.index(min(process_times))
590+
process_loads[i].append(suite)
591+
process_times[i] += t
592+
for group in process_loads:
593+
partitions.append([test for suite in group for test in suite.collected_tests])
594+
557595
return partitions
558596

559597
def run_tests(self, tests: list['TestSuite']):
@@ -582,7 +620,7 @@ def run_tests(self, tests: list['TestSuite']):
582620
log(crash)
583621

584622
def run_partitions_in_subprocesses(self, executor, partitions: list[list['Test']]):
585-
workers = [SubprocessWorker(self, partition) for i, partition in enumerate(partitions)]
623+
workers = [SubprocessWorker(i, self, partition) for i, partition in enumerate(partitions)]
586624
futures = [executor.submit(worker.run_in_subprocess_and_watch) for worker in workers]
587625

588626
def dump_worker_status():
@@ -626,7 +664,8 @@ def sigterm_handler(_signum, _frame):
626664

627665

628666
class SubprocessWorker:
629-
def __init__(self, runner: ParallelTestRunner, tests: list['Test']):
667+
def __init__(self, worker_id: int, runner: ParallelTestRunner, tests: list['Test']):
668+
self.prefix = f'[worker-{worker_id + 1}] '
630669
self.runner = runner
631670
self.stop_event = runner.stop_event
632671
self.lock = threading.RLock()
@@ -649,7 +688,7 @@ def process_event(self, event):
649688
except ValueError:
650689
# It executed something we didn't ask for. Not sure why this happens
651690
log(f'WARNING: unexpected test started {test_id}')
652-
self.runner.report_start(test_id)
691+
self.runner.report_start(test_id, prefix=self.prefix)
653692
with self.lock:
654693
self.last_started_test_id = test_id
655694
self.last_started_time = time.time()
@@ -668,7 +707,7 @@ def process_event(self, event):
668707
output=test_output,
669708
duration=event.get('duration'),
670709
)
671-
self.runner.report_result(result)
710+
self.runner.report_result(result, prefix=self.prefix)
672711
with self.lock:
673712
self.last_started_test_id = None
674713
self.last_started_time = time.time() # Starts timeout for the following teardown/setup
@@ -820,7 +859,7 @@ def run_in_subprocess_and_watch(self):
820859
param=message,
821860
output=output,
822861
duration=(time.time() - self.last_started_time),
823-
))
862+
), prefix=self.prefix)
824863
if blame_id is not self.last_started_test_id:
825864
# If we're here, it means we didn't know exactly which test we were executing, we were
826865
# somewhere in between
@@ -899,6 +938,7 @@ def parse_config(cls, config_path: Path):
899938
if config_tags_dir := settings.get('tags_dir'):
900939
tags_dir = (config_path.parent / config_tags_dir).resolve()
901940
# Temporary hack for Bytecode DSL development in master branch:
941+
# noinspection PyUnresolvedReferences
902942
if IS_GRAALPY and getattr(__graalpython__, 'is_bytecode_dsl_interpreter', False) and tags_dir:
903943
new_tags_dir = (config_path.parent / (config_tags_dir + '_bytecode_dsl')).resolve()
904944
if new_tags_dir.exists():
@@ -972,6 +1012,7 @@ class TestSuite:
9721012
collected_tests: list['Test']
9731013

9741014
def run(self, result):
1015+
os.environ.update(RUNNER_ENV)
9751016
saved_path = sys.path[:]
9761017
sys.path[:] = self.pythonpath
9771018
try:
@@ -1299,6 +1340,31 @@ def get_bool_env(name: str):
12991340
return os.environ.get(name, '').lower() in ('true', '1')
13001341

13011342

1343+
def main_extract_test_timings(args):
1344+
"""
1345+
Fetches a test log from the given URL, extracts per-file test timings, and writes the output as JSON.
1346+
"""
1347+
1348+
# Download the log file
1349+
with urllib.request.urlopen(args.url) as response:
1350+
log_content = response.read().decode("utf-8", errors="replace")
1351+
1352+
pattern = re.compile(
1353+
r"^(?P<path>[^\s:]+)::\S+ +\.\.\. (?:ok|FAIL|ERROR|SKIPPED|expected failure|unexpected success|\S+) \((?P<time>[\d.]+)s\)",
1354+
re.MULTILINE,
1355+
)
1356+
1357+
timings = {}
1358+
for match in pattern.finditer(log_content):
1359+
raw_path = match.group("path").replace("\\", "/")
1360+
t = float(match.group("time"))
1361+
timings.setdefault(raw_path, 0.0)
1362+
timings[raw_path] += t
1363+
1364+
with open(args.output, "w", encoding="utf-8") as f:
1365+
json.dump(timings, f, indent=2, sort_keys=True)
1366+
1367+
13021368
def main():
13031369
is_mx_graalpytest = get_bool_env('MX_GRAALPYTEST')
13041370
parent_parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter)
@@ -1428,6 +1494,20 @@ def main():
14281494
merge_tags_parser.add_argument('report_path')
14291495

14301496
# run the appropriate command
1497+
1498+
# extract-test-timings command declaration
1499+
extract_parser = subparsers.add_parser(
1500+
"extract-test-timings",
1501+
help="Extract per-file test timings from a test log URL and write them as JSON"
1502+
)
1503+
extract_parser.add_argument(
1504+
"url", help="URL of the test log file"
1505+
)
1506+
extract_parser.add_argument(
1507+
"output", help="Output JSON file for per-file timings"
1508+
)
1509+
extract_parser.set_defaults(main=main_extract_test_timings)
1510+
14311511
args = parent_parser.parse_args()
14321512
args.main(args)
14331513

graalpython/com.oracle.graal.python.test/src/tests/conftest.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ exclude_on = ['jvm']
4747
selector = [
4848
# These test would work on JVM too, but they are prohibitively slow due to a large amount of subprocesses
4949
"test_patched_pip.py",
50+
"test_wheel.py",
5051
]
5152

5253
[[test_rules]]
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved.
2+
# DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
3+
#
4+
# The Universal Permissive License (UPL), Version 1.0
5+
#
6+
# Subject to the condition set forth below, permission is hereby granted to any
7+
# person obtaining a copy of this software, associated documentation and/or
8+
# data (collectively the "Software"), free of charge and under any and all
9+
# copyright rights in the Software, and any and all patent rights owned or
10+
# freely licensable by each licensor hereunder covering either (i) the
11+
# unmodified Software as contributed to or provided by such licensor, or (ii)
12+
# the Larger Works (as defined below), to deal in both
13+
#
14+
# (a) the Software, and
15+
#
16+
# (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if
17+
# one is included with the Software each a "Larger Work" to which the Software
18+
# is contributed by such licensors),
19+
#
20+
# without restriction, including without limitation the rights to copy, create
21+
# derivative works of, display, perform, and distribute the Software and make,
22+
# use, sell, offer for sale, import, export, have made, and have sold the
23+
# Software and the Larger Work(s), and to sublicense the foregoing rights on
24+
# either these or other terms.
25+
#
26+
# This license is subject to the following condition:
27+
#
28+
# The above copyright notice and either this complete permission notice or at a
29+
# minimum a reference to the UPL must be included in all copies or substantial
30+
# portions of the Software.
31+
#
32+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
33+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
34+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
35+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
36+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
37+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
38+
# SOFTWARE.
39+
40+
import shutil
41+
import subprocess
42+
import sys
43+
import tempfile
44+
import textwrap
45+
import unittest
46+
from pathlib import Path
47+
48+
from tests.testlib_helper import build_testlib
49+
50+
51+
class TestCtypesInterop(unittest.TestCase):
52+
@classmethod
53+
def setUpClass(cls):
54+
orig_root = Path(__file__).parent.resolve()
55+
orig_testlib = orig_root / "testlib"
56+
cls.tmpdir = Path(tempfile.mkdtemp(prefix="testctypes_tmp_"))
57+
cls.lib_path = build_testlib(cls.tmpdir, orig_testlib)
58+
59+
@classmethod
60+
def tearDownClass(cls):
61+
shutil.rmtree(cls.tmpdir, ignore_errors=True)
62+
63+
def run_in_subprocess(self, code, *args):
64+
proc = subprocess.run(
65+
[sys.executable, "-c", code, *args],
66+
stdout=subprocess.PIPE,
67+
stderr=subprocess.PIPE,
68+
text=True,
69+
)
70+
if proc.returncode != 0:
71+
self.fail(
72+
"Subprocess failed with exit code {}\nstdout:\n{}\nstderr:\n{}".format(
73+
proc.returncode, proc.stdout, proc.stderr
74+
)
75+
)
76+
77+
def test_ctypes_load_and_call(self):
78+
# Pass the library path as an argument
79+
code = textwrap.dedent(
80+
"""
81+
import sys
82+
from ctypes import CDLL, c_int
83+
lib_path = sys.argv[1]
84+
lib = CDLL(lib_path)
85+
get_answer = lib.get_answer
86+
get_answer.restype = c_int
87+
result = get_answer()
88+
assert result == 42, f'expected 42, got {result}'
89+
"""
90+
)
91+
self.run_in_subprocess(code, str(self.lib_path))
92+
93+
@unittest.skipIf(sys.platform != "win32", "Windows-only test")
94+
def test_os_add_dll_directory_and_unload(self):
95+
# Pass the library dir as argument
96+
code = textwrap.dedent(
97+
"""
98+
import os
99+
import sys
100+
from pathlib import Path
101+
from ctypes import CDLL, c_int
102+
lib_dir = Path(sys.argv[1])
103+
dll_dir = lib_dir.parent
104+
dll_name = lib_dir.name
105+
# Should fail to load when DLL dir not added
106+
try:
107+
CDLL(dll_name)
108+
except OSError:
109+
pass
110+
else:
111+
raise AssertionError("CDLL(dll_name) should fail outside dll dir context")
112+
# Should succeed when DLL dir is temporarily added
113+
with os.add_dll_directory(dll_dir):
114+
lib = CDLL(dll_name)
115+
get_answer = lib.get_answer
116+
get_answer.restype = c_int
117+
result = get_answer()
118+
assert result == 42, f'expected 42, got {result}'
119+
"""
120+
)
121+
self.run_in_subprocess(code, str(self.lib_path))
122+
123+
124+
if __name__ == "__main__":
125+
unittest.main()

0 commit comments

Comments
 (0)