Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Release Notes

## 0.16.1

This release fixes interpreter detection for `pyenv` shims on unix. Previously the cache of
interpreter information for the shimmed Python was based on the shim and not the Python executable
it resolved to.

In addition, interpreter discovery on Windows is fixed to only discover `python.exe` / `pypy.exe`.
The windowed versions are resolved based off the console versions just in time when needed.

## 0.16.0

This release fixes interpreter discovery for Windows. The [PEP-514 spec](
Expand Down
4 changes: 1 addition & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ cargo-features = ["profile-rustflags"]

[package]
name = "pexrc"
version = "0.16.0"
version = "0.16.1"
edition = { workspace = true }
publish = false

Expand Down Expand Up @@ -247,7 +247,6 @@ log = { workspace = true }
logging = { path = "crates/logging" }
owo-colors = { workspace = true }
pex = { path = "crates/pex" }
pexrs = { path = "crates/pexrs" }
platform = { path = "crates/platform" }
python-platform = { path = "crates/python-platform" }
python-proxy = { path = "crates/python-proxy" }
Expand Down
4 changes: 2 additions & 2 deletions crates/cache/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,9 +168,9 @@ impl CacheDir {

fn version(&self) -> &'static str {
match self {
CacheDir::Interpreter => "1",
CacheDir::Interpreter => "2",
CacheDir::PythonProxy => "0",
CacheDir::Venv => "0",
CacheDir::Venv => "1",
}
}

Expand Down
1 change: 0 additions & 1 deletion crates/interpreter/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ cache = { path = "../cache" }
indexmap = { workspace = true }
pep440_rs = { workspace = true }
pep508_rs = { workspace = true }
elf = { workspace = true }
fs-err = { workspace = true }
log = { workspace = true }
logging_timer = { workspace = true }
Expand Down
7 changes: 6 additions & 1 deletion crates/interpreter/src/constraints/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,12 @@ fn iter_python_exes(
&& let Some(file_stem) = file_stem.to_str()
{
for name in &names {
if file_stem.starts_with(name) {
if file_stem.starts_with(name)
// N.B.: Since this is a {PEX_PYTHON_,}PATH search,
// we may pick up pythonw.exe, pypyw.exe, etc. but
// we only want to report the console pythons.
&& !file_stem.ends_with("w")
{
yield file;
break;
}
Expand Down
12 changes: 10 additions & 2 deletions crates/interpreter/src/interpreter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -331,8 +331,16 @@ impl Interpreter {
python_exe: &Path,
identification_script: &IdentifyInterpreter,
) -> anyhow::Result<Self> {
let interpreter_info = Self::interpreter_info(python_exe)?;
Self::load_internal(&interpreter_info, python_exe, identification_script)
#[cfg(unix)]
let python_exe = crate::pyenv::Pyenv::locate()
.map(|pyenv| pyenv.resolve_if_shim(python_exe))
.unwrap_or(Ok(Cow::Borrowed(python_exe)))?;
let interpreter_info = Self::interpreter_info(python_exe.as_path())?;
Self::load_internal(
&interpreter_info,
python_exe.as_path(),
identification_script,
)
}

fn load_internal(
Expand Down
2 changes: 2 additions & 0 deletions crates/interpreter/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
#![cfg_attr(windows, feature(coroutine_trait))]
#![cfg_attr(windows, feature(iter_from_coroutine))]
#![feature(stmt_expr_attributes)]
#![feature(str_as_str)]

mod constraints;
mod interpreter;

mod platform;
mod pyenv;
mod search_path;
mod tag;
mod version;
Expand Down
183 changes: 183 additions & 0 deletions crates/interpreter/src/pyenv.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// Copyright 2026 Pex project contributors.
// SPDX-License-Identifier: Apache-2.0

#![cfg(unix)]

use std::borrow::Cow;
use std::env;
use std::env::VarError;
use std::ffi::OsStr;
use std::fmt::Display;
use std::io::{BufRead, BufReader, Read};
use std::os::unix::ffi::OsStrExt;
use std::path::{Component, Path, PathBuf};

use anyhow::{Context, bail};
use fs_err::File;
use log::warn;
use logging_timer::time;
use python_platform::Implementation;

pub(crate) struct Pyenv(PathBuf);

impl Pyenv {
pub(crate) fn locate() -> Option<Self> {
env::var_os("PYENV_ROOT").map(PathBuf::from).map(Self)
}

#[time("debug", "Pyenv.{}")]
pub(crate) fn resolve_if_shim<'a>(&self, path: &'a Path) -> anyhow::Result<Cow<'a, Path>> {
if let Ok(rel_path) = path.strip_prefix(&self.0)
&& let Some(component) = rel_path.components().next()
&& matches!(component, Component::Normal(name) if name == OsStr::from_bytes(b"shims"))
&& let Some(shim) = PythonShim::parse(path)?
{
Ok(Cow::Owned(shim.select_version(&self.0)?))
} else {
Ok(Cow::Borrowed(path))
}
}
}

struct PythonShim<'a> {
path: &'a Path,
implementation: Implementation,
version: &'a str,
}

impl<'a> PythonShim<'a> {
fn parse(path: &'a Path) -> anyhow::Result<Option<Self>> {
// Sanity-check this is a shell script, which all shims should be.
let mut file = File::open(path)?;
let mut buf: [u8; 2] = [0; 2];
file.read_exact(&mut buf).with_context(|| {
format!(
"Failed to confirm {path} as a pyenv shim script",
path = path.display()
)
})?;
if &buf != b"#!" {
bail!(
"Although {path} is under the pyenv shims dir, it is not a shim script.\n\
First two bytes are {buf:?}",
path = path.display()
);
}

if let Some(file_name) = path.file_name()
&& let Some(file_name) = file_name.to_str()
&& let Some((implementation, version)) = file_name
.strip_prefix("python")
.map(|version| (Implementation::CPython, version))
.or_else(|| {
file_name
.strip_prefix("pypy")
.map(|version| (Implementation::PyPy, version))
})
{
return Ok(Some(Self {
path,
implementation,
version,
}));
}
Ok(None)
}

fn select_version(&self, pyenv_root: &Path) -> anyhow::Result<PathBuf> {
if let Some((source, active_versions)) = active_versions(pyenv_root)? {
for version in &active_versions {
if let Some(search_version) = match self.implementation {
Implementation::CPython => {
Some(version.strip_prefix("pypy").unwrap_or(version))
}
Implementation::PyPy => version.strip_prefix("pypy"),
} && search_version.starts_with(self.version)
{
let mut versions_dir = pyenv_root.join("versions");
versions_dir.push(version);
versions_dir.push("bin");
versions_dir.push("python");
if let Ok(python_exe) = versions_dir.canonicalize() {
return Ok(python_exe);
}
}
}

struct ActivatedVersions(Vec<String>);
impl Display for ActivatedVersions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
for version in &self.0 {
writeln!(f, "+ {version}")?;
}
Ok(())
}
}
let activated_versions = ActivatedVersions(active_versions);
bail!(
"The pyenv shim at {path} has no corresponding versions activated.\n\
Versions activated via {source} are:\n\
{activated_versions}",
path = self.path.display()
)
} else {
bail!(
"There are no versions activated for pyenv shim at {path}",
path = self.path.display()
)
}
}
}

fn active_versions(pyenv_root: &Path) -> anyhow::Result<Option<(String, Vec<String>)>> {
// See: https://github.com/pyenv/pyenv/blob/c425c9ec3a409a8d432bc9f4851da8e3f040bddc/README.md#understanding-python-version-selection
if let Ok(shell_versions) = env::var("PYENV_VERSION").inspect_err(|err| {
if let VarError::NotUnicode(value) = err {
warn!(
"Skipping non-utf8 env setting PYENV_VERSION={value}",
value = value.display()
)
}
}) {
return Ok(Some((
format!("PYENV_VERSION={shell_versions}"),
shell_versions.split(":").map(str::to_string).collect(),
)));
} else {
let start = env::var_os("PYENV_DIR")
.map(PathBuf::from)
.map(Ok)
.unwrap_or_else(env::current_dir)
.context("Failed to determine starting directory for pyenv version file search.")?;
for dir in start.ancestors() {
if let Ok(local_version_file) = File::open(dir.join(".python-version")) {
return Ok(Some((
local_version_file.path().display().to_string(),
read_versions(local_version_file),
)));
}
}
if let Ok(global_version_file) = File::open(pyenv_root.join("version")) {
return Ok(Some((
global_version_file.path().display().to_string(),
read_versions(global_version_file),
)));
}
}
Ok(None)
}

fn read_versions(version_file: impl Read) -> Vec<String> {
BufReader::new(version_file)
.lines()
.map_while(Result::ok)
.filter_map(|line| {
let contents = line.trim();
if contents.starts_with("#") {
None
} else {
Some(contents.to_string())
}
})
.collect()
}
2 changes: 1 addition & 1 deletion crates/python-platform/src/implementation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use std::str::FromStr;
use anyhow::bail;

#[derive(Copy, Clone)]
pub(crate) enum Implementation {
pub enum Implementation {
CPython,
PyPy,
}
Expand Down
2 changes: 1 addition & 1 deletion crates/python-platform/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ use pep508_rs::pep440_rs::Version;
use serde::{Deserialize, Serialize};

pub use crate::arch::Arch;
use crate::implementation::Implementation;
pub use crate::implementation::Implementation;
pub use crate::linux::LinuxInfo;
use crate::mac::Release;
pub use crate::markers::{PlatformRelease, PlatformVersion};
Expand Down
4 changes: 2 additions & 2 deletions crates/scripts/src/interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,15 @@ def cpython_abi_info(sys_config_vars):
# extension modules is the best option.
# https://github.com/pypa/pip/issues/3383#issuecomment-173267692
has_ext = "_d.pyd" in EXTENSION_SUFFIXES
if with_debug or (with_debug is None and (has_refcount or has_ext)):
if (with_debug is None and (has_refcount or has_ext)) or bool(with_debug):
debug = True

if sys.version_info >= (3, 13):
free_threaded = bool(sys_config_vars.get("Py_GIL_DISABLED"))

if sys.version_info < (3, 8):
with_pymalloc = sys_config_vars.get("WITH_PYMALLOC")
pymalloc = with_pymalloc or with_pymalloc is None
pymalloc = with_pymalloc is None or bool(with_pymalloc)

if sys.version_info < (3, 3):
unicode_size = sys_config_vars.get("Py_UNICODE_SIZE")
Expand Down
Loading