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
8 changes: 8 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Release Notes

## 0.13.2

This release has injected PEXes using PEP-829 `.start` files to affect `PEX_EXTRA_SYS_PATH`
`sys.path` mutation instead of `.pth` import lines. Although the `.pth` file is still emitted for
maximum compatibility with third party code, the `PEX_EXTRA_SYS_PATH.pth` file will no longer be
emitted for PEX venvs using Python 3.20 and greater. See [PEP-829](https://peps.python.org/pep-0829)
and the [3.15 notes](https://docs.python.org/3.15/whatsnew/3.15.html#whatsnew315-startup-files).

## 0.13.1

This release eliminates an erroneous application starting cursor that would persist for several
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

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

2 changes: 1 addition & 1 deletion 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.13.1"
version = "0.13.2"
edition = { workspace = true }
publish = false

Expand Down
1 change: 1 addition & 0 deletions crates/scripts/src/PEX_EXTRA_SYS_PATH.pth
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import PEX_EXTRA_SYS_PATH; PEX_EXTRA_SYS_PATH.extend_sys_path(legacy=True)
25 changes: 25 additions & 0 deletions crates/scripts/src/PEX_EXTRA_SYS_PATH.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Copyright 2026 Pex project contributors.
# SPDX-License-Identifier: Apache-2.0

import os
import sys


def _extend_sys_path(path_env_var):
path = os.environ.get(path_env_var)
if path:
entries = path.split(os.pathsep)
sys.path.extend(entries)
return entries
return []


def extend_sys_path(legacy=False):
entries = _extend_sys_path("PEX_EXTRA_SYS_PATH")
entries.extend(_extend_sys_path("__PEX_EXTRA_SYS_PATH__"))
debug = os.environ.pop("__PEX_EXTRA_SYS_PATH_DEBUG__", "")
if debug:
import json

with open(debug, "w") as fp:
json.dump({"entries": entries, "legacy": legacy}, fp)
1 change: 1 addition & 0 deletions crates/scripts/src/PEX_EXTRA_SYS_PATH.start
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
PEX_EXTRA_SYS_PATH:extend_sys_path
12 changes: 12 additions & 0 deletions crates/scripts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ pub enum Script {
IdentifyInterpreter,
VendoredVirtualenv,
VenvPex,
VenvPexExtraSysPathPth,
VenvPexExtraSysPathPy,
VenvPexExtraSysPathStart,
VenvPexRepl,
}

Expand All @@ -34,6 +37,9 @@ impl Script {
Script::IdentifyInterpreter => "interpreter.py",
Script::VendoredVirtualenv => "virtualenv.py",
Script::VenvPex => "venv-pex.py",
Script::VenvPexExtraSysPathPth => "PEX_EXTRA_SYS_PATH.pth",
Script::VenvPexExtraSysPathPy => "PEX_EXTRA_SYS_PATH.py",
Script::VenvPexExtraSysPathStart => "PEX_EXTRA_SYS_PATH.start",
Script::VenvPexRepl => "venv-pex-repl.py",
}
}
Expand Down Expand Up @@ -68,6 +74,9 @@ impl Scripts {
Script::IdentifyInterpreter => include_str!("interpreter.py"),
Script::VendoredVirtualenv => include_str!(env!("VIRTUALENV_PY")),
Script::VenvPex => include_str!("venv-pex.py"),
Script::VenvPexExtraSysPathPth => include_str!("PEX_EXTRA_SYS_PATH.pth"),
Script::VenvPexExtraSysPathPy => include_str!("PEX_EXTRA_SYS_PATH.py"),
Script::VenvPexExtraSysPathStart => include_str!("PEX_EXTRA_SYS_PATH.start"),
Script::VenvPexRepl => include_str!("venv-pex-repl.py"),
})),
Scripts::Loose(base_dir) => {
Expand Down Expand Up @@ -212,4 +221,7 @@ macro_rules! generate_script_type {
generate_script_type!(IdentifyInterpreter);
generate_script_type!(VendoredVirtualenv);
generate_script_type!(VenvPex);
generate_script_type!(VenvPexExtraSysPathPth);
generate_script_type!(VenvPexExtraSysPathPy);
generate_script_type!(VenvPexExtraSysPathStart);
generate_script_type!(VenvPexRepl);
65 changes: 48 additions & 17 deletions crates/venv/src/venv_pex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,14 @@ use pex::{
};
use platform::{Perms, mark_executable, path_as_bytes, path_as_str, symlink_or_link_or_copy};
use rayon::iter::{IntoParallelIterator, ParallelIterator};
use scripts::{Scripts, VenvPex, VenvPexRepl};
use scripts::{
Scripts,
VenvPex,
VenvPexExtraSysPathPth,
VenvPexExtraSysPathPy,
VenvPexExtraSysPathStart,
VenvPexRepl,
};
use serde_json::Value;
use zip::ZipArchive;

Expand Down Expand Up @@ -900,6 +907,7 @@ pub fn populate<'a>(
provenance,
)?;
if matches!(scope, InstallScope::All | InstallScope::Srcs) {
write_pex_extra_sys_path_support_files(venv, scripts)?;
write_main(
venv,
shebang_interpreter,
Expand Down Expand Up @@ -1062,6 +1070,45 @@ impl<'a> Display for PythonListTupleStrStr<'a> {
}
}

fn write_pex_extra_sys_path_support_files(
venv: &Virtualenv,
scripts: &mut Scripts,
) -> anyhow::Result<()> {
// See: https://peps.python.org/pep-0829/
let mut pex_extra_sys_path_py_fp =
File::create_new(venv.site_packages_path("PEX_EXTRA_SYS_PATH.py"))?;
pex_extra_sys_path_py_fp
.write_all(VenvPexExtraSysPathPy::read(scripts)?.contents().as_bytes())?;

let python_version = {
let version = venv.interpreter.raw().version;
(version.major, version.minor)
};

// Starting with Python 3.15 .start files trump import lines in .path files.
// See: https://peps.python.org/pep-0829/#abstract
if python_version >= (3, 15) {
let mut pex_extra_sys_path_start_fp =
File::create_new(venv.site_packages_path("PEX_EXTRA_SYS_PATH.start"))?;
pex_extra_sys_path_start_fp.write_all(
VenvPexExtraSysPathStart::read(scripts)?
.contents()
.as_bytes(),
)?;
}

// After ~Python 3.20 .pth import lines will start to raise warnings; so we no longer emit a
// .pth compatibility bridge. See: https://peps.python.org/pep-0829/#abstract
if python_version < (3, 20) {
let mut pex_extra_sys_path_pth_fp =
File::create_new(venv.site_packages_path("PEX_EXTRA_SYS_PATH.pth"))?;
pex_extra_sys_path_pth_fp
.write_all(VenvPexExtraSysPathPth::read(scripts)?.contents().as_bytes())?;
}

Ok(())
}

fn write_main(
venv: &Virtualenv,
shebang_interpreter: &Path,
Expand All @@ -1070,22 +1117,6 @@ fn write_main(
scripts: &mut Scripts,
bin_path_override: Option<BinPath>,
) -> anyhow::Result<()> {
let pex_extra_sys_path_pth = venv.site_packages_path("PEX_EXTRA_SYS_PATH.pth");
let mut pex_extra_sys_path_pth_fp = File::create_new(&pex_extra_sys_path_pth)?;
for env_var in ["PEX_EXTRA_SYS_PATH", "__PEX_EXTRA_SYS_PATH__"].into_iter() {
// # N.B.: .pth import lines must be single lines:
// https://docs.python.org/3/library/site.html
writeln!(
pex_extra_sys_path_pth_fp,
"import os, sys; \
sys.path.extend(\
entry \
for entry in os.environ.get('{env_var}', '').split(os.pathsep) \
if entry\
)",
)?;
}

let main_py = venv.prefix().join("__main__.py");
let mut main_py_fp = File::create_new(&main_py)?;
write_shebang_bytes(&mut main_py_fp, shebang_interpreter, shebang_arg)?;
Expand Down
11 changes: 10 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ name = "pexrc"
requires-python = ">=2.7"
dynamic = ["version"]

[build-system]
requires = ["uv_build>=0.11.16,<0.12"]
build-backend = "uv_build"

[tool.uv]
package = false

Expand All @@ -13,6 +17,7 @@ dev = [
"dev-cmd[old-pythons]; python_version >= '3.8'",
"mypy",
"pex",
"platformdirs",
"psutil",
"pytest",
"pytest-xdist",
Expand All @@ -22,9 +27,13 @@ dev = [
]

[[tool.mypy.overrides]]
module = "_pytest.*"
module = ["_pytest.*"]
follow_imports = "skip"

[[tool.mypy.overrides]]
module = ["platformdirs"]
follow_untyped_imports = true

[tool.ruff]
line-length = 100

Expand Down
7 changes: 7 additions & 0 deletions python/testing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import platform
import subprocess

import platformdirs

IS_CI = os.environ.get("CI", "false") == "true"

IS_LINUX = platform.system().lower() == "linux"
Expand Down Expand Up @@ -41,3 +43,8 @@ def session_dir():
def session_pexrc_root():
# type: () -> str
return os.environ["_PEXRC_TEST_SESSION_PEXRC_ROOT"]


def testing_cache_root():
# type: () -> str
return platformdirs.user_cache_dir("pexrc-dev", opinion=False)
69 changes: 69 additions & 0 deletions python/testing/interpreter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Copyright 2026 Pex project contributors.
# SPDX-License-Identifier: Apache-2.0

from __future__ import absolute_import

import os
import platform
import subprocess

from testing import IS_MAC, IS_WINDOWS, testing_cache_root

TYPE_CHECKING = False
if TYPE_CHECKING:
# Ruff doesn't understand Python 2 and thus the type comment usages.
from typing import Text, Tuple # noqa: F401


def get_os():
# type: () -> str

if IS_WINDOWS:
return "windows"
elif IS_MAC:
return "macos"
else:
return "linux"


def get_arch():
# type: () -> str

if IS_WINDOWS:
arch = os.environ.get("PROCESSOR_ARCHITECTURE", platform.machine()).lower()
else:
arch = platform.machine().lower()

if arch in ("aarch64", "arm64"):
return "aarch64"
elif arch in ("x86_64", "amd64"):
return "x86_64"
else:
return arch


def ensure_python(
version, # type: Tuple[int, int]
install_if_missing=True, # type: bool
):
# type: (...) -> Text

# N.B.: We force arch to get arm64 PBS builds for Windows arm64 machines.
# See: https://github.com/astral-sh/uv/issues/12906
version_spec = "cpython-{major}.{minor}-{os}-{arch}".format(
major=version[0], minor=version[1], os=get_os(), arch=get_arch()
)
env = dict(os.environ, UV_PYTHON_INSTALL_DIR=os.path.join(testing_cache_root(), "interpreters"))
try:
return (
subprocess.check_output(args=["uv", "python", "find", version_spec], env=env)
.decode("utf-8")
.strip()
)
except subprocess.CalledProcessError:
if not install_if_missing:
raise
subprocess.check_call(
args=["uv", "python", "install", "--managed-python", "--force", version_spec], env=env
)
return ensure_python(version, install_if_missing=False)
Loading