Skip to content

Commit d86293e

Browse files
authored
Fix --sh-boot for PEX_TOOLS=1 and broken venvs. (#74)
1 parent 9fec2e9 commit d86293e

10 files changed

Lines changed: 115 additions & 28 deletions

File tree

CHANGES.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Release Notes
22

3+
## 0.7.1
4+
5+
This release fixes injected `--sh-boot` PEXes to honor `PEX_TOOLS=1` and be robust to underlying
6+
venv breaks due to system Python upgrades or uninstalls.
7+
38
## 0.7.0
49

510
This release adds support for PEX_TOOLS when `pexrc` is built with the `tools` feature; e.g.:

Cargo.lock

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
# Copyright 2026 Pex project contributors.
22
# SPDX-License-Identifier: Apache-2.0
33

4+
cargo-features = ["profile-rustflags"]
5+
46
[package]
57
name = "pexrc"
6-
version = "0.7.0"
8+
version = "0.7.1"
79
edition = "2024"
810
publish = false
911

@@ -112,6 +114,11 @@ fingerprint = {
112114
algorithm = "sha256", hash = "26497108bbd3a71cada865d712878bc3957fcaebf2bc431357f560f0524e6273"
113115
}
114116

117+
[profile.dev]
118+
# N.B.: This is needed to get a debug build for the Windows clib dll. It turns out COFF only
119+
# supports 2^16 symbols and we have edged over that.
120+
rustflags = ["-Zshare-generics=off"]
121+
115122
[profile.release]
116123
codegen-units = 1
117124
lto = true

crates/boot/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ cache = { path = "../cache" }
1212
const_format = { workspace = true }
1313
fs-err = { workspace = true }
1414
interpreter = { path = "../interpreter" }
15+
itertools = { workspace = true }
1516
pex = { path = "../pex" }
1617
pexrs = { path = "../pexrs" }
1718
platform = { path = "../platform" }

crates/boot/src/boot.sh

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ set -eu
1313
RAW_DEFAULT_PEXRC_ROOT="{pexrc_root}"
1414
VENV_RELPATH="{venv_relpath}"
1515
PYTHONS="{pythons}"
16+
PYTHON_ARGS="{python_args}"
1617
# --- split --- #
1718

1819
# N.B.: The SC2116 warning suppressions below are in place to ensure tilde-expansion of the
@@ -38,21 +39,18 @@ on_fast_path() {
3839
[ -z "${PEX_IGNORE_RCFILES:-}" ] \
3940
&& [ -z "${PEX_PYTHON:-}" ] \
4041
&& [ -z "${PEX_PYTHON_PATH:-}" ] \
41-
&& [ -z "${PEX_PATH:-}" ]
42+
&& [ -z "${PEX_PATH:-}" ] \
43+
&& [ -z "${PEX_TOOLS:-}" ]
4244
}
4345

4446
if on_fast_path; then
4547
for python in ${PYTHONS} ; do
46-
if [ -x "${VENV}/sh-boot/${python}" ]; then
48+
if [ -x "${VENV}/sh-boot/base-${python}" ] && [ -x "${VENV}/sh-boot/pex-${python}" ]; then
4749
# The fast path: We're installed under the PEXRC_ROOT and the venv interpreter to use is
4850
# embedded in the shebang of our venv pex script; so just execute that script directly.
4951
export PEX="$0"
5052

51-
# TODO: XXX: Instead of linking the venv pex script to the sh-boot python, link the
52-
# venv python. This will ensure when a base interpreter gets uninstalled, the venv will
53-
# automatically invalidate. As it stands the venv pex script we link to can exist but have
54-
# a shebang whose python is gone.
55-
exec "${VENV}/sh-boot/${python}" "$@"
53+
exec "${VENV}/sh-boot/pex-${python}" "$@"
5654
fi
5755
done
5856
fi
@@ -71,11 +69,14 @@ python_exe="$(find_python)"
7169
if [ -n "${python_exe}" ]; then
7270
if [ -n "${PEX_VERBOSE:-}" ]; then
7371
echo >&2 "$0 used /bin/sh boot to select python: ${python_exe} for re-exec..."
74-
echo >&2 "Running pex to lay itself out under PEXRC_ROOT."
72+
if [ -n "${PEX_VERBOSE:-}" ]; then
73+
echo >&2 "Running pex to invoke PEX_TOOLS."
74+
else
75+
echo >&2 "Running pex to lay itself out under PEXRC_ROOT."
76+
fi
7577
fi
7678
export _PEXRC_SH_BOOT_SEED_DIR="${VENV}/sh-boot"
77-
# TODO: XXX: Inject -sE or -I if hermetic?
78-
exec "${python_exe}" "$0" "$@"
79+
exec "${python_exe}" "${PYTHON_ARGS}" "$0" "$@"
7980
fi
8081

8182
echo >&2 "Failed to find any of these python binaries on the PATH:"

crates/boot/src/lib.rs

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ use cache::CacheDir;
1111
use const_format::str_split;
1212
use fs_err as fs;
1313
use fs_err::File;
14-
use interpreter::{InterpreterConstraints, SearchPath, SelectionStrategy};
14+
use interpreter::{InterpreterConstraints, SearchPath, SelectionStrategy, VersionSpec};
15+
use itertools::Itertools;
1516
use pex::{Pex, PexPath};
1617
use pexrs::venv_dir;
1718
use platform::path_as_str;
@@ -21,7 +22,11 @@ use zip::write::{FileOptionExtension, FileOptions};
2122
const SH_BOOT_SHEBANG: &[u8] = b"#!/bin/sh\n";
2223
const SH_BOOT_PARTS: [&str; 4] = str_split!(include_str!("boot.sh"), "# --- split --- #\n");
2324

24-
pub fn sh_boot_shebang(pex: &Path, escaped: bool) -> anyhow::Result<Option<String>> {
25+
pub fn sh_boot_shebang(
26+
pex: &Path,
27+
hermetic: bool,
28+
escaped: bool,
29+
) -> anyhow::Result<Option<String>> {
2530
let pex = Pex::load(pex)?;
2631

2732
let mut sh_boot_shebang_buffer: [_; SH_BOOT_SHEBANG.len()] = [0; SH_BOOT_SHEBANG.len()];
@@ -54,13 +59,28 @@ pub fn sh_boot_shebang(pex: &Path, escaped: bool) -> anyhow::Result<Option<Strin
5459
let pythons = interpreter_constraints
5560
.calculate_compatible_binary_names(selection_strategy)
5661
.into_iter()
57-
.map(|binary_name| {
62+
.map(|(binary_name, version_spec)| {
5863
binary_name
5964
.into_string()
65+
.map(|binary_name| (binary_name, version_spec))
6066
.map_err(|err| anyhow!("{err}", err = err.display()))
6167
})
6268
.collect::<anyhow::Result<Vec<_>>>()?;
63-
69+
let python_args = if hermetic {
70+
if pythons.iter().any(|(_, version_spec)| {
71+
matches!(version_spec, None | Some(VersionSpec::Major(_)))
72+
|| matches!(
73+
version_spec,
74+
Some(VersionSpec::MajorMinor(major, minor)) if (*major, *minor) < (3, 4)
75+
)
76+
}) {
77+
"-sE"
78+
} else {
79+
"-I"
80+
}
81+
} else {
82+
""
83+
};
6484
Ok(Some(format!(
6585
"{shebang}{start_escape}{header}{vars}{body}{end_escape}\n",
6686
shebang = SH_BOOT_PARTS[0], // N.B.: SH_BOOT_SHEBANG
@@ -72,7 +92,14 @@ pub fn sh_boot_shebang(pex: &Path, escaped: bool) -> anyhow::Result<Option<Strin
7292
pex.info.pex_root.as_deref().unwrap_or_default(),
7393
)
7494
.replace("{venv_relpath}", path_as_str(venv_relpath)?)
75-
.replace("{pythons}", &pythons.join("\n")),
95+
.replace(
96+
"{pythons}",
97+
&pythons
98+
.iter()
99+
.map(|(binary_name, _)| binary_name)
100+
.join("\n")
101+
)
102+
.replace("{python_args}", python_args),
76103
body = SH_BOOT_PARTS[3].trim_end(),
77104
end_escape = if escaped { "\n'''\n" } else { "\n" },
78105
)))

crates/interpreter/src/constraints.rs

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use std::str::FromStr;
99
use std::sync::LazyLock;
1010

1111
use anyhow::bail;
12-
use indexmap::{IndexSet, indexset};
12+
use indexmap::{IndexMap, IndexSet};
1313
use log::{debug, warn};
1414
use pep440_rs::{Operator, Version, VersionSpecifier, VersionSpecifiers};
1515
use pep508_rs::{ExtraName, MarkerTree, PackageName, Requirement, VersionOrUrl};
@@ -276,6 +276,11 @@ struct PythonBinarySpec {
276276
suffix: Option<&'static str>,
277277
}
278278

279+
pub enum VersionSpec {
280+
MajorMinor(u8, u8),
281+
Major(u8),
282+
}
283+
279284
pub struct InterpreterConstraints(Vec<InterpreterConstraint>);
280285

281286
impl InterpreterConstraints {
@@ -384,9 +389,9 @@ impl InterpreterConstraints {
384389
pub fn calculate_compatible_binary_names(
385390
&self,
386391
selection_strategy: SelectionStrategy,
387-
) -> IndexSet<OsString> {
392+
) -> IndexMap<OsString, Option<VersionSpec>> {
388393
let binary_specs = self.calculate_compatible_binary_specs(selection_strategy);
389-
let mut binary_names: IndexSet<OsString> = IndexSet::new();
394+
let mut binary_names: IndexMap<OsString, Option<VersionSpec>> = IndexMap::new();
390395
for binary_spec in &binary_specs {
391396
binary_names.insert(
392397
format!(
@@ -397,6 +402,10 @@ impl InterpreterConstraints {
397402
suffix = binary_spec.suffix.unwrap_or("")
398403
)
399404
.into(),
405+
Some(VersionSpec::MajorMinor(
406+
binary_spec.major,
407+
binary_spec.minor,
408+
)),
400409
);
401410
}
402411
for binary_spec in &binary_specs {
@@ -407,10 +416,11 @@ impl InterpreterConstraints {
407416
major = binary_spec.major
408417
)
409418
.into(),
419+
Some(VersionSpec::Major(binary_spec.major)),
410420
);
411421
}
412422
for binary_spec in &binary_specs {
413-
binary_names.insert(binary_spec.name.into());
423+
binary_names.insert(binary_spec.name.into(), None);
414424
}
415425
binary_names
416426
}
@@ -422,9 +432,11 @@ impl InterpreterConstraints {
422432
) -> anyhow::Result<impl Iterator<Item = PathBuf>> {
423433
let (python, path, known_paths) = search_path.into_parts()?;
424434
let binary_names = if let Some(python) = python {
425-
indexset! {python}
435+
vec![python]
426436
} else {
427437
self.calculate_compatible_binary_names(selection_strategy)
438+
.into_keys()
439+
.collect()
428440
};
429441
Ok(PythonExeIter {
430442
known_paths,
@@ -522,6 +534,7 @@ mod tests {
522534
use std::ffi::OsStr;
523535
use std::str::FromStr;
524536

537+
use indexmap::IndexSet;
525538
use pep440_rs::VersionSpecifiers;
526539

527540
use crate::constraints::{InterpreterConstraint, InterpreterImplementation};
@@ -602,7 +615,10 @@ mod tests {
602615
#[test]
603616
fn test_interpreter_constraints_binary_names_all_default_order() {
604617
let ics = InterpreterConstraints::try_from::<&str>(&[]).unwrap();
605-
let binary_names = ics.calculate_compatible_binary_names(SelectionStrategy::Oldest);
618+
let binary_names = ics
619+
.calculate_compatible_binary_names(SelectionStrategy::Oldest)
620+
.into_keys()
621+
.collect::<IndexSet<_>>();
606622

607623
assert_eq!(&["python2.7", "pypy2.7"], &binary_names[0..2]);
608624

@@ -646,7 +662,10 @@ mod tests {
646662
#[test]
647663
fn test_interpreter_constraints_binary_names_all_newest_first() {
648664
let ics = InterpreterConstraints::try_from::<&str>(&[]).unwrap();
649-
let binary_names = ics.calculate_compatible_binary_names(SelectionStrategy::Newest);
665+
let binary_names = ics
666+
.calculate_compatible_binary_names(SelectionStrategy::Newest)
667+
.into_keys()
668+
.collect::<IndexSet<_>>();
650669

651670
assert!(
652671
binary_names.get_index_of(os_str("python3.15"))
@@ -705,6 +724,8 @@ mod tests {
705724
"pypy",
706725
],
707726
ics.calculate_compatible_binary_names(SelectionStrategy::Newest)
727+
.into_keys()
728+
.collect::<Vec<_>>()
708729
.as_slice()
709730
);
710731
assert_eq!(
@@ -722,6 +743,8 @@ mod tests {
722743
"python",
723744
],
724745
ics.calculate_compatible_binary_names(SelectionStrategy::Oldest)
746+
.into_keys()
747+
.collect::<Vec<_>>()
725748
.as_slice()
726749
);
727750
}

crates/interpreter/src/lib.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@ mod platform;
1313
mod search_path;
1414
mod tag;
1515

16-
pub use constraints::{InterpreterConstraint, InterpreterConstraints, SelectionStrategy};
16+
pub use constraints::{
17+
InterpreterConstraint,
18+
InterpreterConstraints,
19+
SelectionStrategy,
20+
VersionSpec,
21+
};
1722
pub use interpreter::Interpreter;
1823
pub use platform::Platform;
1924
pub use search_path::SearchPath;

crates/pexrs/src/lib.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,9 +237,22 @@ fn prepare_venv<'a>(
237237
#[cfg(unix)]
238238
if let Some(sh_boot_seed_dir) = sh_boot_seed_dir {
239239
fs_err::create_dir_all(&sh_boot_seed_dir)?;
240+
let python = venv_interpreter.most_specific_exe_name();
241+
// N.B.: This symlink is probed by the --sh-boot script to confirm the venv is still
242+
// linked to an existing base Python (no uninstalls or upgrades).
243+
platform::unix::symlink(
244+
venv_interpreter
245+
.clone()
246+
.resolve_base_interpreter(&mut pex.scripts()?)?
247+
.path,
248+
sh_boot_seed_dir.join(format!("base-{python}")),
249+
false,
250+
)?;
251+
// N.B.: This is what the --sh-boot script executes after the probe for venv viability
252+
// succeeds.
240253
platform::unix::symlink(
241254
venv_dir.join("pex"),
242-
sh_boot_seed_dir.join(venv_interpreter.most_specific_exe_name()),
255+
sh_boot_seed_dir.join(format!("pex-{python}")),
243256
true,
244257
)?;
245258
}

src/commands/inject.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ fn inject_pex_dir(
5757
) -> anyhow::Result<()> {
5858
// Make sure we have a shebang early. This partially validates the pex to inject is a valid one
5959
// before expending too much effort copying files below.
60-
let shebang = if let Some(sh_boot_shebang) = sh_boot_shebang(pex.path, true)? {
60+
let shebang = if let Some(sh_boot_shebang) =
61+
sh_boot_shebang(pex.path, pex.info.venv_hermetic_scripts, true)?
62+
{
6163
sh_boot_shebang
6264
} else {
6365
let original_main = pex.path.join("__main__.py");
@@ -192,7 +194,9 @@ fn inject_pex_zip(
192194
) -> anyhow::Result<()> {
193195
let zip_read_fp = File::open(pex.path)?;
194196
let mut src_zip = ZipArchive::new(&zip_read_fp)?;
195-
let prefix = if let Some(sh_boot_shebang) = sh_boot_shebang(pex.path, false)? {
197+
let prefix = if let Some(sh_boot_shebang) =
198+
sh_boot_shebang(pex.path, pex.info.venv_hermetic_scripts, false)?
199+
{
196200
Some(sh_boot_shebang.into_bytes())
197201
} else {
198202
let first_entry = src_zip.by_index(0)?;

0 commit comments

Comments
 (0)