From b11e2b3a9c70c805b71dee38b685aae5f4b73c87 Mon Sep 17 00:00:00 2001 From: Tim Felgentreff Date: Tue, 9 Jun 2026 19:27:53 +0200 Subject: [PATCH 1/6] put generated sysconfigdata in the python lib path as in cpython. this matches the expectations of pyo3 when cross-compiling --- mx.graalpython/suite.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mx.graalpython/suite.py b/mx.graalpython/suite.py index f70e224544..bf47948043 100644 --- a/mx.graalpython/suite.py +++ b/mx.graalpython/suite.py @@ -1338,7 +1338,7 @@ "./META-INF/resources///Lib/venv/scripts/nt/graalpy.exe": "dependency:python-venvlauncher", "./META-INF/resources///Lib/venv/scripts/nt/python.exe": "dependency:python-venvlauncher", "./META-INF/resources///include/": "dependency:graalpy-pyconfig/-//pyconfig.h", - "./META-INF/resources///lib-graalpython/modules/": "dependency:graalpy-pyconfig/-//.py", + "./META-INF/resources///Lib/": "dependency:graalpy-pyconfig/-//.py", }, }, }, @@ -1350,7 +1350,7 @@ ], "./META-INF/resources///lib/python/venv/scripts/macos/graalpy": "dependency:python-macos-launcher", "./META-INF/resources///include/python/": "dependency:graalpy-pyconfig/-//pyconfig.h", - "./META-INF/resources///lib/graalpy/modules/": "dependency:graalpy-pyconfig/-//.py", + "./META-INF/resources///lib/python/": "dependency:graalpy-pyconfig/-//.py", } } }, @@ -1361,7 +1361,7 @@ "dependency:GRAALPYTHON_NATIVE_LIBS///*", ], "./META-INF/resources///include/python/": "dependency:graalpy-pyconfig/-//pyconfig.h", - "./META-INF/resources///lib/graalpy/modules/": "dependency:graalpy-pyconfig/-//.py", + "./META-INF/resources///lib/python/": "dependency:graalpy-pyconfig/-//.py", }, }, }, From 149ff5c0bf6b29916b827b64a28fa9e5fb34f826 Mon Sep 17 00:00:00 2001 From: Tim Felgentreff Date: Tue, 9 Jun 2026 15:17:22 +0200 Subject: [PATCH 2/6] Support patching wheels & sdists and injecting urls when using pip with audit hooks --- .../freeze_modules.py | 1 - .../src/tests/test_patched_pip.py | 58 ++ .../src/tests/test_pip_audit_hook.py | 526 ++++++++++++++++++ .../src/tests/test_startup.py | 1 - .../graal/python/builtins/Python3Core.java | 5 +- .../modules/GraalPythonModuleBuiltins.java | 11 + .../objects/module/FrozenModules.java | 3 - .../modules/graalpy_pip_extensions.py | 455 +++++++++++++++ .../lib-graalpython/patches/pip-24.3.1.patch | 372 +------------ graalpython/lib-graalpython/pip_audit_hook.py | 231 ++++++++ graalpython/lib-graalpython/pip_hook.py | 56 +- .../_bundled/pip-24.3.1-py3-none-any.whl | Bin 1859120 -> 1854854 bytes .../lib-python/3/importlib/_bootstrap.py | 7 +- 13 files changed, 1306 insertions(+), 420 deletions(-) create mode 100644 graalpython/com.oracle.graal.python.test/src/tests/test_pip_audit_hook.py create mode 100644 graalpython/lib-graalpython/modules/graalpy_pip_extensions.py create mode 100644 graalpython/lib-graalpython/pip_audit_hook.py diff --git a/graalpython/com.oracle.graal.python.frozen/freeze_modules.py b/graalpython/com.oracle.graal.python.frozen/freeze_modules.py index 937236432b..62415caaad 100644 --- a/graalpython/com.oracle.graal.python.frozen/freeze_modules.py +++ b/graalpython/com.oracle.graal.python.frozen/freeze_modules.py @@ -123,7 +123,6 @@ def add_graalpython_core(): "_sre", "_sysconfig", "java", - "pip_hook", ]: modname = f"graalpy.{os.path.basename(name)}" modpath = os.path.join(lib_graalpython, f"{name}.py") diff --git a/graalpython/com.oracle.graal.python.test/src/tests/test_patched_pip.py b/graalpython/com.oracle.graal.python.test/src/tests/test_patched_pip.py index 21b91b57f1..dc3647c4aa 100644 --- a/graalpython/com.oracle.graal.python.test/src/tests/test_patched_pip.py +++ b/graalpython/com.oracle.graal.python.test/src/tests/test_patched_pip.py @@ -45,6 +45,7 @@ import tempfile import threading import unittest +import zipfile from http.server import HTTPServer, SimpleHTTPRequestHandler from pathlib import Path from urllib.parse import urljoin @@ -189,6 +190,34 @@ def run_venv_pip_install(self, package, extra_env=None, assert_stderr_matches=No f"Didn't match expected stderr.\nExpected (regex): {assert_stderr_matches}\nActual:{proc.stderr}" return re.findall(r'Successfully installed (\S+)', proc.stdout) + def run_venv_pip_runner_install(self, package): + runner = subprocess.check_output([ + self.venv_python, + '-c', + 'import pathlib, pip; print(pathlib.Path(pip.__file__).with_name("__pip-runner__.py"))', + ], universal_newlines=True).strip() + proc = subprocess.run( + [ + str(self.venv_python), + runner, + '--isolated', + 'install', + '--force-reinstall', + '--find-links', str(self.index_dir), + '--no-index', + '--no-cache-dir', + package, + ], + check=True, + capture_output=True, + env=self.pip_env, + universal_newlines=True, + ) + print(proc.stdout) + print(proc.stderr) + assert 'Applying GraalPy patch failed for' not in proc.stderr + return re.findall(r'Successfully installed (\S+)', proc.stdout) + def run_test_fun(self): code = "import patched_package; print(patched_package.test_fun())" return subprocess.check_output([self.venv_python, '-c', code], universal_newlines=True).strip() @@ -233,6 +262,35 @@ def test_sdist_patched_version(self): self.run_venv_pip_install('foo') assert self.run_test_fun() == "Patched" + def test_sdist_patched_version_with_pip_runner(self): + self.add_package_to_index('foo', '1.1.0', 'sdist') + self.prepare_config('foo', [{ + 'patch': 'foo-1.1.0.patch', + 'version': '== 1.1.0', + 'subdir': 'src', + }]) + self.run_venv_pip_runner_install('foo') + assert self.run_test_fun() == "Patched" + + def test_mark_wheel_preserves_executable_scripts(self): + import graalpy_pip_extensions + + wheel = self.build_dir / 'executable_script-1.0-py3-none-any.whl' + script = 'executable_script-1.0.data/scripts/executable-script' + with zipfile.ZipFile(wheel, 'w') as z: + info = zipfile.ZipInfo(script) + info.external_attr = 0o100755 << 16 + z.writestr(info, '#!/usr/bin/env python\n') + z.writestr('executable_script-1.0.dist-info/METADATA', 'Name: executable_script\nVersion: 1.0\n') + z.writestr('executable_script-1.0.dist-info/WHEEL', 'Wheel-Version: 1.0\n') + z.writestr('executable_script-1.0.dist-info/RECORD', '') + + graalpy_pip_extensions.mark_wheel(wheel) + + with zipfile.ZipFile(wheel) as z: + mode = z.getinfo(script).external_attr >> 16 + assert mode & 0o111 + def test_different_patch_wheel_sdist1(self): self.add_package_to_index('foo', '1.1.0', 'sdist') self.prepare_config('foo', [ diff --git a/graalpython/com.oracle.graal.python.test/src/tests/test_pip_audit_hook.py b/graalpython/com.oracle.graal.python.test/src/tests/test_pip_audit_hook.py new file mode 100644 index 0000000000..8a18a4dc3f --- /dev/null +++ b/graalpython/com.oracle.graal.python.test/src/tests/test_pip_audit_hook.py @@ -0,0 +1,526 @@ +# Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. +# DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. +# +# The Universal Permissive License (UPL), Version 1.0 +# +# Subject to the condition set forth below, permission is hereby granted to any +# person obtaining a copy of this software, associated documentation and/or +# data (collectively the "Software"), free of charge and under any and all +# copyright rights in the Software, and any and all patent rights owned or +# freely licensable by each licensor hereunder covering either (i) the +# unmodified Software as contributed to or provided by such licensor, or (ii) +# the Larger Works (as defined below), to deal in both +# +# (a) the Software, and +# +# (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if +# one is included with the Software each a "Larger Work" to which the Software +# is contributed by such licensors), +# +# without restriction, including without limitation the rights to copy, create +# derivative works of, display, perform, and distribute the Software and make, +# use, sell, offer for sale, import, export, have made, and have sold the +# Software and the Larger Work(s), and to sublicense the foregoing rights on +# either these or other terms. +# +# This license is subject to the following condition: +# +# The above copyright notice and either this complete permission notice or at a +# minimum a reference to the UPL must be included in all copies or substantial +# portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +import zipfile +from pathlib import Path + + +def write_fake_pip(root, patched=False): + pip_package = root / "pip" + pip_package.mkdir() + pip_package.joinpath("__init__.py").write_text( + "__GRAALPY_PATCHED = True\n" if patched else "", encoding="utf-8" + ) + return root + + +class PipAuditHookTests(unittest.TestCase): + @unittest.skipUnless(sys.implementation.name == "graalpy", "GraalPy-specific test") + @unittest.skipUnless(shutil.which("patch"), "requires the patch utility") + def test_source_ready_event_applies_local_graalpy_patch_after_unpatched_pip_import(self): + with tempfile.TemporaryDirectory(prefix="graalpy-pip-audit-") as tmp: + root = Path(tmp) + patches = root / "patches" + source = root / "demo-1.0.0" + fake_pip_root = root / "fake-pip" + patches.mkdir() + source.mkdir() + fake_pip_root.mkdir() + write_fake_pip(fake_pip_root) + + (patches / "metadata.toml").write_text( + "\n".join( + [ + "[[demo.rules]]", + "version = '== 1.0.0'", + "patch = 'demo.patch'", + "license = 'MIT'", + ] + ), + encoding="utf-8", + ) + (patches / "demo.patch").write_text( + "\n".join( + [ + "diff --git a/demo_module.py b/demo_module.py", + "--- a/demo_module.py", + "+++ b/demo_module.py", + "@@ -1 +1 @@", + '-value = "old"', + '+value = "patched"', + "", + ] + ), + encoding="utf-8", + ) + module = source / "demo_module.py" + module.write_text('value = "old"\n', encoding="utf-8") + + env = os.environ.copy() + env["PIP_GRAALPY_PATCHES_URL"] = patches.as_uri() + env.pop("PIP_GRAALPY_DISABLE_PATCHING", None) + + subprocess.check_call( + [ + sys.executable, + "-S", + "-c", + ( + "import sys; " + "sys.path.insert(0, sys.argv[1]); " + "import pip; " + "sys.audit('pip.requirement.source_ready', " + "'demo', None, 'https://example.invalid/demo-1.0.0.tar.gz', " + "sys.argv[2], False, 0)" + ), + str(fake_pip_root), + str(source), + ], + env=env, + ) + + self.assertEqual( + module.read_text(encoding="utf-8"), 'value = "patched"\n' + ) + + @unittest.skipUnless(sys.implementation.name == "graalpy", "GraalPy-specific test") + @unittest.skipUnless(shutil.which("patch"), "requires the patch utility") + def test_wheel_install_event_rewrites_wheel_before_install(self): + with tempfile.TemporaryDirectory(prefix="graalpy-pip-audit-wheel-") as tmp: + root = Path(tmp) + patches = root / "patches" + fake_pip_root = root / "fake-pip" + patches.mkdir() + fake_pip_root.mkdir() + write_fake_pip(fake_pip_root) + wheel_path = root / "demo-1.0.0-py3-none-any.whl" + + (patches / "metadata.toml").write_text( + "\n".join( + [ + "[[demo.rules]]", + "version = '== 1.0.0'", + "dist-type = 'wheel'", + "patch = 'demo.patch'", + "license = 'MIT'", + ] + ), + encoding="utf-8", + ) + (patches / "demo.patch").write_text( + "\n".join( + [ + "diff --git a/demo_module.py b/demo_module.py", + "--- a/demo_module.py", + "+++ b/demo_module.py", + "@@ -1 +1 @@", + '-value = "old"', + '+value = "patched"', + "", + ] + ), + encoding="utf-8", + ) + + with zipfile.ZipFile(wheel_path, "w") as z: + z.writestr("demo_module.py", 'value = "old"\n') + z.writestr( + "demo-1.0.0.dist-info/WHEEL", + "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n", + ) + z.writestr( + "demo-1.0.0.dist-info/METADATA", + "Metadata-Version: 2.1\nName: demo\nVersion: 1.0.0\n", + ) + z.writestr("demo-1.0.0.dist-info/RECORD", "") + + env = os.environ.copy() + env["PIP_GRAALPY_PATCHES_URL"] = patches.as_uri() + env.pop("PIP_GRAALPY_DISABLE_PATCHING", None) + + subprocess.check_call( + [ + sys.executable, + "-S", + "-c", + ( + "import sys; " + "sys.path.insert(0, sys.argv[1]); " + "import pip; " + "sys.audit('pip.wheel.install', 'demo', sys.argv[2])" + ), + str(fake_pip_root), + str(wheel_path), + ], + env=env, + ) + + with zipfile.ZipFile(wheel_path) as z: + self.assertEqual( + z.read("demo_module.py").decode("utf-8"), 'value = "patched"\n' + ) + self.assertIn("demo-1.0.0.dist-info/GRAALPY_MARKER", z.namelist()) + record = z.read("demo-1.0.0.dist-info/RECORD").decode("utf-8") + self.assertIn("demo_module.py,sha256=", record) + self.assertIn("demo-1.0.0.dist-info/GRAALPY_MARKER,sha256=", record) + + @unittest.skipUnless(sys.implementation.name == "graalpy", "GraalPy-specific test") + @unittest.skipUnless(shutil.which("patch"), "requires the patch utility") + def test_patched_pip_does_not_install_audit_hook(self): + with tempfile.TemporaryDirectory(prefix="graalpy-pip-audit-patched-") as tmp: + root = Path(tmp) + patches = root / "patches" + source = root / "demo-1.0.0" + fake_pip_root = root / "fake-pip" + patches.mkdir() + source.mkdir() + fake_pip_root.mkdir() + write_fake_pip(fake_pip_root, patched=True) + + (patches / "metadata.toml").write_text( + "\n".join( + [ + "[[demo.rules]]", + "version = '== 1.0.0'", + "patch = 'demo.patch'", + "license = 'MIT'", + ] + ), + encoding="utf-8", + ) + (patches / "demo.patch").write_text( + "\n".join( + [ + "diff --git a/demo_module.py b/demo_module.py", + "--- a/demo_module.py", + "+++ b/demo_module.py", + "@@ -1 +1 @@", + '-value = "old"', + '+value = "patched"', + "", + ] + ), + encoding="utf-8", + ) + module = source / "demo_module.py" + module.write_text('value = "old"\n', encoding="utf-8") + + env = os.environ.copy() + env["PIP_GRAALPY_PATCHES_URL"] = patches.as_uri() + env.pop("PIP_GRAALPY_DISABLE_PATCHING", None) + + subprocess.check_call( + [ + sys.executable, + "-S", + "-c", + ( + "import sys; " + "sys.path.insert(0, sys.argv[1]); " + "import pip; " + "sys.audit('pip.requirement.source_ready', " + "'demo', None, 'https://example.invalid/demo-1.0.0.tar.gz', " + "sys.argv[2], False, 0)" + ), + str(fake_pip_root), + str(source), + ], + env=env, + ) + + self.assertEqual(module.read_text(encoding="utf-8"), 'value = "old"\n') + + @unittest.skipUnless(sys.implementation.name == "graalpy", "GraalPy-specific test") + def test_cli_parse_args_event_adds_graalpy_defaults(self): + with tempfile.TemporaryDirectory(prefix="graalpy-pip-audit-cli-") as tmp: + root = Path(tmp) + fake_pip_root = root / "fake-pip" + fake_pip_root.mkdir() + write_fake_pip(fake_pip_root) + + env = os.environ.copy() + env.pop("PIP_INDEX_URL", None) + env.pop("PIP_CACHE_DIR", None) + + subprocess.check_call( + [ + sys.executable, + "-S", + "-c", + ( + "import sys; " + "from types import SimpleNamespace; " + "sys.path.insert(0, sys.argv[1]); " + "import pip; " + "options = SimpleNamespace(extra_index_urls=[], cache_dir=sys.argv[2]); " + "sys.audit('pip.cli.parse_args', options, [], 0); " + "assert options.extra_index_urls == ['https://www.graalvm.org/python/wheels/']; " + "assert options.cache_dir == sys.argv[2] + '-graalpy'" + ), + str(fake_pip_root), + str(root / "pip-cache"), + ], + env=env, + ) + + @unittest.skipUnless(sys.implementation.name == "graalpy", "GraalPy-specific test") + def test_import_function_installs_pip_audit_hook(self): + with tempfile.TemporaryDirectory(prefix="graalpy-pip-audit-import-") as tmp: + root = Path(tmp) + fake_pip_root = root / "fake-pip" + fake_pip_root.mkdir() + write_fake_pip(fake_pip_root) + + env = os.environ.copy() + env.pop("PIP_INDEX_URL", None) + env.pop("PIP_CACHE_DIR", None) + + subprocess.check_call( + [ + sys.executable, + "-S", + "-c", + ( + "import sys; " + "from types import SimpleNamespace; " + "sys.path.insert(0, sys.argv[1]); " + "__import__('pip'); " + "options = SimpleNamespace(extra_index_urls=[], cache_dir=sys.argv[2]); " + "sys.audit('pip.cli.parse_args', options, [], 0); " + "assert options.extra_index_urls == ['https://www.graalvm.org/python/wheels/']; " + "assert options.cache_dir == sys.argv[2] + '-graalpy'" + ), + str(fake_pip_root), + str(root / "pip-cache"), + ], + env=env, + ) + + @unittest.skipUnless(sys.implementation.name == "graalpy", "GraalPy-specific test") + def test_find_all_candidates_event_adds_metadata_sources(self): + with tempfile.TemporaryDirectory(prefix="graalpy-pip-audit-candidates-") as tmp: + root = Path(tmp) + patches = root / "patches" + fake_pip_root = root / "fake-pip" + patches.mkdir() + fake_pip_root.mkdir() + write_fake_pip(fake_pip_root) + + (patches / "metadata.toml").write_text( + "\n".join( + [ + "[[demo.add-sources]]", + "version = '1.0.0'", + "url = 'https://example.invalid/demo/archive/refs/tags/v1.0.0.tar.gz'", + ] + ), + encoding="utf-8", + ) + + env = os.environ.copy() + env["PIP_GRAALPY_PATCHES_URL"] = patches.as_uri() + + subprocess.check_call( + [ + sys.executable, + "-S", + "-c", + ( + "import sys; " + "sys.path.insert(0, sys.argv[1]); " + "import pip; " + "extra = []; " + "sys.audit('pip.package_finder.find_all_candidates', 'demo', (), extra); " + "assert extra == [('demo', '1.0.0', {" + "'url': 'https://example.invalid/demo/archive/refs/tags/v1.0.0.tar.gz', " + "'filename': 'demo-1.0.0.tar.gz', " + "'comes_from': 'GraalPy compatibility patches', " + "'requires_python': None, " + "'yanked_reason': None})]" + ), + str(fake_pip_root), + ], + env=env, + ) + + @unittest.skipUnless(sys.implementation.name == "graalpy", "GraalPy-specific test") + @unittest.skipUnless(shutil.which("patch"), "requires the patch utility") + def test_source_ready_event_uses_link_filename_for_add_source_urls(self): + with tempfile.TemporaryDirectory(prefix="graalpy-pip-audit-link-filename-") as tmp: + root = Path(tmp) + patches = root / "patches" + source = root / "demo-1.0.0" + fake_pip_root = root / "fake-pip" + patches.mkdir() + source.mkdir() + fake_pip_root.mkdir() + write_fake_pip(fake_pip_root) + + (patches / "metadata.toml").write_text( + "\n".join( + [ + "[[demo.rules]]", + "version = '== 1.0.0'", + "patch = 'demo.patch'", + "license = 'MIT'", + ] + ), + encoding="utf-8", + ) + (patches / "demo.patch").write_text( + "\n".join( + [ + "diff --git a/demo_module.py b/demo_module.py", + "--- a/demo_module.py", + "+++ b/demo_module.py", + "@@ -1 +1 @@", + '-value = "old"', + '+value = "patched"', + "", + ] + ), + encoding="utf-8", + ) + module = source / "demo_module.py" + module.write_text('value = "old"\n', encoding="utf-8") + + env = os.environ.copy() + env["PIP_GRAALPY_PATCHES_URL"] = patches.as_uri() + env.pop("PIP_GRAALPY_DISABLE_PATCHING", None) + + subprocess.check_call( + [ + sys.executable, + "-S", + "-c", + "\n".join( + [ + "import sys", + "sys.path.insert(0, sys.argv[1])", + "import pip", + "class Link:", + " filename = 'demo-1.0.0.tar.gz'", + " def __str__(self):", + " return 'https://example.invalid/demo/archive/refs/tags/v1.0.0.tar.gz'", + "sys.audit(", + " 'pip.requirement.source_ready',", + " 'demo',", + " None,", + " Link(),", + " sys.argv[2],", + " False,", + " 0,", + ")", + ] + ), + str(fake_pip_root), + str(source), + ], + env=env, + ) + + self.assertEqual(module.read_text(encoding="utf-8"), 'value = "patched"\n') + + @unittest.skipUnless(sys.implementation.name == "graalpy", "GraalPy-specific test") + def test_source_ready_event_warns_about_suggested_versions(self): + with tempfile.TemporaryDirectory(prefix="graalpy-pip-audit-suggest-") as tmp: + root = Path(tmp) + patches = root / "patches" + source = root / "demo-2.0.0" + fake_pip_root = root / "fake-pip" + patches.mkdir() + source.mkdir() + fake_pip_root.mkdir() + write_fake_pip(fake_pip_root) + + (patches / "metadata.toml").write_text( + "\n".join( + [ + "[[demo.rules]]", + "version = '== 1.0.0'", + "patch = 'demo.patch'", + "license = 'MIT'", + ] + ), + encoding="utf-8", + ) + (patches / "demo.patch").write_text("", encoding="utf-8") + + env = os.environ.copy() + env["PIP_GRAALPY_PATCHES_URL"] = patches.as_uri() + + subprocess.check_call( + [ + sys.executable, + "-S", + "-c", + "\n".join( + [ + "import sys, warnings", + "sys.path.insert(0, sys.argv[1])", + "import pip", + "with warnings.catch_warnings(record=True) as caught:", + " warnings.simplefilter('always')", + " sys.audit(", + " 'pip.requirement.source_ready',", + " 'demo',", + " None,", + " 'https://example.invalid/demo-2.0.0.tar.gz',", + " sys.argv[2],", + " False,", + " 0,", + " )", + " assert any('version(s): == 1.0.0' in str(w.message) for w in caught)", + ] + ), + str(fake_pip_root), + str(source), + ], + env=env, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/graalpython/com.oracle.graal.python.test/src/tests/test_startup.py b/graalpython/com.oracle.graal.python.test/src/tests/test_startup.py index 5480ed3518..c98e7057fc 100644 --- a/graalpython/com.oracle.graal.python.test/src/tests/test_startup.py +++ b/graalpython/com.oracle.graal.python.test/src/tests/test_startup.py @@ -64,7 +64,6 @@ '_sre', '_sysconfig', 'java', - 'pip_hook', ] + WINDOWS_CORE_MODULES expected_full_startup_modules = expected_nosite_startup_modules + [ diff --git a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/Python3Core.java b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/Python3Core.java index a7793600ef..17b5fec576 100644 --- a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/Python3Core.java +++ b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/Python3Core.java @@ -442,8 +442,7 @@ private static TruffleString[] getCoreFiles() { T___GRAALPYTHON__, T__SRE, T__SYSCONFIG, - T_JAVA, - toTruffleStringUncached("pip_hook") + T_JAVA }; } @@ -1330,7 +1329,7 @@ private Source getInternalSource(TruffleString basename, TruffleString prefix) { throw e; } - private void loadFile(TruffleString s, TruffleString prefix) { + public void loadFile(TruffleString s, TruffleString prefix) { loadFile(s, s, prefix); } diff --git a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/modules/GraalPythonModuleBuiltins.java b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/modules/GraalPythonModuleBuiltins.java index c17d58aa35..de094dd1c4 100644 --- a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/modules/GraalPythonModuleBuiltins.java +++ b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/modules/GraalPythonModuleBuiltins.java @@ -1550,4 +1550,15 @@ static PTuple doCreate(long arrowArrayAddr, long arrowSchemaAddr, return PFactory.createTuple(ctx.getLanguage(inliningTarget), new Object[]{arrowSchemaCapsule, arrowArrayCapsule}); } } + + @Builtin(name = "load_file", minNumOfPositionalArgs = 1) + @GenerateNodeFactory + public abstract static class LoadFile extends PythonUnaryBuiltinNode { + @Specialization + static PNone doit(TruffleString name, + @Bind PythonContext context) { + context.getCore().loadFile(name, context.getCoreHomeOrFail()); + return PNone.NONE; + } + } } diff --git a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/objects/module/FrozenModules.java b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/objects/module/FrozenModules.java index 235a182347..e0aebd29fa 100644 --- a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/objects/module/FrozenModules.java +++ b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/objects/module/FrozenModules.java @@ -102,7 +102,6 @@ private static final class Map { private static final PythonFrozenModule GRAALPY__SRE = new PythonFrozenModule("GRAALPY__SRE", null, false); private static final PythonFrozenModule GRAALPY__SYSCONFIG = new PythonFrozenModule("GRAALPY__SYSCONFIG", null, false); private static final PythonFrozenModule GRAALPY_JAVA = new PythonFrozenModule("GRAALPY_JAVA", null, false); - private static final PythonFrozenModule GRAALPY_PIP_HOOK = new PythonFrozenModule("GRAALPY_PIP_HOOK", null, false); } public static final PythonFrozenModule lookup(String name) { @@ -233,8 +232,6 @@ public static final PythonFrozenModule lookup(String name) { return Map.GRAALPY__SYSCONFIG; case "graalpy.java": return Map.GRAALPY_JAVA; - case "graalpy.pip_hook": - return Map.GRAALPY_PIP_HOOK; default: return null; } diff --git a/graalpython/lib-graalpython/modules/graalpy_pip_extensions.py b/graalpython/lib-graalpython/modules/graalpy_pip_extensions.py new file mode 100644 index 0000000000..8c9c836bf7 --- /dev/null +++ b/graalpython/lib-graalpython/modules/graalpy_pip_extensions.py @@ -0,0 +1,455 @@ +# Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. +# DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. +# +# The Universal Permissive License (UPL), Version 1.0 +# +# Subject to the condition set forth below, permission is hereby granted to any +# person obtaining a copy of this software, associated documentation and/or +# data (collectively the "Software"), free of charge and under any and all +# copyright rights in the Software, and any and all patent rights owned or +# freely licensable by each licensor hereunder covering either (i) the +# unmodified Software as contributed to or provided by such licensor, or (ii) +# the Larger Works (as defined below), to deal in both +# +# (a) the Software, and +# +# (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if +# one is included with the Software each a "Larger Work" to which the Software +# is contributed by such licensors), +# +# without restriction, including without limitation the rights to copy, create +# derivative works of, display, perform, and distribute the Software and make, +# use, sell, offer for sale, import, export, have made, and have sold the +# Software and the Larger Work(s), and to sublicense the foregoing rights on +# either these or other terms. +# +# This license is subject to the following condition: +# +# The above copyright notice and either this complete permission notice or at a +# minimum a reference to the UPL must be included in all copies or substantial +# portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import abc +import logging +import os +import re +import sys +import tempfile +import zipfile +from types import SimpleNamespace +from contextlib import contextmanager +from pathlib import Path +import tomllib as tomli +from tomllib import TOMLDecodeError +from urllib.parse import urlparse, urljoin, urlunparse +from urllib.request import url2pathname + +try: + from pip._internal.models.link import Link as _PipLink +except ImportError: + _PipLink = object + +MARKER_FILE_NAME = 'GRAALPY_MARKER' +METADATA_FILENAME = 'metadata.toml' +DEFAULT_PATCHES_PATH = Path(__graalpython__.core_home) / 'patches' +VERSION_PARAMETER = '' +DEFAULT_PATCHES_URL = f'https://raw.githubusercontent.com/oracle/graalpython/refs/heads/github/patches/{VERSION_PARAMETER}/graalpython/lib-graalpython/patches/' + +PATCHES_URL = os.environ.get('PIP_GRAALPY_PATCHES_URL', DEFAULT_PATCHES_URL) +DISABLED_PATCHES_URL = 'disabled' +DISABLE_PATCHING = os.environ.get('PIP_GRAALPY_DISABLE_PATCHING', '').lower() in ('true', '1') +DISABLE_VERSION_SELECTION = os.environ.get('PIP_GRAALPY_DISABLE_VERSION_SELECTION', '').lower() in ('true', '1') + +GRAALPY_VERSION = os.environ.get('TEST_PIP_GRAALPY_VERSION', __graalpython__.get_graalvm_version()) + +logger = logging.getLogger(__name__) + + +def canonicalize_name(name): + return re.sub(r"[-_.]+", "-", name).lower() + + +def specifier_contains(specifier, version): + try: + from pip._vendor.packaging.specifiers import SpecifierSet + except ImportError: + try: + from packaging.specifiers import SpecifierSet + except ImportError: + return any( + spec.strip().removeprefix("==").strip() == version + for spec in specifier.split(",") + if spec.strip().startswith("==") + ) + return SpecifierSet(specifier).contains(version) + + +def url_for_file(patches_url, filename): + scheme, netloc, path, params, query, fragment = urlparse(patches_url) + path = urljoin(path, filename) + return urlunparse((scheme, netloc, path, params, query, fragment)) + + +class RepositoryException(Exception): + pass + + +class RepositoryNotFound(RepositoryException): + pass + + +class AbstractPatchRepository(metaclass=abc.ABCMeta): + def __init__(self, metadata: dict): + self._repository = metadata + + @staticmethod + def metadata_from_string(metadata_content) -> dict: + try: + parsed_metadata = tomli.loads(metadata_content) + return {canonicalize_name(name): data for name, data in parsed_metadata.items()} + except TOMLDecodeError as e: + raise RepositoryException(f"'{METADATA_FILENAME}' cannot be parsed: {e}") + + def get_rules(self, name): + if metadata := self._repository.get(canonicalize_name(name)): + return metadata.get('rules') + + def get_add_sources(self, name): + if metadata := self._repository.get(canonicalize_name(name)): + return metadata.get('add-sources') + + def get_priority_for_version(self, name, version): + if rules := self.get_rules(name): + for rule in rules: + if self.rule_matches_version(rule, version): + return rule.get('install-priority', 1) + return 0 + + @staticmethod + def rule_matches_version(rule, version): + return not rule.get('version') or specifier_contains(rule['version'], version) + + def get_suggested_version_specs(self, name): + versions = set() + if rules := self.get_rules(name): + for rule in rules: + if 'patch' in rule and rule.get('install-priority', 1) > 0 and (version := rule.get('version')): + versions.add(version) + return versions + + def get_matching_rule(self, name, requested_version, dist_type): + if metadata := self.get_rules(name): + for rule in metadata: + if rule.get('dist-type', dist_type) != dist_type: + continue + if not self.rule_matches_version(rule, requested_version): + continue + return rule + + @abc.abstractmethod + def resolve_patch(self, patch_name: str): + pass + + +class EmptyRepository(AbstractPatchRepository): + def __init__(self): + super().__init__({}) + + def resolve_patch(self, patch_name: str): + raise AssertionError("Invalid call") + + +class LocalPatchRepository(AbstractPatchRepository): + def __init__(self, patches_path: Path, repository_data: dict): + super().__init__(repository_data) + self.patches_path = patches_path + + @classmethod + def from_path(cls, patches_path: Path): + try: + with open(patches_path / METADATA_FILENAME) as f: + metadata_content = f.read() + except FileNotFoundError: + raise RepositoryNotFound(f"'{METADATA_FILENAME}' does not exist") + except OSError as e: + raise RepositoryException(f"'{METADATA_FILENAME}' cannot be read: {e}") + return cls(patches_path, cls.metadata_from_string(metadata_content)) + + @contextmanager + def resolve_patch(self, patch_name: str): + yield self.patches_path / patch_name + + +class RemotePatchRepository(AbstractPatchRepository): + def __init__(self, patches_url: str, repository_data: dict): + super().__init__(repository_data) + self.patches_url = patches_url + + @staticmethod + def get_session(): + try: + from pip._internal.cli.index_command import _GRAALPY_SESSION + if _GRAALPY_SESSION: + return _GRAALPY_SESSION + except ImportError: + pass + from pip._vendor import requests + return requests.Session() + + @classmethod + def from_url(cls, patches_url: str): + try: + url = url_for_file(patches_url, METADATA_FILENAME) + response = cls.get_session().get(url) + if response.status_code == 404: + raise RepositoryNotFound(f"'{METADATA_FILENAME}' not found") + response.raise_for_status() + if not response.encoding: + response.encoding = 'utf-8' + metadata_content = response.text + except Exception as e: + raise RepositoryException(f"'{METADATA_FILENAME}' cannot be retrieved': {e}") + return cls(patches_url, cls.metadata_from_string(metadata_content)) + + @contextmanager + def resolve_patch(self, patch_name: str): + from pip._vendor import requests + + try: + response = self.get_session().get(url_for_file(self.patches_url, patch_name)) + response.raise_for_status() + except requests.RequestException as e: + logger.warning("Failed to download GraalPy patch '%s': %s", patch_name, e) + yield None + else: + with tempfile.TemporaryDirectory() as tempdir: + if not response.encoding: + response.encoding = 'utf-8' + patch_file = Path(tempdir) / patch_name + with open(patch_file, 'w') as f: + f.write(response.text) + yield patch_file + + +__PATCH_REPOSITORY = None + + +def repository_from_url_or_path(url_or_path): + if '://' not in url_or_path: + return LocalPatchRepository.from_path(Path(url_or_path)) + elif url_or_path.startswith('file:'): + patches_path = Path(url2pathname(urlparse(url_or_path).path)) + return LocalPatchRepository.from_path(patches_path) + else: + patches_url = url_or_path + if not patches_url.endswith('/'): + patches_url += '/' + return RemotePatchRepository.from_url(patches_url) + + +def create_patch_repository(patches_url): + if patches_url and VERSION_PARAMETER in patches_url: + if not GRAALPY_VERSION.endswith('-dev'): + patches_url = patches_url.replace(VERSION_PARAMETER, GRAALPY_VERSION) + else: + logger.debug("Skipping versioned GraalPy patch repository on snapshot build") + patches_url = None + if patches_url and patches_url != DISABLED_PATCHES_URL: + logger.info( + "Loading GraalPy post-release patch repository from %s. " + "This can be controlled with PIP_GRAALPY_PATCHES_URL environment variable. Set to '%s' to disable", + patches_url, DISABLED_PATCHES_URL) + try: + return repository_from_url_or_path(patches_url) + except RepositoryException as e: + if patches_url == DEFAULT_PATCHES_URL and isinstance(e, RepositoryNotFound): + logger.info("No post-release patch repository published yet") + else: + logger.warning("Failed to load GraalPy patch repository: %s", e) + logger.warning("Falling back to bundled GraalPy patch repository") + try: + return LocalPatchRepository.from_path(DEFAULT_PATCHES_PATH) + except RepositoryException as e: + logger.warning("Failed to load internal GraalPy patch repository: %s", e) + return EmptyRepository() + + +def get_patch_repository(): + global __PATCH_REPOSITORY + if not __PATCH_REPOSITORY: + __PATCH_REPOSITORY = create_patch_repository(PATCHES_URL) + return __PATCH_REPOSITORY + + +def apply_graalpy_patches(filename, location, warn_suggested_versions=False): + """ + Applies any GraalPy patches to package extracted from 'filename' into 'location'. + Note that 'location' must be the parent directory of the package directory itself. + For example: /path/to/site-package and not /path/to/site-packages/mypackage. + """ + if DISABLE_PATCHING: + return + + # we expect filename to be something like "pytest-5.4.2-py3-none-any.whl" + archive_name = os.path.basename(filename) + name_ver_match = re.match(r"^(?P.*?)-(?P[^-]+).*?\.(?Ptar\.gz|tar|whl|zip)$", + archive_name, re.I) + if not name_ver_match: + logger.warning(f"GraalPy warning: could not parse package name, version, or format from {archive_name!r}.\n" + "Could not determine if any GraalPy specific patches need to be applied.") + return + + name = name_ver_match.group('name') + version = name_ver_match.group('version') + suffix = name_ver_match.group('suffix') + is_wheel = suffix == "whl" + + if is_wheel and is_wheel_marked(filename): + # We already processed it when building from source + return + + import autopatch_capi + import subprocess + + autopatch_capi.auto_patch_tree(location) + + logger.info(f"Looking for GraalPy patches for {name}") + repository = get_patch_repository() + + if is_wheel: + # patches intended for binary distribution: + rule = repository.get_matching_rule(name, version, 'wheel') + else: + # patches intended for source distribution if applicable + rule = repository.get_matching_rule(name, version, 'sdist') + if not rule: + rule = repository.get_matching_rule(name, version, 'wheel') + if rule and (subdir := rule.get('subdir')): + # we may need to change wd if we are actually patching a source distribution + # with a patch intended for a binary distribution, because in the source + # distribution the actual deployed sources may be in a subdirectory (typically "src") + location = os.path.join(location, subdir) + if rule: + if patch := rule.get('patch'): + with repository.resolve_patch(patch) as patch_path: + if not patch_path: + return + logger.info(f"Patching package {name} using {patch}") + exe = '.exe' if os.name == 'nt' else '' + try: + subprocess.run([f"patch{exe}", "-f", "-d", location, "-p1", "-i", str(patch_path)], check=True) + except FileNotFoundError: + logger.warning( + "WARNING: GraalPy needs the 'patch' utility to apply compatibility patches. Please install it using your system's package manager.") + except subprocess.CalledProcessError: + logger.warning(f"Applying GraalPy patch failed for {name}. The package may still work.") + except Exception: + logger.exception(f"Failed to execute patch utility") + elif version_specs := repository.get_suggested_version_specs(name): + logger.info("We have patches to make this package work on GraalVM for some version(s).") + logger.info("If installing or running fails, consider using one of the versions that we have patches for:") + for version_spec in version_specs: + logger.info(f'{name} {version_spec}') + if warn_suggested_versions: + from warnings import warn + warn( + "GraalPy has compatibility patches for " + f"{name} version(s): {', '.join(sorted(version_specs))}. " + "If installing or running this package fails, consider using one of those versions." + ) + + +def apply_graalpy_sort_order(sort_key_func): + if DISABLE_VERSION_SELECTION: + return sort_key_func + + def wrapper(self, candidate): + default_sort_key = sort_key_func(self, candidate) + priority = get_patch_repository().get_priority_for_version(candidate.name, str(candidate.version)) + return priority, default_sort_key + + return wrapper + + +class AddedSourceLink(_PipLink): + def __init__(self, url: str, filename: str): + if _PipLink is object: + self._url = url + else: + super().__init__(url) + self._filename = filename + + @property + def filename(self) -> str: + return self._filename + + @property + def url(self) -> str: + if _PipLink is object: + return self._url + return super().url + + def __str__(self): + return self.url + + +def installation_candidate(name, version, link): + try: + from pip._internal.models.candidate import InstallationCandidate + return InstallationCandidate(name=name, version=version, link=link) + except ImportError: + return SimpleNamespace(name=name, version=version, link=link) + + +def link_for_url(url): + try: + from pip._internal.models.link import Link + return Link(url) + except ImportError: + return AddedSourceLink(url, os.path.basename(urlparse(url).path)) + + +def get_graalpy_candidates(name): + repository = get_patch_repository() + candidates = [] + for add_source in repository.get_add_sources(name) or []: + version = add_source['version'] + url = add_source['url'] + match = re.search(r'\.(tar\.(?:gz|bz2|xz)|zip|whl)$', urlparse(url).path) + assert match, "Couldn't determine URL suffix" + suffix = match.group(1) + # We need to force the filename to match the usual convention, otherwise we won't find a patch + link = AddedSourceLink(url, f'{name}-{version}.{suffix}') + candidates.append(installation_candidate(name=name, version=version, link=link)) + if name == 'graalpy-virtualenv-seeder': + link = link_for_url(Path(os.path.join(sys.base_prefix, 'graalpy_virtualenv_seeder')).resolve().as_uri()) + candidates.append(installation_candidate(name=name, version='0.0.1', link=link)) + return candidates + + +def mark_wheel(path): + if DISABLE_PATCHING: + return + with zipfile.ZipFile(path, 'a') as z: + dist_info = None + for name in z.namelist(): + if m := re.match(r'([^/]+.dist-info)/', name): + dist_info = m.group(1) + break + assert dist_info, "Cannot find .dist_info in built wheel" + marker = f'{dist_info}/{MARKER_FILE_NAME}' + with z.open(marker, 'w'): + pass + + +def is_wheel_marked(path): + with zipfile.ZipFile(path) as z: + return any(re.match(rf'[^/]+.dist-info/{MARKER_FILE_NAME}$', f) for f in z.namelist()) diff --git a/graalpython/lib-graalpython/patches/pip-24.3.1.patch b/graalpython/lib-graalpython/patches/pip-24.3.1.patch index 1fbe76cf08..abfc736862 100644 --- a/graalpython/lib-graalpython/patches/pip-24.3.1.patch +++ b/graalpython/lib-graalpython/patches/pip-24.3.1.patch @@ -66,7 +66,7 @@ index 0d65ce3..63aa513 100644 from pip._internal.utils.misc import build_netloc from pip._internal.utils.packaging import check_requires_python from pip._internal.utils.unpacking import SUPPORTED_EXTENSIONS -+from pip._internal.utils.graalpy import apply_graalpy_sort_order, get_graalpy_candidates ++from graalpy_pip_extensions import apply_graalpy_sort_order, get_graalpy_candidates if TYPE_CHECKING: from pip._vendor.typing_extensions import TypeGuard @@ -99,7 +99,7 @@ index 5c3bce3..f3057d0 100644 import os from typing import Iterable, Optional, Tuple -+from pip._internal.utils.graalpy import AddedSourceLink ++from graalpy_pip_extensions import AddedSourceLink from pip._vendor.requests.models import Response from pip._internal.cli.progress_bars import get_download_progress_renderer @@ -120,7 +120,7 @@ index aef42aa..0dcc357 100644 file.save() record_installed(file.src_record_path, file.dest_path, file.changed) -+ from pip._internal.utils.graalpy import apply_graalpy_patches ++ from graalpy_pip_extensions import apply_graalpy_patches + apply_graalpy_patches(wheel_path, lib_dir) + def pyc_source_file_paths() -> Generator[str, None, None]: @@ -134,7 +134,7 @@ index 6617644..ad52082 100644 from pip._internal.req.req_install import InstallRequirement from pip._internal.utils.direct_url_helpers import direct_url_from_link from pip._internal.utils.misc import normalize_version_info -+from pip._internal.utils import graalpy ++import graalpy_pip_extensions as graalpy from .base import Candidate, Requirement, format_name @@ -147,366 +147,6 @@ index 6617644..ad52082 100644 def get_install_requirement(self) -> Optional[InstallRequirement]: return self._ireq -diff --git a/pip/_internal/utils/graalpy.py b/pip/_internal/utils/graalpy.py -new file mode 100644 -index 0000000..cdc4852 ---- /dev/null -+++ b/pip/_internal/utils/graalpy.py -@@ -0,0 +1,354 @@ -+import abc -+import logging -+import os -+import re -+import sys -+import tempfile -+import zipfile -+from contextlib import contextmanager -+from pathlib import Path -+from tomllib import TOMLDecodeError -+from urllib.parse import urlparse, urljoin, urlunparse -+ -+from pip._internal.models.candidate import InstallationCandidate -+from pip._internal.models.link import Link -+from pip._internal.utils.urls import url_to_path, path_to_url -+from pip._vendor import tomli, requests -+from pip._vendor.packaging.specifiers import SpecifierSet -+from pip._vendor.packaging.utils import canonicalize_name -+from pip._vendor.packaging.version import VERSION_PATTERN -+ -+MARKER_FILE_NAME = 'GRAALPY_MARKER' -+METADATA_FILENAME = 'metadata.toml' -+DEFAULT_PATCHES_PATH = Path(__graalpython__.core_home) / 'patches' -+VERSION_PARAMETER = '' -+DEFAULT_PATCHES_URL = f'https://raw.githubusercontent.com/oracle/graalpython/refs/heads/github/patches/{VERSION_PARAMETER}/graalpython/lib-graalpython/patches/' -+ -+PATCHES_URL = os.environ.get('PIP_GRAALPY_PATCHES_URL', DEFAULT_PATCHES_URL) -+DISABLED_PATCHES_URL = 'disabled' -+DISABLE_PATCHING = os.environ.get('PIP_GRAALPY_DISABLE_PATCHING', '').lower() in ('true', '1') -+DISABLE_VERSION_SELECTION = os.environ.get('PIP_GRAALPY_DISABLE_VERSION_SELECTION', '').lower() in ('true', '1') -+ -+GRAALPY_VERSION = os.environ.get('TEST_PIP_GRAALPY_VERSION', __graalpython__.get_graalvm_version()) -+ -+logger = logging.getLogger(__name__) -+ -+ -+def url_for_file(patches_url, filename): -+ scheme, netloc, path, params, query, fragment = urlparse(patches_url) -+ path = urljoin(path, filename) -+ return urlunparse((scheme, netloc, path, params, query, fragment)) -+ -+ -+class RepositoryException(Exception): -+ pass -+ -+ -+class RepositoryNotFound(RepositoryException): -+ pass -+ -+ -+class AbstractPatchRepository(metaclass=abc.ABCMeta): -+ def __init__(self, metadata: dict): -+ self._repository = metadata -+ -+ @staticmethod -+ def metadata_from_string(metadata_content) -> dict: -+ try: -+ parsed_metadata = tomli.loads(metadata_content) -+ return {canonicalize_name(name): data for name, data in parsed_metadata.items()} -+ except TOMLDecodeError as e: -+ raise RepositoryException(f"'{METADATA_FILENAME}' cannot be parsed: {e}") -+ -+ def get_rules(self, name): -+ if metadata := self._repository.get(canonicalize_name(name)): -+ return metadata.get('rules') -+ -+ def get_add_sources(self, name): -+ if metadata := self._repository.get(canonicalize_name(name)): -+ return metadata.get('add-sources') -+ -+ def get_priority_for_version(self, name, version): -+ if rules := self.get_rules(name): -+ for rule in rules: -+ if self.rule_matches_version(rule, version): -+ return rule.get('install-priority', 1) -+ return 0 -+ -+ @staticmethod -+ def rule_matches_version(rule, version): -+ return not rule.get('version') or SpecifierSet(rule['version']).contains(version) -+ -+ def get_suggested_version_specs(self, name): -+ versions = set() -+ if rules := self.get_rules(name): -+ for rule in rules: -+ if 'patch' in rule and rule.get('install-priority', 1) > 0 and (version := rule.get('version')): -+ versions.add(version) -+ return versions -+ -+ def get_matching_rule(self, name, requested_version, dist_type): -+ if metadata := self.get_rules(name): -+ for rule in metadata: -+ if rule.get('dist-type', dist_type) != dist_type: -+ continue -+ if not self.rule_matches_version(rule, requested_version): -+ continue -+ return rule -+ -+ @abc.abstractmethod -+ def resolve_patch(self, patch_name: str): -+ pass -+ -+ -+class EmptyRepository(AbstractPatchRepository): -+ def __init__(self): -+ super().__init__({}) -+ -+ def resolve_patch(self, patch_name: str): -+ raise AssertionError("Invalid call") -+ -+ -+class LocalPatchRepository(AbstractPatchRepository): -+ def __init__(self, patches_path: Path, repository_data: dict): -+ super().__init__(repository_data) -+ self.patches_path = patches_path -+ -+ @classmethod -+ def from_path(cls, patches_path: Path): -+ try: -+ with open(patches_path / METADATA_FILENAME) as f: -+ metadata_content = f.read() -+ except FileNotFoundError: -+ raise RepositoryNotFound(f"'{METADATA_FILENAME}' does not exist") -+ except OSError as e: -+ raise RepositoryException(f"'{METADATA_FILENAME}' cannot be read: {e}") -+ return cls(patches_path, cls.metadata_from_string(metadata_content)) -+ -+ @contextmanager -+ def resolve_patch(self, patch_name: str): -+ yield self.patches_path / patch_name -+ -+ -+class RemotePatchRepository(AbstractPatchRepository): -+ def __init__(self, patches_url: str, repository_data: dict): -+ super().__init__(repository_data) -+ self.patches_url = patches_url -+ -+ @staticmethod -+ def get_session(): -+ from pip._internal.cli.index_command import _GRAALPY_SESSION -+ return _GRAALPY_SESSION or requests.Session() -+ -+ @classmethod -+ def from_url(cls, patches_url: str): -+ try: -+ url = url_for_file(patches_url, METADATA_FILENAME) -+ response = cls.get_session().get(url) -+ if response.status_code == 404: -+ raise RepositoryNotFound(f"'{METADATA_FILENAME}' not found") -+ response.raise_for_status() -+ if not response.encoding: -+ response.encoding = 'utf-8' -+ metadata_content = response.text -+ except Exception as e: -+ raise RepositoryException(f"'{METADATA_FILENAME}' cannot be retrieved': {e}") -+ return cls(patches_url, cls.metadata_from_string(metadata_content)) -+ -+ @contextmanager -+ def resolve_patch(self, patch_name: str): -+ try: -+ response = self.get_session().get(url_for_file(self.patches_url, patch_name)) -+ response.raise_for_status() -+ except requests.RequestException as e: -+ logger.warning("Failed to download GraalPy patch '%s': %s", patch_name, e) -+ yield None -+ else: -+ with tempfile.TemporaryDirectory() as tempdir: -+ if not response.encoding: -+ response.encoding = 'utf-8' -+ patch_file = Path(tempdir) / patch_name -+ with open(patch_file, 'w') as f: -+ f.write(response.text) -+ yield patch_file -+ -+ -+__PATCH_REPOSITORY = None -+ -+ -+def repository_from_url_or_path(url_or_path): -+ if '://' not in url_or_path: -+ return LocalPatchRepository.from_path(Path(url_or_path)) -+ elif url_or_path.startswith('file:'): -+ patches_path = Path(url_to_path(url_or_path)) -+ return LocalPatchRepository.from_path(patches_path) -+ else: -+ patches_url = url_or_path -+ if not patches_url.endswith('/'): -+ patches_url += '/' -+ return RemotePatchRepository.from_url(patches_url) -+ -+ -+def create_patch_repository(patches_url): -+ if patches_url and VERSION_PARAMETER in patches_url: -+ if not GRAALPY_VERSION.endswith('-dev'): -+ patches_url = patches_url.replace(VERSION_PARAMETER, GRAALPY_VERSION) -+ else: -+ logger.debug("Skipping versioned GraalPy patch repository on snapshot build") -+ patches_url = None -+ if patches_url and patches_url != DISABLED_PATCHES_URL: -+ logger.info( -+ "Loading GraalPy post-release patch repository from %s. " -+ "This can be controlled with PIP_GRAALPY_PATCHES_URL environment variable. Set to '%s' to disable", -+ patches_url, DISABLED_PATCHES_URL) -+ try: -+ return repository_from_url_or_path(patches_url) -+ except RepositoryException as e: -+ if patches_url == DEFAULT_PATCHES_URL and isinstance(e, RepositoryNotFound): -+ logger.info("No post-release patch repository published yet") -+ else: -+ logger.warning("Failed to load GraalPy patch repository: %s", e) -+ logger.warning("Falling back to bundled GraalPy patch repository") -+ try: -+ return LocalPatchRepository.from_path(DEFAULT_PATCHES_PATH) -+ except RepositoryException as e: -+ logger.warning("Failed to load internal GraalPy patch repository: %s", e) -+ return EmptyRepository() -+ -+ -+def get_patch_repository(): -+ global __PATCH_REPOSITORY -+ if not __PATCH_REPOSITORY: -+ __PATCH_REPOSITORY = create_patch_repository(PATCHES_URL) -+ return __PATCH_REPOSITORY -+ -+ -+def apply_graalpy_patches(filename, location): -+ """ -+ Applies any GraalPy patches to package extracted from 'filename' into 'location'. -+ Note that 'location' must be the parent directory of the package directory itself. -+ For example: /path/to/site-package and not /path/to/site-packages/mypackage. -+ """ -+ if DISABLE_PATCHING: -+ return -+ -+ # we expect filename to be something like "pytest-5.4.2-py3-none-any.whl" -+ archive_name = os.path.basename(filename) -+ name_ver_match = re.match(fr"^(?P.*?)-(?P{VERSION_PATTERN}).*?\.(?Ptar\.gz|tar|whl|zip)$", -+ archive_name, re.VERBOSE | re.I) -+ if not name_ver_match: -+ logger.warning(f"GraalPy warning: could not parse package name, version, or format from {archive_name!r}.\n" -+ "Could not determine if any GraalPy specific patches need to be applied.") -+ return -+ -+ name = name_ver_match.group('name') -+ version = name_ver_match.group('version') -+ suffix = name_ver_match.group('suffix') -+ is_wheel = suffix == "whl" -+ -+ if is_wheel and is_wheel_marked(filename): -+ # We already processed it when building from source -+ return -+ -+ import autopatch_capi -+ import subprocess -+ -+ autopatch_capi.auto_patch_tree(location) -+ -+ logger.info(f"Looking for GraalPy patches for {name}") -+ repository = get_patch_repository() -+ -+ if is_wheel: -+ # patches intended for binary distribution: -+ rule = repository.get_matching_rule(name, version, 'wheel') -+ else: -+ # patches intended for source distribution if applicable -+ rule = repository.get_matching_rule(name, version, 'sdist') -+ if not rule: -+ rule = repository.get_matching_rule(name, version, 'wheel') -+ if rule and (subdir := rule.get('subdir')): -+ # we may need to change wd if we are actually patching a source distribution -+ # with a patch intended for a binary distribution, because in the source -+ # distribution the actual deployed sources may be in a subdirectory (typically "src") -+ location = os.path.join(location, subdir) -+ if rule: -+ if patch := rule.get('patch'): -+ with repository.resolve_patch(patch) as patch_path: -+ if not patch_path: -+ return -+ logger.info(f"Patching package {name} using {patch}") -+ exe = '.exe' if os.name == 'nt' else '' -+ try: -+ subprocess.run([f"patch{exe}", "-f", "-d", location, "-p1", "-i", str(patch_path)], check=True) -+ except FileNotFoundError: -+ logger.warning( -+ "WARNING: GraalPy needs the 'patch' utility to apply compatibility patches. Please install it using your system's package manager.") -+ except subprocess.CalledProcessError: -+ logger.warning(f"Applying GraalPy patch failed for {name}. The package may still work.") -+ except Exception: -+ logger.exception(f"Failed to execute patch utility") -+ elif version_specs := repository.get_suggested_version_specs(name): -+ logger.info("We have patches to make this package work on GraalVM for some version(s).") -+ logger.info("If installing or running fails, consider using one of the versions that we have patches for:") -+ for version_spec in version_specs: -+ logger.info(f'{name} {version_spec}') -+ -+ -+def apply_graalpy_sort_order(sort_key_func): -+ if DISABLE_VERSION_SELECTION: -+ return sort_key_func -+ -+ def wrapper(self, candidate): -+ default_sort_key = sort_key_func(self, candidate) -+ priority = get_patch_repository().get_priority_for_version(candidate.name, str(candidate.version)) -+ return priority, default_sort_key -+ -+ return wrapper -+ -+ -+class AddedSourceLink(Link): -+ def __init__(self, url: str, filename: str): -+ super().__init__(url) -+ self._filename = filename -+ -+ @property -+ def filename(self) -> str: -+ return self._filename -+ -+ -+def get_graalpy_candidates(name): -+ repository = get_patch_repository() -+ candidates = [] -+ for add_source in repository.get_add_sources(name) or []: -+ version = add_source['version'] -+ url = add_source['url'] -+ match = re.search(r'\.(tar\.(?:gz|bz2|xz)|zip|whl)$', urlparse(url).path) -+ assert match, "Couldn't determine URL suffix" -+ suffix = match.group(1) -+ # We need to force the filename to match the usual convention, otherwise we won't find a patch -+ link = AddedSourceLink(url, f'{name}-{version}.{suffix}') -+ candidates.append(InstallationCandidate(name=name, version=version, link=link)) -+ if name == 'graalpy-virtualenv-seeder': -+ link = Link(path_to_url(os.path.join(sys.base_prefix, 'graalpy_virtualenv_seeder'))) -+ candidates.append(InstallationCandidate(name=name, version='0.0.1', link=link)) -+ return candidates -+ -+ -+def mark_wheel(path): -+ if DISABLE_PATCHING: -+ return -+ with zipfile.ZipFile(path, 'a') as z: -+ dist_info = None -+ for name in z.namelist(): -+ if m := re.match(r'([^/]+.dist-info)/', name): -+ dist_info = m.group(1) -+ break -+ assert dist_info, "Cannot find .dist_info in built wheel" -+ marker = f'{dist_info}/{MARKER_FILE_NAME}' -+ with z.open(marker, 'w'): -+ pass -+ -+ -+def is_wheel_marked(path): -+ with zipfile.ZipFile(path) as z: -+ return any(re.match(rf'[^/]+.dist-info/{MARKER_FILE_NAME}$', f) for f in z.namelist()) diff --git a/pip/_internal/utils/unpacking.py b/pip/_internal/utils/unpacking.py index 875e30e..f6562cf 100644 --- a/pip/_internal/utils/unpacking.py @@ -515,7 +155,7 @@ index 875e30e..f6562cf 100644 content_type, ) raise InstallationError(f"Cannot determine archive format of {location}") -+ from pip._internal.utils.graalpy import apply_graalpy_patches ++ from graalpy_pip_extensions import apply_graalpy_patches + apply_graalpy_patches(filename, location) diff --git a/pip/_internal/wheel_builder.py b/pip/_internal/wheel_builder.py index 93f8e1f..32fd4ab 100644 @@ -525,7 +165,7 @@ index 93f8e1f..32fd4ab 100644 import shutil from typing import Iterable, List, Optional, Tuple -+from pip._internal.utils import graalpy ++import graalpy_pip_extensions as graalpy from pip._vendor.packaging.utils import canonicalize_name, canonicalize_version from pip._vendor.packaging.version import InvalidVersion, Version diff --git a/graalpython/lib-graalpython/pip_audit_hook.py b/graalpython/lib-graalpython/pip_audit_hook.py new file mode 100644 index 0000000000..03649c16af --- /dev/null +++ b/graalpython/lib-graalpython/pip_audit_hook.py @@ -0,0 +1,231 @@ +# Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. +# DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. +# +# The Universal Permissive License (UPL), Version 1.0 +# +# Subject to the condition set forth below, permission is hereby granted to any +# person obtaining a copy of this software, associated documentation and/or +# data (collectively the "Software"), free of charge and under any and all +# copyright rights in the Software, and any and all patent rights owned or +# freely licensable by each licensor hereunder covering either (i) the +# unmodified Software as contributed to or provided by such licensor, or (ii) +# the Larger Works (as defined below), to deal in both +# +# (a) the Software, and +# +# (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if +# one is included with the Software each a "Larger Work" to which the Software +# is contributed by such licensors), +# +# without restriction, including without limitation the rights to copy, create +# derivative works of, display, perform, and distribute the Software and make, +# use, sell, offer for sale, import, export, have made, and have sold the +# Software and the Larger Work(s), and to sublicense the foregoing rights on +# either these or other terms. +# +# This license is subject to the following condition: +# +# The above copyright notice and either this complete permission notice or a +# minimum a reference to the UPL must be included in all copies or substantial +# portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import os +import re +import sys +from pathlib import Path +from urllib.parse import urlparse +from warnings import warn + +import graalpy_pip_extensions as graalpy + + +PIP_CLI_PARSE_ARGS = "pip.cli.parse_args" +PIP_FIND_ALL_CANDIDATES = "pip.package_finder.find_all_candidates" +PIP_REQUIREMENT_SOURCE_READY = "pip.requirement.source_ready" +PIP_WHEEL_INSTALL = "pip.wheel.install" + +GRAALPY_WHEELS_URL = "https://www.graalvm.org/python/wheels/" +PIP_INDEX_URL_ENV_VAR = "PIP_INDEX_URL" +PIP_CACHE_DIR_ENV_VAR = "PIP_CACHE_DIR" +CACHE_DIR_SUFFIX = "-graalpy" + +_PATCHED_SOURCES = set() +_PATCHED_DISTS = set() + + +def _apply_cli_defaults(options, args): + if PIP_INDEX_URL_ENV_VAR not in os.environ and hasattr(options, "extra_index_urls"): + extra_index_urls = getattr(options, "extra_index_urls") + if extra_index_urls is None: + extra_index_urls = [] + elif not isinstance(extra_index_urls, list): + extra_index_urls = list(extra_index_urls) + if GRAALPY_WHEELS_URL not in extra_index_urls: + extra_index_urls.insert(0, GRAALPY_WHEELS_URL) + options.extra_index_urls = extra_index_urls + + if PIP_CACHE_DIR_ENV_VAR not in os.environ and hasattr(options, "cache_dir") and not any(arg == "--cache-dir" or arg.startswith("--cache-dir=") or arg == "--no-cache-dir" for arg in args or ()): + cache_dir = getattr(options, "cache_dir") + if cache_dir: + cache_path = Path(cache_dir) + if cache_path.name and not cache_path.name.endswith(CACHE_DIR_SUFFIX): + options.cache_dir = str(cache_path.with_name(cache_path.name + CACHE_DIR_SUFFIX)) + + +def _candidate_details(candidate): + link = candidate.link + return ( + candidate.name, + str(candidate.version), + { + "url": link.url, + "filename": link.filename, + "comes_from": getattr(link, "comes_from", "GraalPy compatibility patches"), + "requires_python": getattr(link, "requires_python", None), + "yanked_reason": getattr(link, "yanked_reason", None), + }, + ) + + +def _add_graalpy_candidates(project_name, extra_candidates): + extra_candidates.extend(_candidate_details(candidate) for candidate in graalpy.get_graalpy_candidates(project_name)) + + +def _archive_name(link): + if filename := getattr(link, "filename", None): + return os.path.basename(filename) + path = urlparse(str(link)).path + return os.path.basename(path) or os.path.basename(str(link)) + + +def _dist_key(filename): + match = re.match(r"^(?P.*?)-(?P[^-]+).*?\.(?:tar\.(?:gz|bz2|xz)|tar|zip|whl)$", filename, re.I) + if match: + return graalpy.canonicalize_name(match.group("name")), match.group("version") + return None + + +def _apply_graalpy_source_patches(link, source_dir): + if source_dir is None: + return + source_path = Path(source_dir).resolve() + if source_path in _PATCHED_SOURCES: + return + _PATCHED_SOURCES.add(source_path) + + archive_name = _archive_name(link) + if key := _dist_key(archive_name): + _PATCHED_DISTS.add(key) + graalpy.apply_graalpy_patches(archive_name, source_path, warn_suggested_versions=True) + + +def _find_dist_info(root): + for path in root.iterdir(): + if path.is_dir() and path.name.endswith(".dist-info"): + return path + raise RuntimeError("Cannot find .dist-info directory in wheel") + + +def _write_record(root, dist_info): + import base64 + import csv + import hashlib + + record = dist_info / "RECORD" + rows = [] + for path in sorted(p for p in root.rglob("*") if p.is_file()): + rel = path.relative_to(root).as_posix() + if path == record: + rows.append((rel, "", "")) + continue + digest = hashlib.sha256(path.read_bytes()).digest() + encoded = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + rows.append((rel, f"sha256={encoded}", str(path.stat().st_size))) + with record.open("w", encoding="utf-8", newline="") as f: + csv.writer(f).writerows(rows) + + +def _extract_wheel(wheel_path): + import zipfile + from tempfile import TemporaryDirectory + + tmpdir = TemporaryDirectory() + root = Path(tmpdir.name) + with zipfile.ZipFile(wheel_path) as z: + for info in z.infolist(): + target = Path(z.extract(info, root)) + mode = info.external_attr >> 16 + if mode and not info.is_dir(): + target.chmod(mode & 0o777) + return root, tmpdir + + +def _rewrite_wheel(root, wheel_path): + import zipfile + from tempfile import NamedTemporaryFile + + wheel_path = Path(wheel_path) + with NamedTemporaryFile(dir=wheel_path.parent, prefix=wheel_path.name, suffix=".tmp", delete=False) as tmp_file: + tmp_path = Path(tmp_file.name) + try: + with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as z: + for path in sorted(p for p in root.rglob("*") if p.is_file()): + z.write(path, path.relative_to(root).as_posix()) + os.replace(tmp_path, wheel_path) + finally: + try: + tmp_path.unlink() + except FileNotFoundError: + pass + + +def _mark_extracted_wheel(root): + dist_info = _find_dist_info(root) + (dist_info / graalpy.MARKER_FILE_NAME).touch() + _write_record(root, dist_info) + + +def _apply_graalpy_wheel_patches(wheel_path): + wheel_path = Path(wheel_path) + if graalpy.DISABLE_PATCHING or not wheel_path.is_file() or wheel_path.suffix != ".whl" or graalpy.is_wheel_marked(wheel_path): + return + if _dist_key(wheel_path.name) in _PATCHED_DISTS: + graalpy.mark_wheel(wheel_path) + return + + root, cleanup = _extract_wheel(wheel_path) + try: + graalpy.apply_graalpy_patches(wheel_path, root, warn_suggested_versions=True) + _mark_extracted_wheel(root) + _rewrite_wheel(root, wheel_path) + finally: + cleanup.cleanup() + + +def _audit_hook(event, args): + try: + if event == PIP_CLI_PARSE_ARGS: + options, parsed_args, _verbosity = args + _apply_cli_defaults(options, parsed_args) + elif event == PIP_FIND_ALL_CANDIDATES: + project_name, _candidates, extra_candidates = args + _add_graalpy_candidates(project_name, extra_candidates) + elif event == PIP_REQUIREMENT_SOURCE_READY: + _name, _specifier, link, source_dir, _editable, _verbosity = args + _apply_graalpy_source_patches(link, source_dir) + elif event == PIP_WHEEL_INSTALL: + _name, wheel_path = args + _apply_graalpy_wheel_patches(wheel_path) + except Exception as e: + warn(f"failed to apply pip compatibility patches: {e}") + + +sys.addaudithook(_audit_hook) diff --git a/graalpython/lib-graalpython/pip_hook.py b/graalpython/lib-graalpython/pip_hook.py index 8d466d4b8b..8ef0c011c6 100644 --- a/graalpython/lib-graalpython/pip_hook.py +++ b/graalpython/lib-graalpython/pip_hook.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019, 2023, Oracle and/or its affiliates. All rights reserved. +# Copyright (c) 2019, 2026, Oracle and/or its affiliates. All rights reserved. # DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. # # The Universal Permissive License (UPL), Version 1.0 @@ -38,48 +38,14 @@ # SOFTWARE. import sys -WARNED = False - -def print_version_warning(): - global WARNED - if not WARNED: - from warnings import warn - warn("You are using an untested version of pip. GraalPy " + - "provides patches and workarounds for a number of packages when used with " + - "compatible pip versions. We recommend to stick with the pip version that " + - "ships with this version of GraalPy.", RuntimeWarning) - WARNED = True - - -class PipLoader: - def __init__(self, real_loader): - self.real_loader = real_loader - - def create_module(self, spec): - return self.real_loader.create_module(spec) - - def exec_module(self, module): - self.real_loader.exec_module(module) - if not getattr(module, '__GRAALPY_PATCHED', False): - print_version_warning() - - -class PipImportHook: - @staticmethod - def _wrap_real_spec(fullname, path, target): - for finder in sys.meta_path: - if finder is PipImportHook: - continue - real_spec = finder.find_spec(fullname, path, target) - if real_spec: - real_spec.loader = PipLoader(real_spec.loader) - return real_spec - - @staticmethod - def find_spec(fullname, path, target=None): - if fullname == "pip": - return PipImportHook._wrap_real_spec(fullname, path, target) - - -sys.meta_path.insert(0, PipImportHook) +pip = sys.modules.get("pip") +assert pip, "This should only be loaded after pip is" +if not getattr(pip, "__GRAALPY_PATCHED", False): + from warnings import warn + warn("You are using an unpatched version of pip. GraalPy " + "provides patches and workarounds for a number of packages when used with " + "patched pip versions. We recommend to stick with the pip version that " + "ships with this version of GraalPy or a pip version that implements " + "experimental audit hooks.", RuntimeWarning) + __graalpython__.load_file("pip_audit_hook") diff --git a/graalpython/lib-python/3/ensurepip/_bundled/pip-24.3.1-py3-none-any.whl b/graalpython/lib-python/3/ensurepip/_bundled/pip-24.3.1-py3-none-any.whl index 6e3c73e96528e4e32fa9a6efb82b1859cc74212c..a7902016df5d350e99b189336e0aae862a800d6d 100644 GIT binary patch delta 30085 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 XFgC`^GEZZsG3ree6ho;9yd>~HS0sl} diff --git a/graalpython/lib-python/3/importlib/_bootstrap.py b/graalpython/lib-python/3/importlib/_bootstrap.py index d223bc1a7b..3a2c88600d 100644 --- a/graalpython/lib-python/3/importlib/_bootstrap.py +++ b/graalpython/lib-python/3/importlib/_bootstrap.py @@ -1362,7 +1362,12 @@ def _find_and_load(name, import_): with _ModuleLockManager(name): module = sys.modules.get(name, _NEEDS_LOADING) if module is _NEEDS_LOADING: - return _find_and_load_unlocked(name, import_) + # GraalPy change: we may want to load our pip hook + mod = _find_and_load_unlocked(name, import_) + if name == "pip": + __graalpython__.load_file("pip_hook") + return mod + # End GraalPy change # Optimization: only call _bootstrap._lock_unlock_module() if # module.__spec__._initializing is True. From 246a3cc82ef2eba9615bf36dbe43b1a838aec365 Mon Sep 17 00:00:00 2001 From: Tim Felgentreff Date: Fri, 12 Jun 2026 20:16:23 +0200 Subject: [PATCH 3/6] [GR-76470] Fix UPL header in pip audit hook --- graalpython/lib-graalpython/pip_audit_hook.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graalpython/lib-graalpython/pip_audit_hook.py b/graalpython/lib-graalpython/pip_audit_hook.py index 03649c16af..495791de4f 100644 --- a/graalpython/lib-graalpython/pip_audit_hook.py +++ b/graalpython/lib-graalpython/pip_audit_hook.py @@ -25,7 +25,7 @@ # # This license is subject to the following condition: # -# The above copyright notice and either this complete permission notice or a +# The above copyright notice and either this complete permission notice or at a # minimum a reference to the UPL must be included in all copies or substantial # portions of the Software. # From f9ea569899607f4ce4b79cd353ad28f98a5eed57 Mon Sep 17 00:00:00 2001 From: Tim Felgentreff Date: Fri, 12 Jun 2026 20:17:38 +0200 Subject: [PATCH 4/6] [GR-76470] Document GraalOS pip audit events --- graalpython/lib-graalpython/pip_audit_hook.py | 1 + 1 file changed, 1 insertion(+) diff --git a/graalpython/lib-graalpython/pip_audit_hook.py b/graalpython/lib-graalpython/pip_audit_hook.py index 495791de4f..eeaa50bf01 100644 --- a/graalpython/lib-graalpython/pip_audit_hook.py +++ b/graalpython/lib-graalpython/pip_audit_hook.py @@ -47,6 +47,7 @@ import graalpy_pip_extensions as graalpy +# These events are emitted by the GraalOS pip patch; they are not upstream pip audit events. PIP_CLI_PARSE_ARGS = "pip.cli.parse_args" PIP_FIND_ALL_CANDIDATES = "pip.package_finder.find_all_candidates" PIP_REQUIREMENT_SOURCE_READY = "pip.requirement.source_ready" From b1406a6eeb47da6e31ec5f1880b340242ba6db1d Mon Sep 17 00:00:00 2001 From: Tim Felgentreff Date: Fri, 12 Jun 2026 20:46:44 +0200 Subject: [PATCH 5/6] [GR-76470] Keep load_file out of compiled code --- .../graal/python/builtins/modules/GraalPythonModuleBuiltins.java | 1 + 1 file changed, 1 insertion(+) diff --git a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/modules/GraalPythonModuleBuiltins.java b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/modules/GraalPythonModuleBuiltins.java index de094dd1c4..559fea13d7 100644 --- a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/modules/GraalPythonModuleBuiltins.java +++ b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/modules/GraalPythonModuleBuiltins.java @@ -1554,6 +1554,7 @@ static PTuple doCreate(long arrowArrayAddr, long arrowSchemaAddr, @Builtin(name = "load_file", minNumOfPositionalArgs = 1) @GenerateNodeFactory public abstract static class LoadFile extends PythonUnaryBuiltinNode { + @TruffleBoundary @Specialization static PNone doit(TruffleString name, @Bind PythonContext context) { From f9131b8ab4f07f1a5825163ad78503289aea42da Mon Sep 17 00:00:00 2001 From: Tim Felgentreff Date: Fri, 12 Jun 2026 21:58:55 +0200 Subject: [PATCH 6/6] [GR-76470] Pass patch workdir as string --- graalpython/lib-graalpython/modules/graalpy_pip_extensions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graalpython/lib-graalpython/modules/graalpy_pip_extensions.py b/graalpython/lib-graalpython/modules/graalpy_pip_extensions.py index 8c9c836bf7..e963eefdf0 100644 --- a/graalpython/lib-graalpython/modules/graalpy_pip_extensions.py +++ b/graalpython/lib-graalpython/modules/graalpy_pip_extensions.py @@ -345,7 +345,7 @@ def apply_graalpy_patches(filename, location, warn_suggested_versions=False): logger.info(f"Patching package {name} using {patch}") exe = '.exe' if os.name == 'nt' else '' try: - subprocess.run([f"patch{exe}", "-f", "-d", location, "-p1", "-i", str(patch_path)], check=True) + subprocess.run([f"patch{exe}", "-f", "-d", str(location), "-p1", "-i", str(patch_path)], check=True) except FileNotFoundError: logger.warning( "WARNING: GraalPy needs the 'patch' utility to apply compatibility patches. Please install it using your system's package manager.")