Skip to content

Commit 7917269

Browse files
[ty] Add support for PyPy virtual environments (astral-sh#18203)
Co-authored-by: Alex Waygood <[email protected]>
1 parent e8d4f6d commit 7917269

File tree

2 files changed

+171
-17
lines changed

2 files changed

+171
-17
lines changed

clippy.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
doc-valid-idents = [
22
"..",
33
"CodeQL",
4+
"CPython",
45
"FastAPI",
56
"IPython",
67
"LangChain",
@@ -14,7 +15,7 @@ doc-valid-idents = [
1415
"SNMPv1",
1516
"SNMPv2",
1617
"SNMPv3",
17-
"PyFlakes"
18+
"PyFlakes",
1819
]
1920

2021
ignore-interior-mutability = [

crates/ty_python_semantic/src/site_packages.rs

Lines changed: 169 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,34 @@ impl PythonEnvironment {
6262
}
6363
}
6464

65+
/// The Python runtime that produced the venv.
66+
///
67+
/// We only need to distinguish cases that change the on-disk layout.
68+
/// Everything else can be treated like CPython.
69+
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
70+
pub(crate) enum PythonImplementation {
71+
CPython,
72+
PyPy,
73+
GraalPy,
74+
/// Fallback when the value is missing or unrecognised.
75+
/// We treat it like CPython but keep the information for diagnostics.
76+
Unknown,
77+
}
78+
79+
impl PythonImplementation {
80+
/// Return the relative path from `sys.prefix` to the `site-packages` directory
81+
/// if this is a known implementation. Return `None` if this is an unknown implementation.
82+
fn relative_site_packages_path(self, version: Option<PythonVersion>) -> Option<String> {
83+
match self {
84+
Self::CPython | Self::GraalPy => {
85+
version.map(|version| format!("lib/python{version}/site-packages"))
86+
}
87+
Self::PyPy => version.map(|version| format!("lib/pypy{version}/site-packages")),
88+
Self::Unknown => None,
89+
}
90+
}
91+
}
92+
6593
/// Abstraction for a Python virtual environment.
6694
///
6795
/// Most of this information is derived from the virtual environment's `pyvenv.cfg` file.
@@ -82,6 +110,7 @@ pub(crate) struct VirtualEnvironment {
82110
/// in an acceptable format under any of the keys we expect.
83111
/// This field will be `None` if so.
84112
version: Option<PythonVersion>,
113+
implementation: PythonImplementation,
85114
}
86115

87116
impl VirtualEnvironment {
@@ -104,6 +133,7 @@ impl VirtualEnvironment {
104133
let mut include_system_site_packages = false;
105134
let mut base_executable_home_path = None;
106135
let mut version_info_string = None;
136+
let mut implementation = PythonImplementation::Unknown;
107137

108138
// A `pyvenv.cfg` file *looks* like a `.ini` file, but actually isn't valid `.ini` syntax!
109139
// The Python standard-library's `site` module parses these files by splitting each line on
@@ -140,6 +170,14 @@ impl VirtualEnvironment {
140170
// `virtualenv` and `uv` call this key `version_info`,
141171
// but the stdlib venv module calls it `version`
142172
"version" | "version_info" => version_info_string = Some(value),
173+
"implementation" => {
174+
implementation = match value.to_ascii_lowercase().as_str() {
175+
"cpython" => PythonImplementation::CPython,
176+
"graalvm" => PythonImplementation::GraalPy,
177+
"pypy" => PythonImplementation::PyPy,
178+
_ => PythonImplementation::Unknown,
179+
};
180+
}
143181
_ => continue,
144182
}
145183
}
@@ -179,6 +217,7 @@ impl VirtualEnvironment {
179217
base_executable_home_path,
180218
include_system_site_packages,
181219
version,
220+
implementation,
182221
};
183222

184223
tracing::trace!("Resolved metadata for virtual environment: {metadata:?}");
@@ -196,11 +235,15 @@ impl VirtualEnvironment {
196235
root_path,
197236
base_executable_home_path,
198237
include_system_site_packages,
238+
implementation,
199239
version,
200240
} = self;
201241

202242
let mut site_packages_directories = vec![site_packages_directory_from_sys_prefix(
203-
root_path, *version, system,
243+
root_path,
244+
*version,
245+
*implementation,
246+
system,
204247
)?];
205248

206249
if *include_system_site_packages {
@@ -211,7 +254,12 @@ impl VirtualEnvironment {
211254
// or if we fail to resolve the `site-packages` from the `sys.prefix` path,
212255
// we should probably print a warning but *not* abort type checking
213256
if let Some(sys_prefix_path) = system_sys_prefix {
214-
match site_packages_directory_from_sys_prefix(&sys_prefix_path, *version, system) {
257+
match site_packages_directory_from_sys_prefix(
258+
&sys_prefix_path,
259+
*version,
260+
*implementation,
261+
system,
262+
) {
215263
Ok(site_packages_directory) => {
216264
site_packages_directories.push(site_packages_directory);
217265
}
@@ -265,7 +313,10 @@ impl SystemEnvironment {
265313
let SystemEnvironment { root_path } = self;
266314

267315
let site_packages_directories = vec![site_packages_directory_from_sys_prefix(
268-
root_path, None, system,
316+
root_path,
317+
None,
318+
PythonImplementation::Unknown,
319+
system,
269320
)?];
270321

271322
tracing::debug!(
@@ -330,6 +381,7 @@ when trying to resolve the `home` value to a directory on disk: {io_err}"
330381
fn site_packages_directory_from_sys_prefix(
331382
sys_prefix_path: &SysPrefixPath,
332383
python_version: Option<PythonVersion>,
384+
implementation: PythonImplementation,
333385
system: &dyn System,
334386
) -> SitePackagesDiscoveryResult<SystemPathBuf> {
335387
tracing::debug!("Searching for site-packages directory in {sys_prefix_path}");
@@ -369,15 +421,21 @@ fn site_packages_directory_from_sys_prefix(
369421

370422
// If we were able to figure out what Python version this installation is,
371423
// we should be able to avoid iterating through all items in the `lib/` directory:
372-
if let Some(version) = python_version {
373-
let expected_path = sys_prefix_path.join(format!("lib/python{version}/site-packages"));
374-
if system.is_directory(&expected_path) {
375-
return Ok(expected_path);
424+
if let Some(expected_relative_path) = implementation.relative_site_packages_path(python_version)
425+
{
426+
let expected_absolute_path = sys_prefix_path.join(expected_relative_path);
427+
if system.is_directory(&expected_absolute_path) {
428+
return Ok(expected_absolute_path);
376429
}
377-
if version.free_threaded_build_available() {
378-
// Nearly the same as `expected_path`, but with an additional `t` after {version}:
379-
let alternative_path =
380-
sys_prefix_path.join(format!("lib/python{version}t/site-packages"));
430+
431+
// CPython free-threaded (3.13+) variant: pythonXYt
432+
if matches!(implementation, PythonImplementation::CPython)
433+
&& python_version.is_some_and(PythonVersion::free_threaded_build_available)
434+
{
435+
let alternative_path = sys_prefix_path.join(format!(
436+
"lib/python{}t/site-packages",
437+
python_version.unwrap()
438+
));
381439
if system.is_directory(&alternative_path) {
382440
return Ok(alternative_path);
383441
}
@@ -412,7 +470,7 @@ fn site_packages_directory_from_sys_prefix(
412470
.file_name()
413471
.expect("File name to be non-null because path is guaranteed to be a child of `lib`");
414472

415-
if !name.starts_with("python3.") {
473+
if !(name.starts_with("python3.") || name.starts_with("pypy3.")) {
416474
continue;
417475
}
418476

@@ -623,10 +681,20 @@ mod tests {
623681

624682
use super::*;
625683

684+
impl PythonEnvironment {
685+
fn expect_venv(self) -> VirtualEnvironment {
686+
match self {
687+
Self::Virtual(venv) => venv,
688+
Self::System(_) => panic!("Expected a virtual environment"),
689+
}
690+
}
691+
}
692+
626693
struct VirtualEnvironmentTestCase {
627694
system_site_packages: bool,
628695
pyvenv_cfg_version_field: Option<&'static str>,
629696
command_field: Option<&'static str>,
697+
implementation_field: Option<&'static str>,
630698
}
631699

632700
struct PythonEnvironmentTestCase {
@@ -679,6 +747,7 @@ mod tests {
679747
pyvenv_cfg_version_field,
680748
system_site_packages,
681749
command_field,
750+
implementation_field,
682751
}) = virtual_env
683752
else {
684753
return system_install_sys_prefix;
@@ -709,6 +778,10 @@ mod tests {
709778
pyvenv_cfg_contents.push_str(command_field);
710779
pyvenv_cfg_contents.push('\n');
711780
}
781+
if let Some(implementation_field) = implementation_field {
782+
pyvenv_cfg_contents.push_str(implementation_field);
783+
pyvenv_cfg_contents.push('\n');
784+
}
712785
// Deliberately using weird casing here to test that our pyvenv.cfg parsing is case-insensitive:
713786
if *system_site_packages {
714787
pyvenv_cfg_contents.push_str("include-system-site-packages = TRuE\n");
@@ -727,28 +800,29 @@ mod tests {
727800
}
728801

729802
#[track_caller]
730-
fn run(self) {
803+
fn run(self) -> PythonEnvironment {
731804
let env_path = self.build();
732805
let env = PythonEnvironment::new(env_path.clone(), self.origin, &self.system)
733806
.expect("Expected environment construction to succeed");
734807

735808
let expect_virtual_env = self.virtual_env.is_some();
736-
match env {
809+
match &env {
737810
PythonEnvironment::Virtual(venv) if expect_virtual_env => {
738-
self.assert_virtual_environment(&venv, &env_path);
811+
self.assert_virtual_environment(venv, &env_path);
739812
}
740813
PythonEnvironment::Virtual(venv) => {
741814
panic!(
742815
"Expected a system environment, but got a virtual environment: {venv:?}"
743816
);
744817
}
745818
PythonEnvironment::System(env) if !expect_virtual_env => {
746-
self.assert_system_environment(&env, &env_path);
819+
self.assert_system_environment(env, &env_path);
747820
}
748821
PythonEnvironment::System(env) => {
749822
panic!("Expected a virtual environment, but got a system environment: {env:?}");
750823
}
751824
}
825+
env
752826
}
753827

754828
fn assert_virtual_environment(
@@ -941,6 +1015,7 @@ mod tests {
9411015
system_site_packages: false,
9421016
pyvenv_cfg_version_field: None,
9431017
command_field: None,
1018+
implementation_field: None,
9441019
}),
9451020
};
9461021
test.run();
@@ -957,6 +1032,7 @@ mod tests {
9571032
system_site_packages: false,
9581033
pyvenv_cfg_version_field: Some("version = 3.12"),
9591034
command_field: None,
1035+
implementation_field: None,
9601036
}),
9611037
};
9621038
test.run();
@@ -973,6 +1049,7 @@ mod tests {
9731049
system_site_packages: false,
9741050
pyvenv_cfg_version_field: Some("version_info = 3.12"),
9751051
command_field: None,
1052+
implementation_field: None,
9761053
}),
9771054
};
9781055
test.run();
@@ -989,6 +1066,7 @@ mod tests {
9891066
system_site_packages: false,
9901067
pyvenv_cfg_version_field: Some("version_info = 3.12.0rc2"),
9911068
command_field: None,
1069+
implementation_field: None,
9921070
}),
9931071
};
9941072
test.run();
@@ -1005,6 +1083,7 @@ mod tests {
10051083
system_site_packages: false,
10061084
pyvenv_cfg_version_field: Some("version_info = 3.13"),
10071085
command_field: None,
1086+
implementation_field: None,
10081087
}),
10091088
};
10101089
test.run();
@@ -1021,11 +1100,84 @@ mod tests {
10211100
system_site_packages: true,
10221101
pyvenv_cfg_version_field: Some("version_info = 3.13"),
10231102
command_field: None,
1103+
implementation_field: None,
10241104
}),
10251105
};
10261106
test.run();
10271107
}
10281108

1109+
#[test]
1110+
fn detects_pypy_implementation() {
1111+
let test = PythonEnvironmentTestCase {
1112+
system: TestSystem::default(),
1113+
minor_version: 13,
1114+
free_threaded: true,
1115+
origin: SysPrefixPathOrigin::VirtualEnvVar,
1116+
virtual_env: Some(VirtualEnvironmentTestCase {
1117+
system_site_packages: true,
1118+
pyvenv_cfg_version_field: None,
1119+
command_field: None,
1120+
implementation_field: Some("implementation = PyPy"),
1121+
}),
1122+
};
1123+
let venv = test.run().expect_venv();
1124+
assert_eq!(venv.implementation, PythonImplementation::PyPy);
1125+
}
1126+
1127+
#[test]
1128+
fn detects_cpython_implementation() {
1129+
let test = PythonEnvironmentTestCase {
1130+
system: TestSystem::default(),
1131+
minor_version: 13,
1132+
free_threaded: true,
1133+
origin: SysPrefixPathOrigin::VirtualEnvVar,
1134+
virtual_env: Some(VirtualEnvironmentTestCase {
1135+
system_site_packages: true,
1136+
pyvenv_cfg_version_field: None,
1137+
command_field: None,
1138+
implementation_field: Some("implementation = CPython"),
1139+
}),
1140+
};
1141+
let venv = test.run().expect_venv();
1142+
assert_eq!(venv.implementation, PythonImplementation::CPython);
1143+
}
1144+
1145+
#[test]
1146+
fn detects_graalpy_implementation() {
1147+
let test = PythonEnvironmentTestCase {
1148+
system: TestSystem::default(),
1149+
minor_version: 13,
1150+
free_threaded: true,
1151+
origin: SysPrefixPathOrigin::VirtualEnvVar,
1152+
virtual_env: Some(VirtualEnvironmentTestCase {
1153+
system_site_packages: true,
1154+
pyvenv_cfg_version_field: None,
1155+
command_field: None,
1156+
implementation_field: Some("implementation = GraalVM"),
1157+
}),
1158+
};
1159+
let venv = test.run().expect_venv();
1160+
assert_eq!(venv.implementation, PythonImplementation::GraalPy);
1161+
}
1162+
1163+
#[test]
1164+
fn detects_unknown_implementation() {
1165+
let test = PythonEnvironmentTestCase {
1166+
system: TestSystem::default(),
1167+
minor_version: 13,
1168+
free_threaded: true,
1169+
origin: SysPrefixPathOrigin::VirtualEnvVar,
1170+
virtual_env: Some(VirtualEnvironmentTestCase {
1171+
system_site_packages: true,
1172+
pyvenv_cfg_version_field: None,
1173+
command_field: None,
1174+
implementation_field: None,
1175+
}),
1176+
};
1177+
let venv = test.run().expect_venv();
1178+
assert_eq!(venv.implementation, PythonImplementation::Unknown);
1179+
}
1180+
10291181
#[test]
10301182
fn reject_env_that_does_not_exist() {
10311183
let system = TestSystem::default();
@@ -1122,6 +1274,7 @@ mod tests {
11221274
command_field: Some(
11231275
r#"command = /.pyenv/versions/3.13.3/bin/python3.13 -m venv --without-pip --prompt="python-default/3.13.3" /somewhere-else/python/virtualenvs/python-default/3.13.3"#,
11241276
),
1277+
implementation_field: None,
11251278
}),
11261279
};
11271280
test.run();

0 commit comments

Comments
 (0)