@@ -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
87116impl 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}"
330381fn 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