Copy entry points and Jupyter data directories into ephemeral environments (#14790)

This is an alternative to https://github.com/astral-sh/uv/pull/14788
which has the benefit that it addresses
https://github.com/astral-sh/uv/issues/13327 which would be an issue
even if we reverted #14447.

There are two changes here

1. We copy entry points into the ephemeral environment, and rewrite
their shebangs (or trampoline target) to ensure the ephemeral
environment is not bypassed.
2. We link `etc/jupyter` and `share/jupyter` data directories into the
ephemeral environment, this is in order to ensure the above doesn't
break Jupyter which unfortunately cannot find the `share` directory
otherwise. I'd love not to do this, as it seems brittle and we don't
have a motivating use-case beyond Jupyter. I've opened
https://github.com/jupyterlab/jupyterlab/issues/17716 upstream for
discussion, as there is a viable patch that could be made upstream to
resolve the problem. I've limited the fix to Jupyter directories so we
can remove it without breakage.

Closes https://github.com/astral-sh/uv/issues/14729
Closes https://github.com/astral-sh/uv/issues/13327
Closes https://github.com/astral-sh/uv/issues/14749

---------

Co-authored-by: Charlie Marsh <charlie.r.marsh@gmail.com>
This commit is contained in:
Zanie Blue 2025-07-22 07:11:05 -05:00 committed by GitHub
parent c1bf934721
commit 8bffa693b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 437 additions and 2 deletions

View File

@ -84,6 +84,8 @@ pub async fn read_to_string_transcode(path: impl AsRef<Path>) -> std::io::Result
/// junction at the same path.
///
/// Note that because junctions are used, the source must be a directory.
///
/// Changes to this function should be reflected in [`create_symlink`].
#[cfg(windows)]
pub fn replace_symlink(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Result<()> {
// If the source is a file, we can't create a junction
@ -138,6 +140,38 @@ pub fn replace_symlink(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io:
}
}
/// Create a symlink at `dst` pointing to `src`.
///
/// On Windows, this uses the `junction` crate to create a junction point.
///
/// Note that because junctions are used, the source must be a directory.
///
/// Changes to this function should be reflected in [`replace_symlink`].
#[cfg(windows)]
pub fn create_symlink(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Result<()> {
// If the source is a file, we can't create a junction
if src.as_ref().is_file() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"Cannot create a junction for {}: is not a directory",
src.as_ref().display()
),
));
}
junction::create(
dunce::simplified(src.as_ref()),
dunce::simplified(dst.as_ref()),
)
}
/// Create a symlink at `dst` pointing to `src`.
#[cfg(unix)]
pub fn create_symlink(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Result<()> {
fs_err::os::unix::fs::symlink(src.as_ref(), dst.as_ref())
}
#[cfg(unix)]
pub fn remove_symlink(path: impl AsRef<Path>) -> std::io::Result<()> {
fs_err::remove_file(path.as_ref())

View File

@ -41,6 +41,7 @@ const MAGIC_NUMBER_SIZE: usize = 4;
pub struct Launcher {
pub kind: LauncherKind,
pub python_path: PathBuf,
payload: Vec<u8>,
}
impl Launcher {
@ -109,11 +110,69 @@ impl Launcher {
String::from_utf8(buffer).map_err(|err| Error::InvalidPath(err.utf8_error()))?,
);
#[allow(clippy::cast_possible_truncation)]
let file_size = {
let raw_length = file
.seek(io::SeekFrom::End(0))
.map_err(|e| Error::InvalidLauncherSeek("size probe".into(), 0, e))?;
if raw_length > usize::MAX as u64 {
return Err(Error::InvalidDataLength(raw_length));
}
// SAFETY: Above we guarantee the length is less than uszie
raw_length as usize
};
// Read the payload
file.seek(io::SeekFrom::Start(0))
.map_err(|e| Error::InvalidLauncherSeek("rewind".into(), 0, e))?;
let payload_len =
file_size.saturating_sub(MAGIC_NUMBER_SIZE + PATH_LENGTH_SIZE + path_length);
let mut buffer = vec![0u8; payload_len];
file.read_exact(&mut buffer)
.map_err(|err| Error::InvalidLauncherRead("payload".into(), err))?;
Ok(Some(Self {
kind,
payload: buffer,
python_path: path,
}))
}
pub fn write_to_file(self, file: &mut File) -> Result<(), Error> {
let python_path = self.python_path.simplified_display().to_string();
if python_path.len() > MAX_PATH_LENGTH as usize {
return Err(Error::InvalidPathLength(
u32::try_from(python_path.len()).expect("path length already checked"),
));
}
let mut launcher: Vec<u8> = Vec::with_capacity(
self.payload.len() + python_path.len() + PATH_LENGTH_SIZE + MAGIC_NUMBER_SIZE,
);
launcher.extend_from_slice(&self.payload);
launcher.extend_from_slice(python_path.as_bytes());
launcher.extend_from_slice(
&u32::try_from(python_path.len())
.expect("file path should be smaller than 4GB")
.to_le_bytes(),
);
launcher.extend_from_slice(self.kind.magic_number());
file.write_all(&launcher)?;
Ok(())
}
#[must_use]
pub fn with_python_path(self, path: PathBuf) -> Self {
Self {
kind: self.kind,
payload: self.payload,
python_path: path,
}
}
}
/// The kind of trampoline launcher to create.
@ -177,6 +236,8 @@ pub enum Error {
Io(#[from] io::Error),
#[error("Only paths with a length up to 32KB are supported but found a length of {0} bytes")]
InvalidPathLength(u32),
#[error("Only data with a length up to usize is supported but found a length of {0} bytes")]
InvalidDataLength(u64),
#[error("Failed to parse executable path")]
InvalidPath(#[source] Utf8Error),
#[error("Failed to seek to {0} at offset {1}")]

View File

@ -78,6 +78,20 @@ impl EphemeralEnvironment {
)?;
Ok(())
}
/// Returns the path to the environment's scripts directory.
pub(crate) fn scripts(&self) -> &Path {
self.0.scripts()
}
/// Returns the path to the environment's Python executable.
pub(crate) fn sys_executable(&self) -> &Path {
self.0.interpreter().sys_executable()
}
pub(crate) fn sys_prefix(&self) -> &Path {
self.0.interpreter().sys_prefix()
}
}
/// A [`PythonEnvironment`] stored in the cache.

View File

@ -9,8 +9,9 @@ use anyhow::{Context, anyhow, bail};
use futures::StreamExt;
use itertools::Itertools;
use owo_colors::OwoColorize;
use thiserror::Error;
use tokio::process::Command;
use tracing::{debug, warn};
use tracing::{debug, trace, warn};
use url::Url;
use uv_cache::Cache;
@ -22,7 +23,7 @@ use uv_configuration::{
};
use uv_distribution_types::Requirement;
use uv_fs::which::is_executable;
use uv_fs::{PythonExt, Simplified};
use uv_fs::{PythonExt, Simplified, create_symlink};
use uv_installer::{SatisfiesResult, SitePackages};
use uv_normalize::{DefaultExtras, DefaultGroups, PackageName};
use uv_python::{
@ -1071,6 +1072,67 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
requirements_site_packages.escape_for_python(),
))?;
// N.B. The order here matters — earlier interpreters take precedence over the
// later ones.
for interpreter in [requirements_env.interpreter(), &base_interpreter] {
// Copy each entrypoint from the base environments to the ephemeral environment,
// updating the Python executable target to ensure they run in the ephemeral
// environment.
for entry in fs_err::read_dir(interpreter.scripts())? {
let entry = entry?;
if !entry.file_type()?.is_file() {
continue;
}
match copy_entrypoint(
&entry.path(),
&ephemeral_env.scripts().join(entry.file_name()),
interpreter.sys_executable(),
ephemeral_env.sys_executable(),
) {
Ok(()) => {}
// If the entrypoint already exists, skip it.
Err(CopyEntrypointError::Io(err))
if err.kind() == std::io::ErrorKind::AlreadyExists =>
{
trace!(
"Skipping copy of entrypoint `{}`: already exists",
&entry.path().display()
);
}
Err(err) => return Err(err.into()),
}
}
// Link data directories from the base environment to the ephemeral environment.
//
// This is critical for Jupyter Lab, which cannot operate without the files it
// writes to `<prefix>/share/jupyter`.
//
// See https://github.com/jupyterlab/jupyterlab/issues/17716
for dir in &["etc/jupyter", "share/jupyter"] {
let source = interpreter.sys_prefix().join(dir);
if !matches!(source.try_exists(), Ok(true)) {
continue;
}
if !source.is_dir() {
continue;
}
let target = ephemeral_env.sys_prefix().join(dir);
if let Some(parent) = target.parent() {
fs_err::create_dir_all(parent)?;
}
match create_symlink(&source, &target) {
Ok(()) => trace!(
"Created link for {} -> {}",
target.user_display(),
source.user_display()
),
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(err) => return Err(err.into()),
}
}
}
// Write the `sys.prefix` of the parent environment to the `extends-environment` key of the `pyvenv.cfg`
// file. This helps out static-analysis tools such as ty (see docs on
// `CachedEnvironment::set_parent_environment`).
@ -1669,3 +1731,92 @@ fn read_recursion_depth_from_environment_variable() -> anyhow::Result<u32> {
.parse::<u32>()
.with_context(|| format!("invalid value for {}", EnvVars::UV_RUN_RECURSION_DEPTH))
}
#[derive(Error, Debug)]
enum CopyEntrypointError {
#[error(transparent)]
Io(#[from] std::io::Error),
#[cfg(windows)]
#[error(transparent)]
Trampoline(#[from] uv_trampoline_builder::Error),
}
/// Create a copy of the entrypoint at `source` at `target`, if it has a Python shebang, replacing
/// the previous Python executable with a new one.
///
/// This is a no-op if the target already exists.
///
/// Note on Windows, the entrypoints do not use shebangs and require a rewrite of the trampoline.
#[cfg(unix)]
fn copy_entrypoint(
source: &Path,
target: &Path,
previous_executable: &Path,
python_executable: &Path,
) -> Result<(), CopyEntrypointError> {
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use fs_err::os::unix::fs::OpenOptionsExt;
let contents = fs_err::read_to_string(source)?;
let Some(contents) = contents
// Check for a relative path or relocatable shebang
.strip_prefix(
r#"#!/bin/sh
'''exec' "$(dirname -- "$(realpath -- "$0")")"/'python' "$0" "$@"
' '''
"#,
)
// Or an absolute path shebang
.or_else(|| contents.strip_prefix(&format!("#!{}\n", previous_executable.display())))
else {
// If it's not a Python shebang, we'll skip it
trace!(
"Skipping copy of entrypoint `{}`: does not start with expected shebang",
source.user_display()
);
return Ok(());
};
let contents = format!("#!{}\n{}", python_executable.display(), contents);
let mode = fs_err::metadata(source)?.permissions().mode();
let mut file = fs_err::OpenOptions::new()
.create_new(true)
.write(true)
.mode(mode)
.open(target)?;
file.write_all(contents.as_bytes())?;
trace!("Updated entrypoint at {}", target.user_display());
Ok(())
}
/// Create a copy of the entrypoint at `source` at `target`, if it's a Python script launcher,
/// replacing the target Python executable with a new one.
#[cfg(windows)]
fn copy_entrypoint(
source: &Path,
target: &Path,
_previous_executable: &Path,
python_executable: &Path,
) -> Result<(), CopyEntrypointError> {
use uv_trampoline_builder::Launcher;
let Some(launcher) = Launcher::try_from_path(source)? else {
return Ok(());
};
let launcher = launcher.with_python_path(python_executable.to_path_buf());
let mut file = fs_err::OpenOptions::new()
.create_new(true)
.write(true)
.open(target)?;
launcher.write_to_file(&mut file)?;
trace!("Updated entrypoint at {}", target.user_display());
Ok(())
}

View File

@ -1319,6 +1319,181 @@ fn run_with_pyvenv_cfg_file() -> Result<()> {
Ok(())
}
#[test]
fn run_with_overlay_interpreter() -> Result<()> {
let context = TestContext::new("3.12").with_filtered_exe_suffix();
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! { r#"
[project]
name = "foo"
version = "1.0.0"
requires-python = ">=3.8"
dependencies = ["anyio"]
[build-system]
requires = ["setuptools>=42"]
build-backend = "setuptools.build_meta"
[project.scripts]
main = "foo:main"
"#
})?;
let foo = context.temp_dir.child("src").child("foo");
foo.create_dir_all()?;
let init_py = foo.child("__init__.py");
init_py.write_str(indoc! { r#"
import sys
import shutil
from pathlib import Path
def show_python():
print(sys.executable)
def copy_entrypoint():
base = Path(sys.executable)
shutil.copyfile(base.with_name("main").with_suffix(base.suffix), sys.argv[1])
def main():
show_python()
if len(sys.argv) > 1:
copy_entrypoint()
"#
})?;
// The project's entrypoint should be rewritten to use the overlay interpreter.
uv_snapshot!(context.filters(), context.run().arg("--with").arg("iniconfig").arg("main").arg(context.temp_dir.child("main").as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
[CACHE_DIR]/builds-v0/[TMP]/python
----- stderr -----
Resolved 6 packages in [TIME]
Prepared 4 packages in [TIME]
Installed 4 packages in [TIME]
+ anyio==4.3.0
+ foo==1.0.0 (from file://[TEMP_DIR]/)
+ idna==3.6
+ sniffio==1.3.1
Resolved 1 package in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ iniconfig==2.0.0
");
#[cfg(unix)]
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
context.read("main"), @r##"
#![CACHE_DIR]/builds-v0/[TMP]/python
# -*- coding: utf-8 -*-
import sys
from foo import main
if __name__ == "__main__":
if sys.argv[0].endswith("-script.pyw"):
sys.argv[0] = sys.argv[0][:-11]
elif sys.argv[0].endswith(".exe"):
sys.argv[0] = sys.argv[0][:-4]
sys.exit(main())
"##
);
}
);
// The package, its dependencies, and the overlay dependencies should be available.
context
.run()
.arg("--with")
.arg("iniconfig")
.arg("python")
.arg("-c")
.arg("import foo; import anyio; import iniconfig")
.assert()
.success();
// When layering the project on top (via `--with`), the overlay interpreter also should be used.
uv_snapshot!(context.filters(), context.run().arg("--no-project").arg("--with").arg(".").arg("main"), @r"
success: true
exit_code: 0
----- stdout -----
[CACHE_DIR]/builds-v0/[TMP]/python
----- stderr -----
Resolved 4 packages in [TIME]
Prepared 1 package in [TIME]
Installed 4 packages in [TIME]
+ anyio==4.3.0
+ foo==1.0.0 (from file://[TEMP_DIR]/)
+ idna==3.6
+ sniffio==1.3.1
");
// Switch to a relocatable virtual environment.
context.venv().arg("--relocatable").assert().success();
// The project's entrypoint should be rewritten to use the overlay interpreter.
uv_snapshot!(context.filters(), context.run().arg("--with").arg("iniconfig").arg("main").arg(context.temp_dir.child("main").as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
[CACHE_DIR]/builds-v0/[TMP]/python
----- stderr -----
Resolved 6 packages in [TIME]
Audited 4 packages in [TIME]
Resolved 1 package in [TIME]
");
// The package, its dependencies, and the overlay dependencies should be available.
context
.run()
.arg("--with")
.arg("iniconfig")
.arg("python")
.arg("-c")
.arg("import foo; import anyio; import iniconfig")
.assert()
.success();
#[cfg(unix)]
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
context.read("main"), @r##"
#![CACHE_DIR]/builds-v0/[TMP]/python
# -*- coding: utf-8 -*-
import sys
from foo import main
if __name__ == "__main__":
if sys.argv[0].endswith("-script.pyw"):
sys.argv[0] = sys.argv[0][:-11]
elif sys.argv[0].endswith(".exe"):
sys.argv[0] = sys.argv[0][:-4]
sys.exit(main())
"##
);
}
);
// When layering the project on top (via `--with`), the overlay interpreter also should be used.
uv_snapshot!(context.filters(), context.run().arg("--no-project").arg("--with").arg(".").arg("main"), @r"
success: true
exit_code: 0
----- stdout -----
[CACHE_DIR]/builds-v0/[TMP]/python
----- stderr -----
Resolved 4 packages in [TIME]
");
Ok(())
}
#[test]
fn run_with_build_constraints() -> Result<()> {
let context = TestContext::new("3.9");