From 7c853310b64b448bb24b107ac28a9e81b7433752 Mon Sep 17 00:00:00 2001 From: John Sirois Date: Mon, 1 Jun 2026 09:48:56 -0700 Subject: [PATCH] Fix interpreter discovery for Windows. The [PEP-514 spec](https://peps.python.org/pep-0514/) is now implemented while also searching further on the `PEX_PYTHON_PATH` or `PATH` as appropriate. In addition, new `pexrc python {list,inspect}` tools allow insight into the `pexrc` interpreter discovery mechanism. --- CHANGES.md | 9 + Cargo.lock | 4 +- Cargo.toml | 4 +- crates/boot/src/lib.rs | 25 +- crates/build-system/src/tools.rs | 6 +- crates/cli/src/json.rs | 2 +- crates/cli/src/output.rs | 2 +- crates/interpreter/Cargo.toml | 3 + .../{constraints.rs => constraints/mod.rs} | 548 +++++------------- crates/interpreter/src/constraints/unix.rs | 374 ++++++++++++ crates/interpreter/src/constraints/windows.rs | 267 +++++++++ crates/interpreter/src/lib.rs | 5 + crates/interpreter/src/search_path.rs | 2 +- crates/pex/src/pex.rs | 9 +- crates/pex/src/wheel/file.rs | 11 +- crates/pex/src/wheel/record.rs | 2 +- crates/target/src/lib.rs | 2 +- .../tools/src/commands/repository/extract.rs | 4 +- crates/venv/src/venv_pex.rs | 2 +- pexrc.rs | 9 +- src/commands/inject.rs | 2 +- src/commands/mod.rs | 2 + src/commands/python/inspect.rs | 70 +++ src/commands/python/list.rs | 103 ++++ src/commands/python/mod.rs | 19 + 25 files changed, 1054 insertions(+), 432 deletions(-) rename crates/interpreter/src/{constraints.rs => constraints/mod.rs} (51%) create mode 100644 crates/interpreter/src/constraints/unix.rs create mode 100644 crates/interpreter/src/constraints/windows.rs create mode 100644 src/commands/python/inspect.rs create mode 100644 src/commands/python/list.rs create mode 100644 src/commands/python/mod.rs diff --git a/CHANGES.md b/CHANGES.md index 1801e6e..e204aef 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,14 @@ # Release Notes +## 0.16.0 + +This release fixes interpreter discovery for Windows. The [PEP-514 spec]( +https://peps.python.org/pep-0514/) is now implemented while also searching further on the +`PEX_PYTHON_PATH` or `PATH` as appropriate. + +In addition, the new `pexrc python {list,inspect}` tools allow insight into the `pexrc` interpreter +discovery mechanism. + ## 0.15.0 This release adds defaults for Linux and Windows for the `platform_release` environment marker when diff --git a/Cargo.lock b/Cargo.lock index 9d1fe78..d95680d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1481,6 +1481,7 @@ dependencies = [ "time", "url", "which", + "windows-registry", ] [[package]] @@ -2096,7 +2097,7 @@ dependencies = [ [[package]] name = "pexrc" -version = "0.15.0" +version = "0.16.0" dependencies = [ "anstream", "anyhow", @@ -2128,6 +2129,7 @@ dependencies = [ "rayon", "request", "scripts", + "serde_json", "sha2 0.11.0", "strum", "target", diff --git a/Cargo.toml b/Cargo.toml index 9d15297..95766f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ cargo-features = ["profile-rustflags"] [package] name = "pexrc" -version = "0.15.0" +version = "0.16.0" edition = { workspace = true } publish = false @@ -210,6 +210,7 @@ url = "2.5" # does not have a feature to activate the dep. version-ranges = "*" walkdir = "2.5" +windows-registry = "0.6" which = "8.0" xz2 = "0.1" zip = { version = "8.6", default-features = false, features = ["chrono", "deflate", "zstd"] } @@ -253,6 +254,7 @@ python-proxy = { path = "crates/python-proxy" } rayon = { workspace = true } request = { path = "crates/request" } scripts = { path = "crates/scripts", features = ["embedded"] } +serde_json = { workspace = true } sha2 = { workspace = true } strum = { workspace = true } target = { path = "crates/target" } diff --git a/crates/boot/src/lib.rs b/crates/boot/src/lib.rs index f1e35fb..1603c93 100644 --- a/crates/boot/src/lib.rs +++ b/crates/boot/src/lib.rs @@ -18,6 +18,7 @@ use interpreter::{ SearchPath, SelectionStrategy, VersionSpec, + calculate_compatible_unix_binary_names, }; use itertools::Itertools; use pex::{Pex, PexPath}; @@ -63,16 +64,20 @@ pub fn sh_boot_shebang( .interpreter_selection_strategy .unwrap_or(pex::InterpreterSelectionStrategy::Oldest) .into(); - let pythons = interpreter_constraints - .calculate_compatible_binary_names(selection_strategy, preferred_interpreter) - .into_iter() - .map(|(binary_name, version_spec)| { - binary_name - .into_string() - .map(|binary_name| (binary_name, version_spec)) - .map_err(|err| anyhow!("{err}", err = err.display())) - }) - .collect::>>()?; + let pythons = calculate_compatible_unix_binary_names( + &interpreter_constraints, + selection_strategy, + preferred_interpreter, + true, + ) + .into_iter() + .map(|(binary_name, version_spec)| { + binary_name + .into_string() + .map(|binary_name| (binary_name, version_spec)) + .map_err(|err| anyhow!("{err}", err = err.display())) + }) + .collect::>>()?; let python_args = if hermetic { if pythons.iter().any(|(_, version_spec)| { matches!(version_spec, None | Some(VersionSpec::Major(_))) diff --git a/crates/build-system/src/tools.rs b/crates/build-system/src/tools.rs index de6cf52..28767da 100644 --- a/crates/build-system/src/tools.rs +++ b/crates/build-system/src/tools.rs @@ -21,7 +21,7 @@ use crate::downloads::ensure_download; use crate::metadata::{Build, CargoBinstall, Download, Embeds, Glibc}; pub(crate) struct ToolBox<'a> { - emeds: Embeds<'a>, + embeds: Embeds<'a>, binstall: CargoBinstall<'a>, zig_version: &'a str, glibc: Glibc<'a>, @@ -40,7 +40,7 @@ impl<'a> From> for ToolBox<'a> { let downloads: Vec<(&'static str, Download<'a>)> = Vec::new(); Self { - emeds: build.embeds, + embeds: build.embeds, binstall: build.cargo_binstall, zig_version: build.zig_version, glibc: build.glibc, @@ -77,7 +77,7 @@ impl<'a> ToolBox<'a> { } } Ok(ToolInventory { - embeds: self.emeds, + embeds: self.embeds, binstall: self.binstall, downloads: self.downloads, zig, diff --git a/crates/cli/src/json.rs b/crates/cli/src/json.rs index 0c387e6..c42725d 100644 --- a/crates/cli/src/json.rs +++ b/crates/cli/src/json.rs @@ -10,7 +10,7 @@ use serde_json::ser::PrettyFormatter; #[derive(Args)] pub struct Json { /// Pretty-print json output with the given indent. - #[arg(short = 'i', long)] + #[arg(short = 'i', long, help_heading = "Output")] indent: Option, } diff --git a/crates/cli/src/output.rs b/crates/cli/src/output.rs index 91fa824..4603106 100644 --- a/crates/cli/src/output.rs +++ b/crates/cli/src/output.rs @@ -12,7 +12,7 @@ use fs_err::File; #[derive(Args)] pub struct Output { /// A file to send output to; STDOUT by default. - #[arg(short = 'o', long)] + #[arg(short = 'o', long, help_heading = "Output")] output: Option, } diff --git a/crates/interpreter/Cargo.toml b/crates/interpreter/Cargo.toml index d9837c2..a10a5e1 100644 --- a/crates/interpreter/Cargo.toml +++ b/crates/interpreter/Cargo.toml @@ -28,6 +28,9 @@ time = { workspace = true } url = { workspace = true } which = { workspace = true } +[target.'cfg(windows)'.dependencies] +windows-registry = { workspace = true } + [dev-dependencies] anyhow = { workspace = true } pep440_rs = { workspace = true } diff --git a/crates/interpreter/src/constraints.rs b/crates/interpreter/src/constraints/mod.rs similarity index 51% rename from crates/interpreter/src/constraints.rs rename to crates/interpreter/src/constraints/mod.rs index cde1c23..f91e568 100644 --- a/crates/interpreter/src/constraints.rs +++ b/crates/interpreter/src/constraints/mod.rs @@ -1,8 +1,9 @@ // Copyright 2026 Pex project contributors. // SPDX-License-Identifier: Apache-2.0 -use std::collections::HashSet; -use std::ffi::OsString; +pub(crate) mod unix; +mod windows; + use std::fmt::{Display, Formatter}; use std::ops::Deref; use std::path::PathBuf; @@ -10,17 +11,19 @@ use std::str::FromStr; use std::sync::LazyLock; use anyhow::bail; -use indexmap::{IndexMap, IndexSet}; +use indexmap::IndexSet; use log::debug; use pep440_rs::{Operator, Version, VersionSpecifier, VersionSpecifiers}; use pep508_rs::{ExtraName, MarkerTree, PackageName, Requirement, VersionOrUrl}; use url::Url; -use which::sys::{RealSys, Sys}; -use which::which_in_global; +#[cfg(unix)] +use crate::constraints::unix::iter_possibly_compatible_python_exes; +#[cfg(windows)] +use crate::constraints::windows::iter_possibly_compatible_python_exes; use crate::{Interpreter, SearchPath}; -#[derive(Debug, Hash, Eq, PartialEq)] +#[derive(Copy, Clone, Debug, Hash, Eq, PartialEq)] enum InterpreterImplementation { CPython, CPythonFreeThreaded, @@ -81,15 +84,15 @@ impl InterpreterImplementation { bail!( "Invalid interpreter implementation in: {source}\n\ Only the following are recognized:\n\ - + PyPy: any PyPy interpreter\n\ + CPython: any CPython interpreter\n\ + CPython+t or CPython[free-threaded]: a free-threaded CPython interpreter\n\ - + CPython-t or CPython[gil]: a traditional GIL-enabled CPython interpreter", + + CPython-t or CPython[gil]: a traditional GIL-enabled CPython interpreter\n\ + + PyPy: any PyPy interpreter", ) } } -#[derive(Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] pub struct InterpreterConstraint { implementation: Option, version_specifiers: Option, @@ -113,7 +116,43 @@ impl InterpreterConstraint { } } - fn parse(constraint: &str) -> anyhow::Result { + pub fn parse(constraint: &str) -> anyhow::Result { + constraint.parse() + } + + fn contains(&self, interpreter: &Interpreter) -> bool { + if let Some(implementation) = self.implementation.as_ref() { + if let Some(other_implementation) = InterpreterImplementation::of(interpreter) { + if !implementation.matches(&other_implementation) { + return false; + } + } else { + return false; + } + } + self.contains_version( + interpreter.details.version.major, + interpreter.details.version.minor, + ) + } + + fn contains_version(&self, major: u8, minor: u8) -> bool { + if let Some(version_specifiers) = self.version_specifiers.as_ref() { + let version = Version::new([u64::from(major), u64::from(minor)]); + return version_specifiers.contains(&version); + } + true + } + + pub fn version_specifiers(&self) -> Option<&VersionSpecifiers> { + self.version_specifiers.as_ref() + } +} + +impl FromStr for InterpreterConstraint { + type Err = anyhow::Error; + + fn from_str(constraint: &str) -> Result { if let Ok(version_specifiers) = VersionSpecifiers::from_str(constraint) { return Ok(Self { implementation: None, @@ -167,34 +206,6 @@ impl InterpreterConstraint { }) } } - - fn contains(&self, interpreter: &Interpreter) -> bool { - if let Some(implementation) = self.implementation.as_ref() { - if let Some(other_implementation) = InterpreterImplementation::of(interpreter) { - if !implementation.matches(&other_implementation) { - return false; - } - } else { - return false; - } - } - self.contains_version( - interpreter.details.version.major, - interpreter.details.version.minor, - ) - } - - fn contains_version(&self, major: u8, minor: u8) -> bool { - if let Some(version_specifiers) = self.version_specifiers.as_ref() { - let version = Version::new([u64::from(major), u64::from(minor)]); - return version_specifiers.contains(&version); - } - true - } - - pub fn version_specifiers(&self) -> Option<&VersionSpecifiers> { - self.version_specifiers.as_ref() - } } impl Display for InterpreterConstraint { @@ -238,35 +249,39 @@ pub enum SelectionStrategy { Newest, } -#[derive(Hash, Eq, PartialEq)] -struct PythonBinarySpec { - name: &'static str, - major: u8, - minor: u8, - suffix: Option<&'static str>, -} - +#[derive(Debug)] pub enum VersionSpec { MajorMinor(u8, u8), Major(u8), } +#[derive(Debug)] pub struct InterpreterConstraints(Vec); impl InterpreterConstraints { + pub const EMPTY: Self = Self(vec![]); + pub fn try_from>(constraints: &[S]) -> anyhow::Result { Ok(Self( constraints .iter() - .map(|constraint| InterpreterConstraint::parse(constraint.as_ref())) + .map(|constraint| constraint.as_ref().parse()) .collect::>>()?, )) } + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + pub fn into_constraints(self) -> Vec { self.0 } + pub fn as_slice(&self) -> &[InterpreterConstraint] { + self.0.as_slice() + } + pub fn contains(&self, interpreter: &Interpreter) -> bool { self.0.is_empty() || self @@ -275,110 +290,88 @@ impl InterpreterConstraints { .any(|constraint| constraint.contains(interpreter)) } - fn calculate_compatible_binary_specs( - &self, - selection_strategy: SelectionStrategy, - preferred_interpreter: Option<&Interpreter>, - ) -> IndexSet { - let mut binary_specs: IndexSet = IndexSet::new(); - if let Some(interpreter) = preferred_interpreter - && self.contains(interpreter) - { - let implementation = InterpreterImplementation::of(interpreter); - insert_specs( - &mut binary_specs, - implementation.as_ref(), - interpreter.details.version.major, - interpreter.details.version.minor, - ); - } - if !self.0.is_empty() { - let versions = match selection_strategy { - SelectionStrategy::Oldest => &SUPPORTED_VERSIONS, - SelectionStrategy::Newest => &SUPPORTED_VERSIONS_NEWEST_FIRST, - }; - for (major, minor) in versions.iter() { - for constraint in &self.0 { - if constraint.contains_version(*major, *minor) { - insert_specs( - &mut binary_specs, - constraint.implementation.as_ref(), - *major, - *minor, - ); - } - } - } - } - for (major, minor) in SUPPORTED_VERSIONS_NEWEST_FIRST.iter() { - insert_specs(&mut binary_specs, None, *major, *minor); - } - binary_specs - } - - pub fn calculate_compatible_binary_names( - &self, - selection_strategy: SelectionStrategy, - preferred_interpreter: Option<&Interpreter>, - ) -> IndexMap> { - let binary_specs = - self.calculate_compatible_binary_specs(selection_strategy, preferred_interpreter); - let mut binary_names: IndexMap> = IndexMap::new(); - for binary_spec in &binary_specs { - binary_names.insert( - format!( - "{name}{major}.{minor}{suffix}", - name = binary_spec.name, - major = binary_spec.major, - minor = binary_spec.minor, - suffix = binary_spec.suffix.unwrap_or("") - ) - .into(), - Some(VersionSpec::MajorMinor( - binary_spec.major, - binary_spec.minor, - )), - ); - } - for binary_spec in &binary_specs { - binary_names.insert( - format!( - "{name}{major}", - name = binary_spec.name, - major = binary_spec.major - ) - .into(), - Some(VersionSpec::Major(binary_spec.major)), - ); - } - for binary_spec in &binary_specs { - binary_names.insert(binary_spec.name.into(), None); - } - binary_names + pub fn contains_version(&self, major: u8, minor: u8) -> bool { + self.0.is_empty() + || self + .0 + .iter() + .any(|constraint| constraint.contains_version(major, minor)) } pub fn iter_possibly_compatible_python_exes( &self, selection_strategy: SelectionStrategy, search_path: SearchPath, + include_pex_compatible: bool, ) -> anyhow::Result> { - let (python, path, known_paths) = search_path.into_parts()?; - let binary_names = if let Some(python) = python { - vec![python] + iter_possibly_compatible_python_exes( + self, + selection_strategy, + search_path, + include_pex_compatible, + ) + } +} + +impl From> for InterpreterConstraints { + fn from(value: Vec) -> Self { + Self(value) + } +} + +#[derive(Hash, Eq, PartialEq)] +struct PythonBinarySpec { + name: &'static str, + major: u8, + minor: u8, + suffix: Option<&'static str>, +} + +fn calculate_compatible_binary_specs( + constraints: &InterpreterConstraints, + selection_strategy: SelectionStrategy, + preferred_interpreter: Option<&Interpreter>, + include_pex_compatible: bool, +) -> IndexSet { + let mut binary_specs: IndexSet = IndexSet::new(); + if let Some(interpreter) = preferred_interpreter + && constraints.contains(interpreter) + { + let implementation = InterpreterImplementation::of(interpreter); + insert_specs( + &mut binary_specs, + implementation.as_ref(), + interpreter.details.version.major, + interpreter.details.version.minor, + ); + } + let constraints = constraints.as_slice(); + let versions = match selection_strategy { + SelectionStrategy::Oldest => &SUPPORTED_VERSIONS, + SelectionStrategy::Newest => &SUPPORTED_VERSIONS_NEWEST_FIRST, + }; + for (major, minor) in versions.iter() { + if constraints.is_empty() && !include_pex_compatible { + insert_specs(&mut binary_specs, None, *major, *minor); } else { - self.calculate_compatible_binary_names(selection_strategy, None) - .into_keys() - .collect() - }; - Ok(PythonExeIter { - known_paths, - path, - binary_names: binary_names.into_iter(), - which_fn: which_in_global, - binary_paths: None, - seen: HashSet::new(), - }) + for constraint in constraints { + if constraint.contains_version(*major, *minor) { + insert_specs( + &mut binary_specs, + constraint.implementation.as_ref(), + *major, + *minor, + ); + } + } + } } + if include_pex_compatible { + for (major, minor) in SUPPORTED_VERSIONS_NEWEST_FIRST.iter() { + insert_specs(&mut binary_specs, None, *major, *minor); + } + } + binary_specs } fn insert_specs( @@ -446,96 +439,13 @@ fn insert_specs( } } -struct PythonExeIter< - KnownBinaryPaths: Iterator, - Name, - BinaryNames: Iterator, - BinaryPaths: Iterator, - WhichError, - WhichFunction: Fn(Name, Option) -> Result, -> { - known_paths: Option, - path: Option, - binary_names: BinaryNames, - which_fn: WhichFunction, - binary_paths: Option, - seen: HashSet, -} - -impl< - KnownBinaryPaths: Iterator, - BinaryNames: Iterator, - BinaryPaths: Iterator, - WhichError, - WhichFunction: Fn(OsString, Option) -> Result, -> Iterator - for PythonExeIter< - KnownBinaryPaths, - OsString, - BinaryNames, - BinaryPaths, - WhichError, - WhichFunction, - > -{ - type Item = PathBuf; - - fn next(&mut self) -> Option { - loop { - if let Some(known_paths) = self.known_paths.as_mut() { - if let Some(binary_path) = known_paths.next() { - if let Ok(real_binary_path) = binary_path.canonicalize() { - if self.seen.contains(real_binary_path.as_path()) { - continue; - } - self.seen.insert(real_binary_path.clone()); - return Some(real_binary_path); - } else { - // E.G: A broken symbolic link. - continue; - } - } else { - self.known_paths = None; - } - } else if let Some(binary_paths) = self.binary_paths.as_mut() { - if let Some(binary_path) = binary_paths.next() { - if let Ok(real_binary_path) = binary_path.canonicalize() { - if self.seen.contains(real_binary_path.as_path()) { - continue; - } - self.seen.insert(real_binary_path.clone()); - return Some(real_binary_path); - } else { - // E.G: A broken symbolic link. - continue; - } - } else { - self.binary_paths = None; - } - } else if let Some(binary_name) = self.binary_names.next() - && let Ok(binary_paths) = (self.which_fn)( - binary_name, - self.path.clone().or_else(|| RealSys.env_path()), - ) - { - self.binary_paths = Some(binary_paths); - } else { - return None; - } - } - } -} - #[cfg(test)] mod tests { - use std::ffi::OsStr; use std::str::FromStr; - use indexmap::IndexSet; use pep440_rs::VersionSpecifiers; use crate::constraints::{InterpreterConstraint, InterpreterImplementation}; - use crate::{InterpreterConstraints, SelectionStrategy}; #[test] fn test_parse_interpreter_constraint() { @@ -544,233 +454,63 @@ mod tests { implementation: None, version_specifiers: Some(VersionSpecifiers::from_str(">=3.14").unwrap()) }, - InterpreterConstraint::parse(">=3.14").unwrap() + ">=3.14".parse().unwrap() ); assert_eq!( InterpreterConstraint { implementation: Some(InterpreterImplementation::CPython), version_specifiers: None }, - InterpreterConstraint::parse("CPython").unwrap() + "CPython".parse().unwrap() ); assert_eq!( InterpreterConstraint { implementation: Some(InterpreterImplementation::CPythonFreeThreaded), version_specifiers: None }, - InterpreterConstraint::parse("CPython+t").unwrap() + "CPython+t".parse().unwrap() ); assert_eq!( InterpreterConstraint { implementation: Some(InterpreterImplementation::CPythonFreeThreaded), version_specifiers: Some(VersionSpecifiers::from_str("==3.15.*").unwrap()) }, - InterpreterConstraint::parse("CPython+t==3.15.*").unwrap() + "CPython+t==3.15.*".parse().unwrap() ); assert_eq!( InterpreterConstraint { implementation: Some(InterpreterImplementation::CPythonGil), version_specifiers: None }, - InterpreterConstraint::parse("CPython-t").unwrap() + "CPython-t".parse().unwrap() ); assert_eq!( InterpreterConstraint { implementation: Some(InterpreterImplementation::CPythonGil), version_specifiers: Some(VersionSpecifiers::from_str("==3.13.*").unwrap()) }, - InterpreterConstraint::parse("CPython-t==3.13.*").unwrap() + "CPython-t==3.13.*".parse().unwrap() ); assert_eq!( InterpreterConstraint { implementation: Some(InterpreterImplementation::CPythonFreeThreaded), version_specifiers: None }, - InterpreterConstraint::parse("CPython[free-threaded]").unwrap() + "CPython[free-threaded]".parse().unwrap() ); assert_eq!( InterpreterConstraint { implementation: Some(InterpreterImplementation::CPythonGil), version_specifiers: None }, - InterpreterConstraint::parse("CPython[gil]").unwrap() + "CPython[gil]".parse().unwrap() ); assert_eq!( InterpreterConstraint { implementation: Some(InterpreterImplementation::PyPy), version_specifiers: None }, - InterpreterConstraint::parse("PyPy").unwrap() - ); - } - - fn os_str(value: &str) -> &OsStr { - // SAFETY: Tests use ascii. - unsafe { OsStr::from_encoded_bytes_unchecked(value.as_bytes()) } - } - - // N.B.: As time advances, the supported set by PEXrc will steadily add python3.16, etc to - // the top of this list per https://peps.python.org/pep-0602/ (c.f. code above); so we just - // check a known run as of this test writing. - const EXPECTED_PER_PEXRC_MAJOR_MINOR: &[&str] = &[ - "python3.15", - "python3.15t", - "pypy3.15", - "python3.14", - "python3.14t", - "pypy3.14", - "python3.13", - "python3.13t", - "pypy3.13", - "python3.12", - "pypy3.12", - "python3.11", - "pypy3.11", - "python3.10", - "pypy3.10", - "python3.9", - "pypy3.9", - "python3.8", - "pypy3.8", - "python3.7", - "pypy3.7", - "python3.6", - "pypy3.6", - "python3.5", - "pypy3.5", - "python2.7", - "pypy2.7", - ]; - - const EXPECTED_PER_PEXRC_REST: &[&str] = - &["python3", "pypy3", "python2", "pypy2", "python", "pypy"]; - - #[test] - fn test_interpreter_constraints_binary_names_all_default_order() { - let ics = InterpreterConstraints::try_from::<&str>(&[]).unwrap(); - let binary_names = ics - .calculate_compatible_binary_names(SelectionStrategy::Oldest, None) - .into_keys() - .collect::>(); - - let major_minor_start = binary_names.len() - - EXPECTED_PER_PEXRC_REST.len() - - EXPECTED_PER_PEXRC_MAJOR_MINOR.len(); - let rest_start = binary_names.len() - EXPECTED_PER_PEXRC_REST.len(); - - assert_eq!( - EXPECTED_PER_PEXRC_MAJOR_MINOR, - &binary_names[major_minor_start..rest_start] - ); - assert_eq!(EXPECTED_PER_PEXRC_REST, &binary_names[rest_start..]); - } - - #[test] - fn test_interpreter_constraints_binary_names_all_newest_first() { - let ics = InterpreterConstraints::try_from::<&str>(&[]).unwrap(); - let binary_names = ics - .calculate_compatible_binary_names(SelectionStrategy::Newest, None) - .into_keys() - .collect::>(); - - assert!( - binary_names.get_index_of(os_str("python3.15")) - < binary_names.get_index_of(os_str("pypy3.15")) - ); - assert!( - binary_names.get_index_of(os_str("pypy3.15")) - < binary_names.get_index_of(os_str("python3.14")) - ); - assert!( - binary_names.get_index_of(os_str("python3.14")) - < binary_names.get_index_of(os_str("pypy3.14")) - ); - assert!( - binary_names.get_index_of(os_str("pypy3.14")) - < binary_names.get_index_of(os_str("python2.7")) - ); - assert_eq!( - &[ - "python2.7", - "pypy2.7", - "python3", - "pypy3", - "python2", - "pypy2", - "python", - "pypy" - ], - &binary_names[binary_names.len() - 8..] - ); - } - - #[test] - fn test_interpreter_constraints_complex() { - let ics = InterpreterConstraints::try_from::<&str>(&[ - "CPython+t==3.15.*", - "CPython[free-threaded]==3.14.*", - "CPython-t==3.13.*", - "CPython[gil]==3.12.*", - "PyPy>=3.9,<3.12", - ]) - .unwrap(); - - let binary_names = ics - .calculate_compatible_binary_names(SelectionStrategy::Newest, None) - .into_keys() - .collect::>(); - - let expected_per_ics = &[ - "python3.15t", - "python3.14t", - "python3.13", - "python3.12", - "pypy3.11", - "pypy3.10", - "pypy3.9", - ]; - - let expexcted_per_pexrc_major_minor = EXPECTED_PER_PEXRC_MAJOR_MINOR - .into_iter() - .filter(|name| !expected_per_ics.contains(name)) - .copied() - .collect::>(); - - let major_minor_start = binary_names.len() - - EXPECTED_PER_PEXRC_REST.len() - - expexcted_per_pexrc_major_minor.len(); - let rest_start = binary_names.len() - EXPECTED_PER_PEXRC_REST.len(); - - assert_eq!(expected_per_ics, &binary_names[..expected_per_ics.len()]); - assert_eq!( - expexcted_per_pexrc_major_minor, - &binary_names[major_minor_start..rest_start] - ); - assert_eq!(EXPECTED_PER_PEXRC_REST, &binary_names[rest_start..]); - - let binary_names = ics - .calculate_compatible_binary_names(SelectionStrategy::Oldest, None) - .into_keys() - .collect::>(); - - let expected_per_ics = &[ - "pypy3.9", - "pypy3.10", - "pypy3.11", - "python3.12", - "python3.13", - "python3.14t", - "python3.15t", - ]; - let expected_shared = &["pypy3", "python3", "python2", "pypy2", "pypy", "python"]; - - let rest_start = binary_names.len() - expected_shared.len(); - - assert_eq!(expected_per_ics, &binary_names[..expected_per_ics.len()]); - assert_eq!( - expexcted_per_pexrc_major_minor, - &binary_names[major_minor_start..rest_start] + "PyPy".parse().unwrap() ); - assert_eq!(expected_shared, &binary_names[rest_start..]); } } diff --git a/crates/interpreter/src/constraints/unix.rs b/crates/interpreter/src/constraints/unix.rs new file mode 100644 index 0000000..9de551d --- /dev/null +++ b/crates/interpreter/src/constraints/unix.rs @@ -0,0 +1,374 @@ +// Copyright 2026 Pex project contributors. +// SPDX-License-Identifier: Apache-2.0 + +use std::ffi::OsString; + +use indexmap::IndexMap; + +use crate::constraints::calculate_compatible_binary_specs; +use crate::{Interpreter, InterpreterConstraints, SelectionStrategy, VersionSpec}; + +#[cfg(unix)] +pub(crate) fn iter_possibly_compatible_python_exes( + constraints: &InterpreterConstraints, + selection_strategy: SelectionStrategy, + search_path: crate::SearchPath, + include_pex_compatible: bool, +) -> anyhow::Result> { + _unix::iter_possibly_compatible_python_exes( + constraints, + selection_strategy, + search_path, + include_pex_compatible, + ) +} + +// N.B.: We need to be able to caclculate unix Python executable names from windows when +// cross-building / injecting a PEX that will target unix with a --sh-boot header. +pub fn calculate_compatible_binary_names( + constraints: &InterpreterConstraints, + selection_strategy: SelectionStrategy, + preferred_interpreter: Option<&Interpreter>, + include_pex_compatible: bool, +) -> IndexMap> { + let binary_specs = calculate_compatible_binary_specs( + constraints, + selection_strategy, + preferred_interpreter, + include_pex_compatible, + ); + let mut binary_names: IndexMap> = IndexMap::new(); + for binary_spec in &binary_specs { + binary_names.insert( + format!( + "{name}{major}.{minor}{suffix}", + name = binary_spec.name, + major = binary_spec.major, + minor = binary_spec.minor, + suffix = binary_spec.suffix.unwrap_or("") + ) + .into(), + Some(VersionSpec::MajorMinor( + binary_spec.major, + binary_spec.minor, + )), + ); + } + for binary_spec in &binary_specs { + binary_names.insert( + format!( + "{name}{major}", + name = binary_spec.name, + major = binary_spec.major + ) + .into(), + Some(VersionSpec::Major(binary_spec.major)), + ); + } + for binary_spec in &binary_specs { + binary_names.insert(binary_spec.name.into(), None); + } + binary_names +} + +#[cfg(unix)] +mod _unix { + use std::collections::HashSet; + use std::ffi::OsString; + use std::path::PathBuf; + + use which::sys::{RealSys, Sys}; + use which::which_in_global; + + use super::calculate_compatible_binary_names; + use crate::{InterpreterConstraints, SearchPath, SelectionStrategy}; + + pub(super) fn iter_possibly_compatible_python_exes( + constraints: &InterpreterConstraints, + selection_strategy: SelectionStrategy, + search_path: SearchPath, + include_pex_compatible: bool, + ) -> anyhow::Result> { + let (python, search_path, known_python_exes) = search_path.into_parts()?; + let binary_names = if let Some(python) = python { + vec![python] + } else { + calculate_compatible_binary_names( + constraints, + selection_strategy, + None, + include_pex_compatible, + ) + .into_keys() + .collect() + }; + Ok(PythonExeIter { + known_python_exes, + search_path, + binary_names: binary_names.into_iter(), + which_fn: which_in_global, + binary_paths: None, + seen: HashSet::new(), + }) + } + + struct PythonExeIter< + KnownPythonExes: Iterator, + Name, + BinaryNames: Iterator, + BinaryPaths: Iterator, + WhichError, + WhichFunction: Fn(Name, Option) -> Result, + > { + known_python_exes: Option, + search_path: Option, + binary_names: BinaryNames, + which_fn: WhichFunction, + binary_paths: Option, + seen: HashSet, + } + + impl< + KnownBinaryPaths: Iterator, + BinaryNames: Iterator, + BinaryPaths: Iterator, + WhichError, + WhichFunction: Fn(OsString, Option) -> Result, + > Iterator + for PythonExeIter< + KnownBinaryPaths, + OsString, + BinaryNames, + BinaryPaths, + WhichError, + WhichFunction, + > + { + type Item = PathBuf; + + fn next(&mut self) -> Option { + loop { + if let Some(known_paths) = self.known_python_exes.as_mut() { + if let Some(binary_path) = known_paths.next() { + if let Ok(real_binary_path) = binary_path.canonicalize() { + if self.seen.contains(real_binary_path.as_path()) { + continue; + } + self.seen.insert(real_binary_path.clone()); + return Some(real_binary_path); + } else { + // E.G: A broken symbolic link. + continue; + } + } else { + self.known_python_exes = None; + } + } else if let Some(binary_paths) = self.binary_paths.as_mut() { + if let Some(binary_path) = binary_paths.next() { + if let Ok(real_binary_path) = binary_path.canonicalize() { + if self.seen.contains(real_binary_path.as_path()) { + continue; + } + self.seen.insert(real_binary_path.clone()); + return Some(real_binary_path); + } else { + // E.G: A broken symbolic link. + continue; + } + } else { + self.binary_paths = None; + } + } else if let Some(binary_name) = self.binary_names.next() + && let Ok(binary_paths) = (self.which_fn)( + binary_name, + self.search_path.clone().or_else(|| RealSys.env_path()), + ) + { + self.binary_paths = Some(binary_paths); + } else { + return None; + } + } + } + } +} + +#[cfg(test)] +mod tests { + use std::ffi::OsStr; + + use indexmap::IndexSet; + + use crate::constraints::unix::calculate_compatible_binary_names; + use crate::{InterpreterConstraints, SelectionStrategy}; + + fn os_str(value: &str) -> &OsStr { + // SAFETY: Tests use ascii. + unsafe { OsStr::from_encoded_bytes_unchecked(value.as_bytes()) } + } + + // N.B.: As time advances, the supported set by PEXrc will steadily add python3.16, etc to + // the top of this list per https://peps.python.org/pep-0602/ (c.f. code above); so we just + // check a known run as of this test writing. + const EXPECTED_PER_PEXRC_MAJOR_MINOR: &[&str] = &[ + "python3.15", + "python3.15t", + "pypy3.15", + "python3.14", + "python3.14t", + "pypy3.14", + "python3.13", + "python3.13t", + "pypy3.13", + "python3.12", + "pypy3.12", + "python3.11", + "pypy3.11", + "python3.10", + "pypy3.10", + "python3.9", + "pypy3.9", + "python3.8", + "pypy3.8", + "python3.7", + "pypy3.7", + "python3.6", + "pypy3.6", + "python3.5", + "pypy3.5", + "python2.7", + "pypy2.7", + ]; + + const EXPECTED_PER_PEXRC_REST: &[&str] = + &["python3", "pypy3", "python2", "pypy2", "python", "pypy"]; + + #[test] + fn test_interpreter_constraints_binary_names_all_default_order() { + let ics = InterpreterConstraints::try_from::<&str>(&[]).unwrap(); + let binary_names = + calculate_compatible_binary_names(&ics, SelectionStrategy::Oldest, None, true) + .into_keys() + .collect::>(); + + let major_minor_start = binary_names.len() + - EXPECTED_PER_PEXRC_REST.len() + - EXPECTED_PER_PEXRC_MAJOR_MINOR.len(); + let rest_start = binary_names.len() - EXPECTED_PER_PEXRC_REST.len(); + + assert_eq!( + EXPECTED_PER_PEXRC_MAJOR_MINOR, + &binary_names[major_minor_start..rest_start] + ); + assert_eq!(EXPECTED_PER_PEXRC_REST, &binary_names[rest_start..]); + } + + #[test] + fn test_interpreter_constraints_binary_names_all_newest_first() { + let ics = InterpreterConstraints::try_from::<&str>(&[]).unwrap(); + let binary_names = + calculate_compatible_binary_names(&ics, SelectionStrategy::Newest, None, true) + .into_keys() + .collect::>(); + + assert!( + binary_names.get_index_of(os_str("python3.15")) + < binary_names.get_index_of(os_str("pypy3.15")) + ); + assert!( + binary_names.get_index_of(os_str("pypy3.15")) + < binary_names.get_index_of(os_str("python3.14")) + ); + assert!( + binary_names.get_index_of(os_str("python3.14")) + < binary_names.get_index_of(os_str("pypy3.14")) + ); + assert!( + binary_names.get_index_of(os_str("pypy3.14")) + < binary_names.get_index_of(os_str("python2.7")) + ); + assert_eq!( + &[ + "python2.7", + "pypy2.7", + "python3", + "pypy3", + "python2", + "pypy2", + "python", + "pypy" + ], + &binary_names[binary_names.len() - 8..] + ); + } + + #[test] + fn test_interpreter_constraints_complex() { + let ics = InterpreterConstraints::try_from::<&str>(&[ + "CPython+t==3.15.*", + "CPython[free-threaded]==3.14.*", + "CPython-t==3.13.*", + "CPython[gil]==3.12.*", + "PyPy>=3.9,<3.12", + ]) + .unwrap(); + + let binary_names = + calculate_compatible_binary_names(&ics, SelectionStrategy::Newest, None, true) + .into_keys() + .collect::>(); + + let expected_per_ics = &[ + "python3.15t", + "python3.14t", + "python3.13", + "python3.12", + "pypy3.11", + "pypy3.10", + "pypy3.9", + ]; + + let expected_per_pexrc_major_minor = EXPECTED_PER_PEXRC_MAJOR_MINOR + .into_iter() + .filter(|name| !expected_per_ics.contains(name)) + .copied() + .collect::>(); + + let major_minor_start = binary_names.len() + - EXPECTED_PER_PEXRC_REST.len() + - expected_per_pexrc_major_minor.len(); + let rest_start = binary_names.len() - EXPECTED_PER_PEXRC_REST.len(); + + assert_eq!(expected_per_ics, &binary_names[..expected_per_ics.len()]); + assert_eq!( + expected_per_pexrc_major_minor, + &binary_names[major_minor_start..rest_start] + ); + assert_eq!(EXPECTED_PER_PEXRC_REST, &binary_names[rest_start..]); + + let binary_names = + calculate_compatible_binary_names(&ics, SelectionStrategy::Oldest, None, true) + .into_keys() + .collect::>(); + + let expected_per_ics = &[ + "pypy3.9", + "pypy3.10", + "pypy3.11", + "python3.12", + "python3.13", + "python3.14t", + "python3.15t", + ]; + let expected_shared = &["pypy3", "python3", "python2", "pypy2", "pypy", "python"]; + + let rest_start = binary_names.len() - expected_shared.len(); + + assert_eq!(expected_per_ics, &binary_names[..expected_per_ics.len()]); + assert_eq!( + expected_per_pexrc_major_minor, + &binary_names[major_minor_start..rest_start] + ); + assert_eq!(expected_shared, &binary_names[rest_start..]); + } +} diff --git a/crates/interpreter/src/constraints/windows.rs b/crates/interpreter/src/constraints/windows.rs new file mode 100644 index 0000000..c5a69f7 --- /dev/null +++ b/crates/interpreter/src/constraints/windows.rs @@ -0,0 +1,267 @@ +// Copyright 2026 Pex project contributors. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(windows)] + +use std::collections::{HashMap, HashSet}; +use std::env; +use std::ffi::OsString; +use std::fs::FileType; +use std::ops::Coroutine; +use std::path::PathBuf; +use std::pin::Pin; + +use indexmap::IndexSet; +use which::sys::{RealSys, Sys}; +use windows_registry::{CURRENT_USER, LOCAL_MACHINE}; + +use crate::constraints::{PythonBinarySpec, calculate_compatible_binary_specs}; +use crate::{InterpreterConstraints, SearchPath, SelectionStrategy}; + +fn iter_python_exes( + constraints: &InterpreterConstraints, + python_binary_specs: IndexSet, + pex_python: Option, + search_path: Option, + known_python_exes: Option>, +) -> Pin>> { + let mut explicit_pythons = known_python_exes + .map(|exes| exes.collect::>()) + .unwrap_or_default(); + + // C.F.: https://peps.python.org/pep-0514/ + let pex_python_path = + search_path.map(|search_path| env::split_paths(&search_path).collect::>()); + let mut versioned_pythons: HashMap<(u8, u8), IndexSet> = HashMap::new(); + for root_key in CURRENT_USER + .open(r"Software\Python") + .into_iter() + .chain(LOCAL_MACHINE.open(r"Software\Python").into_iter()) + { + if let Ok(companies) = root_key.keys() { + for company in companies { + if &company == "PyLauncher" { + continue; + } + if let Ok(company_key) = root_key.open(&company) + && let Ok(tags) = company_key.keys() + { + for tag in tags { + if let Ok(tag_key) = company_key.open(&tag) { + let version = if let Ok(version) = tag_key.get_string("SysVersion") { + let mut components = version.split("."); + if let Some(component) = components.next() + && let Ok(major) = component.parse::() + && let Some(component) = components.next() + && let Ok(minor) = component.parse::() + { + if !constraints.contains_version(major, minor) { + continue; + } else { + Some((major, minor)) + } + } else { + None + } + } else { + None + }; + + if let Some(python) = + if let Ok(install_path_key) = tag_key.open("InstallPath") { + if let Ok(python_exe) = + install_path_key.get_string("ExecutablePath") + { + Some(PathBuf::from(python_exe)) + // Older Pythons (I found this to be true of CPython 2.7) have + // no ExecutablePath and just a sys.prefix default key. + } else if let Ok(sys_prefix) = install_path_key.get_string( + // N.B.: This is for real even though the spec says + // `(Default)` and `regedit.exe` displays that as well. + "", + ) { + Some(PathBuf::from(sys_prefix).join("python.exe")) + } else { + None + } + } else { + None + } + { + if explicit_pythons.contains(&python) { + continue; + } + if versioned_pythons + .values() + .any(|pythons| pythons.contains(&python)) + { + continue; + } + if pex_python_path + .as_ref() + .map(|ppp| !ppp.iter().any(|entry| python.starts_with(entry))) + .unwrap_or_default() + { + continue; + } + if let Some(version) = version { + explicit_pythons.shift_remove(&python); + versioned_pythons + .entry(version) + .or_insert_with(IndexSet::new) + .insert(python); + } else { + explicit_pythons.insert(python); + } + } + } + } + } + } + } + } + + let mut seen: HashSet = HashSet::with_capacity( + explicit_pythons.len() + + versioned_pythons + .values() + .fold(0, |sum, pythons| sum + pythons.len()), + ); + seen.extend(explicit_pythons.clone()); + seen.extend(versioned_pythons.values().flatten().cloned()); + Box::pin( + #[coroutine] + static move || { + let mut names: IndexSet<&'static str> = IndexSet::new(); + for python_binary_spec in &python_binary_specs { + if pex_python.is_none() && !explicit_pythons.is_empty() { + names.insert(python_binary_spec.name); + } + if let Some(pythons) = + versioned_pythons.remove(&(python_binary_spec.major, python_binary_spec.minor)) + { + for python in pythons { + if let Some(pex_python) = pex_python.as_ref() { + if let Some(file_name) = python.file_name() + && file_name == pex_python + { + yield python + } else { + versioned_pythons + .entry((python_binary_spec.major, python_binary_spec.minor)) + .or_insert_with(IndexSet::new) + .insert(python); + } + } else { + if let Some(file_stem) = python.file_stem() + && let Some(file_stem) = file_stem.to_str() + && file_stem.starts_with(python_binary_spec.name) + { + yield python + } else { + versioned_pythons + .entry((python_binary_spec.major, python_binary_spec.minor)) + .or_insert_with(IndexSet::new) + .insert(python); + } + } + } + } + } + + if let Some(name) = pex_python.as_ref() { + for python in explicit_pythons { + if let Some(file_name) = python.file_name() + && file_name == name + { + yield python + } + } + } else { + for name in &names { + let mut unused = IndexSet::new(); + for python in explicit_pythons { + if let Some(file_stem) = python.file_stem() + && let Some(file_stem) = file_stem.to_str() + && file_stem.starts_with(name) + { + yield python + } else { + unused.insert(python); + } + } + explicit_pythons = unused; + } + } + + if let Some(search_path) = pex_python_path.or_else(|| { + RealSys + .env_path() + .map(|path| env::split_paths(&path).collect()) + }) { + for entry in search_path { + if let Ok(listing) = entry.read_dir() { + for path in listing { + if let Ok(file) = path + && file + .file_type() + .ok() + .as_ref() + .map(FileType::is_file) + .unwrap_or_default() + { + let file = file.path(); + if seen.contains(&file) { + continue; + } + if !platform::is_executable(&file).ok().unwrap_or_default() { + continue; + } + if let Some(name) = pex_python.as_ref() { + if let Some(file_name) = file.file_name() + && file_name == name + { + yield file + } + } else { + if let Some(file_stem) = file.file_stem() + && let Some(file_stem) = file_stem.to_str() + { + for name in &names { + if file_stem.starts_with(name) { + yield file; + break; + } + } + } + } + } + } + } + } + } + }, + ) +} + +pub(crate) fn iter_possibly_compatible_python_exes( + constraints: &InterpreterConstraints, + selection_strategy: SelectionStrategy, + search_path: SearchPath, + include_pex_compatible: bool, +) -> anyhow::Result> { + let (pex_python, search_path, known_python_exes) = search_path.into_parts()?; + let python_binary_specs = calculate_compatible_binary_specs( + constraints, + selection_strategy, + None, + include_pex_compatible, + ); + Ok(std::iter::from_coroutine(iter_python_exes( + constraints, + python_binary_specs, + pex_python, + search_path, + known_python_exes, + ))) +} diff --git a/crates/interpreter/src/lib.rs b/crates/interpreter/src/lib.rs index 8e8da5a..d5dc66d 100644 --- a/crates/interpreter/src/lib.rs +++ b/crates/interpreter/src/lib.rs @@ -2,6 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 #![deny(clippy::all)] +#![feature(coroutines)] +#![cfg_attr(windows, feature(coroutine_trait))] +#![cfg_attr(windows, feature(iter_from_coroutine))] +#![feature(stmt_expr_attributes)] mod constraints; mod interpreter; @@ -11,6 +15,7 @@ mod search_path; mod tag; mod version; +pub use constraints::unix::calculate_compatible_binary_names as calculate_compatible_unix_binary_names; pub use constraints::{ InterpreterConstraint, InterpreterConstraints, diff --git a/crates/interpreter/src/search_path.rs b/crates/interpreter/src/search_path.rs index f530d0e..37e732b 100644 --- a/crates/interpreter/src/search_path.rs +++ b/crates/interpreter/src/search_path.rs @@ -58,7 +58,7 @@ impl SearchPath { } if !contained { bail!( - "The given PEX_PYTHON {python} if not contained in the given \ + "The given PEX_PYTHON {python} is not contained in the given \ PEX_PYTHON_PATH: {pex_python_path}", python = python.display(), pex_python_path = pex_python_path.display() diff --git a/crates/pex/src/pex.rs b/crates/pex/src/pex.rs index f877361..53ea8df 100644 --- a/crates/pex/src/pex.rs +++ b/crates/pex/src/pex.rs @@ -4,6 +4,7 @@ use std::borrow::Cow; use std::collections::{BTreeSet, HashMap, HashSet, VecDeque}; use std::ffi::OsStr; +use std::fs::FileType; use std::io; use std::io::{BufReader, Read, Seek}; use std::path::{Path, PathBuf}; @@ -56,7 +57,12 @@ impl Layout { .filter(|e| e.path().extension() == Some(OsStr::new("whl"))) }) .next() - && wheel.path().is_file() + && wheel + .file_type() + .ok() + .as_ref() + .map(FileType::is_file) + .unwrap_or_default() { Layout::Packed } else { @@ -503,6 +509,7 @@ impl<'a> Pex<'a> { .unwrap_or(InterpreterSelectionStrategy::Oldest) .into(), search_path, + false, )? .collect::>(); diff --git a/crates/pex/src/wheel/file.rs b/crates/pex/src/wheel/file.rs index 8929eca..b0e8c3f 100644 --- a/crates/pex/src/wheel/file.rs +++ b/crates/pex/src/wheel/file.rs @@ -3,6 +3,7 @@ use std::borrow::Cow; use std::fmt::{Display, Formatter}; +use std::fs::FileType; use std::io::{Read, Seek}; use std::ops::Range; use std::path::{Component, Path, PathBuf}; @@ -133,7 +134,13 @@ impl MetadataDirs { let read_dir = wheel_dir.read_dir()?; let listing = read_dir.into_iter().filter_map(|result| { result.ok().and_then(|entry| { - if entry.path().is_dir() { + if entry + .file_type() + .ok() + .as_ref() + .map(FileType::is_dir) + .unwrap_or_default() + { entry.file_name().into_string().ok().map(Cow::Owned) } else { None @@ -237,7 +244,7 @@ pub struct WheelFile<'a> { impl<'a> WheelFile<'a> { pub fn parse_file_name(file_name: &'a str) -> anyhow::Result { - // See: https://packaging.python.org/en/latest/specifications/binary-distribution-format/#file-name-convention + // See: https://packaging.python.org/specifications/binary-distribution-format/#file-name-convention // {distribution}-{version}(-{build tag})?-{python tag}-{abi tag}-{platform tag}.whl if !file_name.ends_with(".whl") { bail!("Not a wheel file name {file_name}") diff --git a/crates/pex/src/wheel/record.rs b/crates/pex/src/wheel/record.rs index 34ae7fc..0914ab6 100644 --- a/crates/pex/src/wheel/record.rs +++ b/crates/pex/src/wheel/record.rs @@ -32,7 +32,7 @@ fn parse_entry_record<'a>( match fields.as_slice() { &[raw_path, hash, size] => { // N.B.: The spec here is very poor: - // https://packaging.python.org/en/latest/specifications/recording-installed-packages/#the-record-file + // https://packaging.python.org/specifications/recording-installed-packages/#the-record-file // There is no such thing as "on Windows" since a non-platform-specific wheel // could be created on Windows or Unix and uploaded to a registry. That said, // the occurrence of a dir name like bin/suffix\\ on Windows or vice versa seems diff --git a/crates/target/src/lib.rs b/crates/target/src/lib.rs index 998839a..849554c 100644 --- a/crates/target/src/lib.rs +++ b/crates/target/src/lib.rs @@ -190,7 +190,7 @@ impl SimplifiedTarget { } } else if platform_tag.starts_with("macos") { // For the psuedo-arch (universal2, etc) matches, see: - // https://packaging.python.org/en/latest/specifications/platform-compatibility-tags/#macos + // https://packaging.python.org/specifications/platform-compatibility-tags/#macos if platform_tag.contains("arm64") { return Ok(Some(enum_set!(Self::Arm64Macos))); } else if platform_tag.contains("x86_64") { diff --git a/crates/tools/src/commands/repository/extract.rs b/crates/tools/src/commands/repository/extract.rs index 6ba69d0..cda6ffc 100644 --- a/crates/tools/src/commands/repository/extract.rs +++ b/crates/tools/src/commands/repository/extract.rs @@ -427,7 +427,7 @@ fn add_loose_source( .path() .strip_prefix(pex) .expect("Walker paths of a PEX directory are always sub-paths"); - if entry.path().is_file() + if entry.file_type().is_file() && !entry_relpath.as_os_str().as_encoded_bytes().contains(&b'/') && let Some(file_name) = entry_relpath.file_name() && file_name.as_encoded_bytes().ends_with(b".py") @@ -446,7 +446,7 @@ fn add_loose_source( .expect("We confirmed the file name ended with .py above") .to_string(), ); - } else if entry.path().is_dir() { + } else if entry.file_type().is_dir() { let mut package = String::new(); for component in entry_relpath.components() { if let Component::Normal(name) = component { diff --git a/crates/venv/src/venv_pex.rs b/crates/venv/src/venv_pex.rs index 9fddc8f..e3d766e 100644 --- a/crates/venv/src/venv_pex.rs +++ b/crates/venv/src/venv_pex.rs @@ -462,7 +462,7 @@ fn populate_wheel_dir( }) .collect::, _>>()?; wheel_contents.into_par_iter().try_for_each(|(src, dst)| { - if src.path().is_dir() { + if src.file_type().is_dir() { fs::create_dir_all(&dst)?; } else { if let Some(parent_dir) = dst.parent() { diff --git a/pexrc.rs b/pexrc.rs index 6c12490..f3f29f4 100644 --- a/pexrc.rs +++ b/pexrc.rs @@ -4,7 +4,7 @@ #![deny(clippy::all)] use clap::{Args, Parser, Subcommand}; -use pexrc::commands::{Build, Extract, Inject, Platform, Script, info}; +use pexrc::commands::{Build, Extract, Inject, Platform, Python, Script, info}; /// Pex Runtime Control. #[derive(Parser)] @@ -51,6 +51,9 @@ enum Commands { /// Work with supported platforms. #[command(subcommand)] Platform(Platform), + /// Work with local Python installations. + #[command(subcommand)] + Python(Python), /// Create a Windows-style Python venv console script executable. Script(Script), } @@ -96,6 +99,10 @@ fn main() -> anyhow::Result<()> { Platform::List(list) => list.execute(), Platform::Python(python) => python.execute(), }, + Commands::Python(python) => match python { + Python::Inspect(inspect) => inspect.execute(), + Python::List(list) => list.execute(), + }, Commands::Script(script) => script.execute(), } } diff --git a/src/commands/inject.rs b/src/commands/inject.rs index 97a5605..b6d3689 100644 --- a/src/commands/inject.rs +++ b/src/commands/inject.rs @@ -293,7 +293,7 @@ fn inject_pex_dir( { let entry = entry?; let dst = dest_pex.path().join(entry.path().strip_prefix(pex.path)?); - if entry.path().is_dir() { + if entry.file_type().is_dir() { fs::create_dir_all(dst)?; } else { fs::copy(entry.path(), dst)?; diff --git a/src/commands/mod.rs b/src/commands/mod.rs index e737959..6cae04e 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -6,10 +6,12 @@ mod extract; pub mod info; mod inject; mod platform; +mod python; mod script; pub use build::Build; pub use extract::Extract; pub use inject::Inject; pub use platform::Platform; +pub use python::Python; pub use script::Script; diff --git a/src/commands/python/inspect.rs b/src/commands/python/inspect.rs new file mode 100644 index 0000000..368e5ba --- /dev/null +++ b/src/commands/python/inspect.rs @@ -0,0 +1,70 @@ +// Copyright 2026 Pex project contributors. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::PathBuf; + +use anyhow::anyhow; +use clap::Args; +use cli::{Json, Output}; +use interpreter::{Interpreter, InterpreterConstraints, SearchPath, SelectionStrategy}; +use python_platform::PythonPlatform; +use scripts::IdentifyInterpreter; +use scripts::Scripts::Embedded; +use serde_json::{Value, json}; + +#[derive(Args)] +#[group(skip)] +pub struct Inspect { + #[command(flatten)] + json_serializer: Json, + + #[command(flatten)] + output: Output, + + #[arg()] + python: Option, +} + +impl Inspect { + pub fn execute(self) -> anyhow::Result<()> { + let identification_script = IdentifyInterpreter::read(&mut Embedded)?; + let interpreter = self + .python + .and_then(|python| { + Interpreter::load(&python, &identification_script) + .ok() + .map(Ok) + }) + .unwrap_or_else(|| { + InterpreterConstraints::EMPTY + .iter_possibly_compatible_python_exes( + SelectionStrategy::Newest, + SearchPath::from_env()?, + false, + )? + .filter_map(|python| Interpreter::load(&python, &identification_script).ok()) + .next() + .ok_or_else(|| anyhow!("No Python installations could be found on the system.")) + })?; + + let mut out = self.output.writer()?; + + let mut object = serde_json::Map::new(); + object.insert("realpath".to_string(), json!(interpreter.realpath)); + // N.B.: This inlines the details as top-level keys. + object.append( + json!(interpreter.details).as_object_mut().expect( + "Interpreter details is a struct which always equates to a json Object Map", + ), + ); + object.insert("env_markers".to_string(), json!(interpreter.marker_env())); + object.insert( + "supported_tags".to_string(), + Value::from(interpreter.supported_tags().collect::>()), + ); + + self.json_serializer + .serialize(&mut out, &serde_json::Value::Object(object))?; + Ok(()) + } +} diff --git a/src/commands/python/list.rs b/src/commands/python/list.rs new file mode 100644 index 0000000..141aa26 --- /dev/null +++ b/src/commands/python/list.rs @@ -0,0 +1,103 @@ +// Copyright 2026 Pex project contributors. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::PathBuf; + +use clap::{ArgAction, Args}; +use cli::{Json, Output}; +use interpreter::{ + Interpreter, + InterpreterConstraint, + InterpreterConstraints, + SearchPath, + SelectionStrategy, +}; +use owo_colors::OwoColorize; +use scripts::{IdentifyInterpreter, Scripts}; + +#[derive(Args)] +#[group(skip)] +pub struct List { + /// Output discovered Python paths in JSON. + #[arg(long, default_value_t = false, help_heading = "Output")] + json: bool, + + #[command(flatten)] + json_serializer: Json, + + #[command(flatten)] + output: Output, + + /// Constrain the interpreters to those meeting any of the given constraints. + /// + /// Interpreter constraints are composed of implementation names and a version specifiers in + /// one of the following forms: + /// + `` + /// + `` + /// + `` + /// + /// Implementation names are: + /// + `CPython`: any CPython interpreter + /// + `CPython+t` or `CPython[free-threaded]`: a free-threaded CPython interpreter + /// + `CPython-t` or `CPython[gil]`: a traditional GIL-enabled CPython interpreter + /// + `PyPy`: any PyPy interpreter + /// + /// Version specifiers are PEP-440 [^1] version specifiers [^2]. + /// + /// Some examples: + /// + `PyPy`: any PyPy interpreter + /// + `==3.14.*`: any Python interpreter version 3.14 + /// + `CPython>=3.12`: any CPython interpreter version 3.12 or greater + /// + /// [^1]: https://peps.python.org/pep-0440/ + /// [^2]: https://packaging.python.org/specifications/version-specifiers/#version-specifiers + #[arg( + short = 'c', + long = "constraint", + visible_aliases = ["ic", "interpreter-constraint"], + value_name = "CONSTRAINT", + help_heading = "Constraints", + action = ArgAction::Append, + value_parser = InterpreterConstraint::parse, + verbatim_doc_comment, + )] + constraints: Vec, +} + +impl List { + pub fn execute(self) -> anyhow::Result<()> { + let ics = InterpreterConstraints::from(self.constraints); + let pythons = ics.iter_possibly_compatible_python_exes( + SelectionStrategy::Newest, + SearchPath::from_env()?, + false, + )?; + let pythons: Vec = if !ics.is_empty() { + let identification_script = IdentifyInterpreter::read(&mut Scripts::Embedded)?; + pythons + .filter_map(|python| { + if let Some(interpreter) = + Interpreter::load(&python, &identification_script).ok() + && ics.contains(&interpreter) + { + Some(python) + } else { + None + } + }) + .collect() + } else { + pythons.collect() + }; + + let mut out = self.output.writer()?; + if self.json { + self.json_serializer.serialize(&mut out, &pythons)?; + } else { + for python in pythons { + anstream::println!("{}", python.display().blue()) + } + } + Ok(()) + } +} diff --git a/src/commands/python/mod.rs b/src/commands/python/mod.rs new file mode 100644 index 0000000..e91b4b7 --- /dev/null +++ b/src/commands/python/mod.rs @@ -0,0 +1,19 @@ +// Copyright 2026 Pex project contributors. +// SPDX-License-Identifier: Apache-2.0 + +use clap::Subcommand; + +use crate::commands::python::inspect::Inspect; +use crate::commands::python::list::List; + +mod inspect; +mod list; + +#[derive(Subcommand)] +#[group(skip)] +pub enum Python { + /// Inspect Python installation. + Inspect(Inspect), + /// List supported Python installations. + List(List), +}