Skip to content

Commit 970b829

Browse files
timfelmsimacek
andauthored
Add GraalPy support (#2859)
Co-authored-by: Michael Simacek <[email protected]>
1 parent 60a6956 commit 970b829

File tree

15 files changed

+130
-14
lines changed

15 files changed

+130
-14
lines changed

.github/workflows/check.yaml

+2
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ jobs:
3232
- pypy-3.10
3333
- pypy-3.9
3434
- pypy-3.8
35+
- graalpy-24.1
3536
os:
3637
- ubuntu-latest
3738
- macos-latest
@@ -41,6 +42,7 @@ jobs:
4142
- { os: macos-latest, py: "[email protected]" }
4243
- { os: macos-latest, py: "[email protected]" }
4344
exclude:
45+
- { os: windows-latest, py: "graalpy-24.1" }
4446
- { os: windows-latest, py: "pypy-3.10" }
4547
- { os: windows-latest, py: "pypy-3.9" }
4648
- { os: windows-latest, py: "pypy-3.8" }

docs/changelog/2832.feature.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add support for `GraalPy <https://github.com/oracle/graalpython>`_.

pyproject.toml

+3-1
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ optional-dependencies.test = [
6464
"packaging>=23.1",
6565
"pytest>=7.4",
6666
"pytest-env>=0.8.2",
67-
"pytest-freezer>=0.4.8; platform_python_implementation=='PyPy' or (platform_python_implementation=='CPython' and sys_platform=='win32' and python_version>='3.13')",
67+
"pytest-freezer>=0.4.8; platform_python_implementation=='PyPy' or platform_python_implementation=='GraalVM' or (platform_python_implementation=='CPython' and sys_platform=='win32' and python_version>='3.13')",
6868
"pytest-mock>=3.11.1",
6969
"pytest-randomly>=3.12",
7070
"pytest-timeout>=2.1",
@@ -87,6 +87,8 @@ entry-points."virtualenv.create".cpython3-mac-brew = "virtualenv.create.via_glob
8787
entry-points."virtualenv.create".cpython3-mac-framework = "virtualenv.create.via_global_ref.builtin.cpython.mac_os:CPython3macOsFramework"
8888
entry-points."virtualenv.create".cpython3-posix = "virtualenv.create.via_global_ref.builtin.cpython.cpython3:CPython3Posix"
8989
entry-points."virtualenv.create".cpython3-win = "virtualenv.create.via_global_ref.builtin.cpython.cpython3:CPython3Windows"
90+
entry-points."virtualenv.create".graalpy-posix = "virtualenv.create.via_global_ref.builtin.graalpy:GraalPyPosix"
91+
entry-points."virtualenv.create".graalpy-win = "virtualenv.create.via_global_ref.builtin.graalpy:GraalPyWindows"
9092
entry-points."virtualenv.create".pypy3-posix = "virtualenv.create.via_global_ref.builtin.pypy.pypy3:PyPy3Posix"
9193
entry-points."virtualenv.create".pypy3-win = "virtualenv.create.via_global_ref.builtin.pypy.pypy3:Pypy3Windows"
9294
entry-points."virtualenv.create".venv = "virtualenv.create.via_global_ref.venv:Venv"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
from __future__ import annotations
2+
3+
from abc import ABC
4+
from pathlib import Path
5+
6+
from virtualenv.create.describe import PosixSupports, WindowsSupports
7+
from virtualenv.create.via_global_ref.builtin.ref import PathRefToDest, RefMust, RefWhen
8+
from virtualenv.create.via_global_ref.builtin.via_global_self_do import ViaGlobalRefVirtualenvBuiltin
9+
10+
11+
class GraalPy(ViaGlobalRefVirtualenvBuiltin, ABC):
12+
@classmethod
13+
def can_describe(cls, interpreter):
14+
return interpreter.implementation == "GraalVM" and super().can_describe(interpreter)
15+
16+
@classmethod
17+
def exe_stem(cls):
18+
return "graalpy"
19+
20+
@classmethod
21+
def exe_names(cls, interpreter):
22+
return {
23+
cls.exe_stem(),
24+
"python",
25+
f"python{interpreter.version_info.major}",
26+
f"python{interpreter.version_info.major}.{interpreter.version_info.minor}",
27+
}
28+
29+
@classmethod
30+
def _executables(cls, interpreter):
31+
host = Path(interpreter.system_executable)
32+
targets = sorted(f"{name}{cls.suffix}" for name in cls.exe_names(interpreter))
33+
yield host, targets, RefMust.NA, RefWhen.ANY
34+
35+
@classmethod
36+
def sources(cls, interpreter):
37+
yield from super().sources(interpreter)
38+
python_dir = Path(interpreter.system_executable).resolve().parent
39+
if python_dir.name in {"bin", "Scripts"}:
40+
python_dir = python_dir.parent
41+
42+
native_lib = cls._native_lib(python_dir / "lib", interpreter.platform)
43+
if native_lib.exists():
44+
yield PathRefToDest(native_lib, dest=lambda self, s: self.bin_dir.parent / "lib" / s.name)
45+
46+
for jvm_dir_name in ("jvm", "jvmlibs", "modules"):
47+
jvm_dir = python_dir / jvm_dir_name
48+
if jvm_dir.exists():
49+
yield PathRefToDest(jvm_dir, dest=lambda self, s: self.bin_dir.parent / s.name)
50+
51+
@classmethod
52+
def _shared_libs(cls, python_dir):
53+
raise NotImplementedError
54+
55+
def set_pyenv_cfg(self):
56+
super().set_pyenv_cfg()
57+
# GraalPy 24.0 and older had home without the bin
58+
version = self.interpreter.version_info
59+
if version.major == 3 and version.minor <= 10: # noqa: PLR2004
60+
home = Path(self.pyenv_cfg["home"])
61+
if home.name == "bin":
62+
self.pyenv_cfg["home"] = str(home.parent)
63+
64+
65+
class GraalPyPosix(GraalPy, PosixSupports):
66+
@classmethod
67+
def _native_lib(cls, lib_dir, platform):
68+
if platform == "darwin":
69+
return lib_dir / "libpythonvm.dylib"
70+
return lib_dir / "libpythonvm.so"
71+
72+
73+
class GraalPyWindows(GraalPy, WindowsSupports):
74+
@classmethod
75+
def _native_lib(cls, lib_dir, _platform):
76+
return lib_dir / "pythonvm.dll"
77+
78+
def set_pyenv_cfg(self):
79+
# GraalPy needs an additional entry in pyvenv.cfg on Windows
80+
super().set_pyenv_cfg()
81+
self.pyenv_cfg["venvlauncher_command"] = self.interpreter.system_executable
82+
83+
84+
__all__ = [
85+
"GraalPyPosix",
86+
"GraalPyWindows",
87+
]

src/virtualenv/discovery/py_info.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import os
1212
import platform
1313
import re
14+
import struct
1415
import sys
1516
import sysconfig
1617
import warnings
@@ -44,7 +45,10 @@ def abs_path(v):
4445

4546
# this is a tuple in earlier, struct later, unify to our own named tuple
4647
self.version_info = VersionInfo(*sys.version_info)
47-
self.architecture = 64 if sys.maxsize > 2**32 else 32
48+
# Use the same implementation as found in stdlib platform.architecture
49+
# to account for platforms where the maximum integer is not equal the
50+
# pointer size.
51+
self.architecture = 32 if struct.calcsize("P") == 4 else 64 # noqa: PLR2004
4852

4953
# Used to determine some file names.
5054
# See `CPython3Windows.python_zip()`.

src/virtualenv/info.py

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
IMPLEMENTATION = platform.python_implementation()
1010
IS_PYPY = IMPLEMENTATION == "PyPy"
11+
IS_GRAALPY = IMPLEMENTATION == "GraalVM"
1112
IS_CPYTHON = IMPLEMENTATION == "CPython"
1213
IS_WIN = sys.platform == "win32"
1314
IS_MAC_ARM64 = sys.platform == "darwin" and platform.machine() == "arm64"
@@ -55,6 +56,7 @@ def fs_path_id(path: str) -> str:
5556

5657
__all__ = (
5758
"IS_CPYTHON",
59+
"IS_GRAALPY",
5860
"IS_MAC_ARM64",
5961
"IS_PYPY",
6062
"IS_WIN",

tasks/pick_tox_env.py

+2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
py = sys.argv[1]
88
if py.startswith("brew@"):
99
py = py[len("brew@") :]
10+
if py.startswith("graalpy-"):
11+
py = "graalpy"
1012
env = f"TOXENV={py}"
1113
if len(sys.argv) > 2: # noqa: PLR2004
1214
env += f"\nTOX_BASEPYTHON={sys.argv[2]}"

tests/conftest.py

+8-2
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,13 @@
1313

1414
from virtualenv.app_data import AppDataDiskFolder
1515
from virtualenv.discovery.py_info import PythonInfo
16-
from virtualenv.info import IS_PYPY, IS_WIN, fs_supports_symlink
16+
from virtualenv.info import IS_GRAALPY, IS_PYPY, IS_WIN, fs_supports_symlink
1717
from virtualenv.report import LOGGER
1818

1919

2020
def pytest_addoption(parser):
2121
parser.addoption("--int", action="store_true", default=False, help="run integration tests")
22+
parser.addoption("--skip-slow", action="store_true", default=False, help="skip slow tests")
2223

2324

2425
def pytest_configure(config):
@@ -46,6 +47,11 @@ def pytest_collection_modifyitems(config, items):
4647
if item.location[0].startswith(int_location):
4748
item.add_marker(pytest.mark.skip(reason="need --int option to run"))
4849

50+
if config.getoption("--skip-slow"):
51+
for item in items:
52+
if "slow" in [mark.name for mark in item.iter_markers()]:
53+
item.add_marker(pytest.mark.skip(reason="skipped because --skip-slow was passed"))
54+
4955

5056
@pytest.fixture(scope="session")
5157
def has_symlink_support(tmp_path_factory): # noqa: ARG001
@@ -355,7 +361,7 @@ def _skip_if_test_in_system(session_app_data):
355361
pytest.skip("test not valid if run under system")
356362

357363

358-
if IS_PYPY or (IS_WIN and sys.version_info[0:2] >= (3, 13)): # https://github.com/adamchainz/time-machine/issues/456
364+
if IS_PYPY or IS_GRAALPY:
359365

360366
@pytest.fixture
361367
def time_freeze(freezer):

tests/integration/test_zipapp.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
@pytest.fixture(scope="session")
2020
def zipapp_build_env(tmp_path_factory):
2121
create_env_path = None
22-
if CURRENT.implementation != "PyPy":
22+
if CURRENT.implementation not in {"PyPy", "GraalVM"}:
2323
exe = CURRENT.executable # guaranteed to contain a recent enough pip (tox.ini)
2424
else:
2525
create_env_path = tmp_path_factory.mktemp("zipapp-create-env")
@@ -112,6 +112,7 @@ def test_zipapp_help(call_zipapp, capsys):
112112
assert not err
113113

114114

115+
@pytest.mark.slow
115116
@pytest.mark.parametrize("seeder", ["app-data", "pip"])
116117
def test_zipapp_create(call_zipapp, seeder):
117118
call_zipapp("--seeder", seeder)

tests/unit/create/test_creator.py

+1
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,7 @@ def test_create_long_path(tmp_path):
398398
subprocess.check_call([str(result.creator.script("pip")), "--version"])
399399

400400

401+
@pytest.mark.slow
401402
@pytest.mark.parametrize("creator", sorted(set(PythonInfo.current_system().creators().key_to_class) - {"builtin"}))
402403
@pytest.mark.usefixtures("session_app_data")
403404
def test_create_distutils_cfg(creator, tmp_path, monkeypatch):

tests/unit/create/via_global_ref/test_build_c_ext.py

+1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ def builtin_shows_marker_missing():
2626
return not marker.exists()
2727

2828

29+
@pytest.mark.slow
2930
@pytest.mark.xfail(
3031
condition=bool(os.environ.get("CI_RUN")),
3132
strict=False,

tests/unit/discovery/py_info/test_py_info.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -416,11 +416,11 @@ def test_fallback_existent_system_executable(mocker):
416416
mocker.patch.object(sys, "executable", current.executable)
417417

418418
# ensure it falls back to an alternate binary name that exists
419-
current._fast_get_system_executable() # noqa: SLF001
420-
assert os.path.basename(current.system_executable) in [
419+
system_executable = current._fast_get_system_executable() # noqa: SLF001
420+
assert os.path.basename(system_executable) in [
421421
f"python{v}" for v in (current.version_info.major, f"{current.version_info.major}.{current.version_info.minor}")
422422
]
423-
assert os.path.exists(current.system_executable)
423+
assert os.path.exists(system_executable)
424424

425425

426426
@pytest.mark.skipif(sys.version_info[:2] != (3, 10), reason="3.10 specific")

tests/unit/seed/embed/test_bootstrap_link_via_app_data.py

+3
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ def read_only_app_data(temp_app_data):
147147
yield temp_app_data
148148

149149

150+
@pytest.mark.slow
150151
@pytest.mark.skipif(sys.platform == "win32", reason="Windows only applies R/O to files")
151152
@pytest.mark.usefixtures("read_only_app_data")
152153
def test_base_bootstrap_link_via_app_data_not_writable(tmp_path, current_fastest):
@@ -155,6 +156,7 @@ def test_base_bootstrap_link_via_app_data_not_writable(tmp_path, current_fastest
155156
assert result
156157

157158

159+
@pytest.mark.slow
158160
@pytest.mark.skipif(sys.platform == "win32", reason="Windows only applies R/O to files")
159161
def test_populated_read_only_cache_and_symlinked_app_data(tmp_path, current_fastest, temp_app_data):
160162
dest = tmp_path / "venv"
@@ -180,6 +182,7 @@ def test_populated_read_only_cache_and_symlinked_app_data(tmp_path, current_fast
180182
check_call((str(dest.joinpath("bin/python")), "-c", "import pip"))
181183

182184

185+
@pytest.mark.slow
183186
@pytest.mark.skipif(sys.platform == "win32", reason="Windows only applies R/O to files")
184187
def test_populated_read_only_cache_and_copied_app_data(tmp_path, current_fastest, temp_app_data):
185188
dest = tmp_path / "venv"

tests/unit/seed/wheels/test_periodic_update.py

+2
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ def test_trigger_update_no_debug(for_py_version, session_app_data, tmp_path, moc
280280
monkeypatch.delenv("_VIRTUALENV_PERIODIC_UPDATE_INLINE", raising=False)
281281
current = get_embed_wheel("setuptools", for_py_version)
282282
process = mocker.MagicMock()
283+
process.pid = 123
283284
process.communicate.return_value = None, None
284285
Popen = mocker.patch("virtualenv.seed.wheels.periodic_update.Popen", return_value=process) # noqa: N806
285286

@@ -328,6 +329,7 @@ def test_trigger_update_debug(for_py_version, session_app_data, tmp_path, mocker
328329
current = get_embed_wheel("pip", for_py_version)
329330

330331
process = mocker.MagicMock()
332+
process.pid = 123
331333
process.communicate.return_value = None, None
332334
Popen = mocker.patch("virtualenv.seed.wheels.periodic_update.Popen", return_value=process) # noqa: N806
333335

tox.ini

+8-6
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ env_list =
1010
3.10
1111
3.9
1212
3.8
13+
graalpy
1314
coverage
1415
readme
1516
docs
@@ -32,12 +33,13 @@ set_env =
3233
PYTHONWARNDEFAULTENCODING = 1
3334
_COVERAGE_SRC = {envsitepackagesdir}/virtualenv
3435
commands =
35-
coverage erase
36-
coverage run -m pytest {posargs:--junitxml "{toxworkdir}/junit.{envname}.xml" tests --int}
37-
coverage combine
38-
coverage report --skip-covered --show-missing
39-
coverage xml -o "{toxworkdir}/coverage.{envname}.xml"
40-
coverage html -d {envtmpdir}/htmlcov --show-contexts --title virtualenv-{envname}-coverage
36+
!graalpy: coverage erase
37+
!graalpy: coverage run -m pytest {posargs:--junitxml "{toxworkdir}/junit.{envname}.xml" tests --int}
38+
!graalpy: coverage combine
39+
!graalpy: coverage report --skip-covered --show-missing
40+
!graalpy: coverage xml -o "{toxworkdir}/coverage.{envname}.xml"
41+
!graalpy: coverage html -d {envtmpdir}/htmlcov --show-contexts --title virtualenv-{envname}-coverage
42+
graalpy: pytest {posargs:--junitxml "{toxworkdir}/junit.{envname}.xml" tests --skip-slow}
4143
uv_seed = true
4244

4345
[testenv:fix]

0 commit comments

Comments
 (0)