Skip to content

Commit 85eed2b

Browse files
authored
feat(venv): Create a relocatable venv shim (#546)
Related to #522 and associated issues. This PR introduces a new venv tool, what for lack of better words I'm calling the "shim". While standard interpreters do support the use of relative paths to the interpreter in `pyvenv.cfg` -- for instance `home = ./bin/python` is legal -- we still need a `bin/python` which is portable. Existing relocatable virtual environment solutions (uv, conda) still ultimately create both an absolute path reference in `pyvenv.cfg` and usually in the `bin/python` symlink to a specific interpreter on the filesystem. UV does this statically as is standard, Conda will also do this statically with an explicit update process as part of the conda unpack. We can't create relocatable symlinks, and we also can't dynamically correct static symlinks without continuing to have #339 as a problem. What we can do is dynamically identify a Python interpreter and hoodwink it with regards to its own path, causing it to load a virtualenv which is itself entirely relocatable. As with many other tools, the Python interpreter introspects its `argv[0]` to figure out its own nominal path and identify the home, see [2], [3] for the gory details. This means that when the interpreter is invoked via a symlink under normal execution, the "path" of the interpreter per `argv[0]` is the path of the symlink with respect to which `pyvenv.cfg` can be identified and the virtualenv activated automatically. The Darwin platform provides the `_NSGetExecutablePath` libc call which is an alternative mechanism for determining the "path" by which the interpreter is invoked. This PR introduces a shim tool which can be emplaced as the target of `bin/python`, which will consult the `pyvenv.cfg` of a conventional virtualenv to find the requested interpreter version, and will attempt to delegate to an identified interpreter in such a way as to make the interpreter believe that its path is that of the shim tool. On a conventional unix this is as simple as lying about the value of `argv[0]`, on Darwin the `PYTHONEXECUTABLE` environment flag must be set to make Python disbelieve the value of `_NSGetExecutablePath` which is harder to hoodwink. Using this tool, a relocatable virtualenv can be structured as follows ``` ./pyvenv.cfg # conventional ./bin/python -> ./aspect_venv_shim # customized "interpreter" ./bin/python3 -> ./python # conventional ./bin/python3.{N} -> ./python # conventional ./bin/aspect_venv_shim ./lib/python3.${N}/site-packages/... # conventional; standard contents ``` We should be able to create one of these venvs by updating our `uv` dependency, setting the `relocatable=True` flag when creating virtualenvs, and attempting to specify the custom interpreter path of `./bin/aspect_venv_shim` so that the "python" symlink and its siblings will enter this pipeline. Using this as our "interpreter" will allow us to pull venv creation forwards from runtime to a normal build action, and allow us to create conda-pack like structures which require no unpack post-processing directly from that venv. The downside of this approach is that as with other `exec` based Python launchers such as Pex or Bazel's `--run_under` it is likely to interfere with debugging tools that want to instrument a Python process, as the bootloader is not an interpreter and cannot be analyzed as such. ### Changes are visible to end-users: yes - Searched for relevant documentation and updated as needed: yes - Breaking change (forces users to change their own code or config): no - Suggested release notes appear below: no ### Test plan - [x] Built for MacOS; made a relocatable venv, customized the `pyvenv.cfg` to be `home = ./bin/python`, updated the `bin/python` link to be `bin/aspect_venv_shim`, confirmed that invoking the venv's python links did cause the venv to load. - [x] Built for Linux; made a relocatable venv, customized the `pyvenv.cfg` to be `home = ./bin/python`, updated the `bin/python` link to be `bin/aspect_venv_shim`, confirmed that invoking the venv's python links did cause the venv to load. ### Notes [1] https://discuss.python.org/t/interpreter-independent-isolated-virtual-environments/5378/53 [2] https://github.com/python/cpython/blob/main/Modules/getpath.py#L293 [3] https://github.com/python/cpython/blob/main/Modules/getpath.c#L774
1 parent 1448e6e commit 85eed2b

File tree

9 files changed

+253
-5
lines changed

9 files changed

+253
-5
lines changed

Cargo.Bazel.lock

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"checksum": "81a92b86099c7c9b831ce831b33ad24c552a0c97ef3d8dc37836db3d08529ec4",
2+
"checksum": "7c8cd2683de76ab9fae29a74881389aefee00f8d83f53bd006aba44b4dd3731d",
33
"crates": {
44
"addr2line 0.24.2": {
55
"name": "addr2line",
@@ -23131,6 +23131,33 @@
2313123131
"license_ids": [],
2313223132
"license_file": null
2313323133
},
23134+
"venv_shim 0.1.0": {
23135+
"name": "venv_shim",
23136+
"version": "0.1.0",
23137+
"package_url": "https://github.com/aspect-build/rules_py",
23138+
"repository": null,
23139+
"targets": [],
23140+
"library_target_name": null,
23141+
"common_attrs": {
23142+
"compile_data_glob": [
23143+
"**"
23144+
],
23145+
"deps": {
23146+
"common": [
23147+
{
23148+
"id": "miette 7.2.0",
23149+
"target": "miette"
23150+
}
23151+
],
23152+
"selects": {}
23153+
},
23154+
"edition": "2021",
23155+
"version": "0.1.0"
23156+
},
23157+
"license": "Apache 2",
23158+
"license_ids": [],
23159+
"license_file": null
23160+
},
2313423161
"version_check 0.9.5": {
2313523162
"name": "version_check",
2313623163
"version": "0.9.5",
@@ -26950,7 +26977,8 @@
2695026977
"workspace_members": {
2695126978
"py 0.1.0": "py/tools/py",
2695226979
"unpack_bin 0.1.0": "py/tools/unpack_bin",
26953-
"venv_bin 0.1.0": "py/tools/venv_bin"
26980+
"venv_bin 0.1.0": "py/tools/venv_bin",
26981+
"venv_shim 0.1.0": "py/tools/venv_shim"
2695426982
},
2695526983
"conditions": {
2695626984
"aarch64-apple-darwin": [

Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ members = [
44
"py/tools/py",
55
"py/tools/venv_bin",
66
"py/tools/unpack_bin",
7+
"py/tools/venv_shim",
78
]
89

910
[workspace.package]

MODULE.bazel

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,9 @@ crate.from_cargo(
8080
manifests = [
8181
"//:Cargo.toml",
8282
"//py/tools/py:Cargo.toml",
83-
"//py/tools/venv_bin:Cargo.toml",
8483
"//py/tools/unpack_bin:Cargo.toml",
84+
"//py/tools/venv_bin:Cargo.toml",
85+
"//py/tools/venv_shim:Cargo.toml",
8586
],
8687
)
8788
use_repo(crate, "crate_index")

WORKSPACE

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,8 +281,9 @@ crates_repository(
281281
manifests = [
282282
"//:Cargo.toml",
283283
"//py/tools/py:Cargo.toml",
284-
"//py/tools/venv_bin:Cargo.toml",
285284
"//py/tools/unpack_bin:Cargo.toml",
285+
"//py/tools/venv_bin:Cargo.toml",
286+
"//py/tools/venv_shim:Cargo.toml",
286287
],
287288
)
288289

e2e/smoke/WORKSPACE.bazel

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,9 @@ crates_repository(
3636
manifests = [
3737
"@aspect_rules_py//:Cargo.toml",
3838
"@aspect_rules_py//py/tools/py:Cargo.toml",
39-
"@aspect_rules_py//py/tools/venv_bin:Cargo.toml",
4039
"@aspect_rules_py//py/tools/unpack_bin:Cargo.toml",
40+
"@aspect_rules_py//py/tools/venv_bin:Cargo.toml",
41+
"@aspect_rules_py//py/tools/venv_shim:Cargo.toml",
4142
],
4243
)
4344

py/tools/venv_shim/BUILD.bazel

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
load("//tools/release:defs.bzl", "rust_binary")
2+
3+
rust_binary(
4+
name = "shim",
5+
srcs = [
6+
"src/main.rs",
7+
],
8+
deps = [
9+
"@crate_index//:miette",
10+
],
11+
)
12+
13+
alias(
14+
name = "venv_shim",
15+
actual = ":shim",
16+
visibility = [
17+
"//visibility:public",
18+
],
19+
)

py/tools/venv_shim/Cargo.toml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
[package]
2+
name = "venv_shim"
3+
version.workspace = true
4+
categories.workspace = true
5+
homepage.workspace = true
6+
repository.workspace = true
7+
license.workspace = true
8+
edition.workspace = true
9+
readme.workspace = true
10+
rust-version.workspace = true
11+
12+
[features]
13+
debug = []
14+
15+
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
16+
17+
[[bin]]
18+
name = "python_shim"
19+
path = "src/main.rs"
20+
21+
[dependencies]
22+
miette = { workspace = true }

py/tools/venv_shim/src/main.rs

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
use miette::miette;
2+
use std::env;
3+
use std::fs;
4+
use std::io::{self, BufRead};
5+
use std::os::unix::process::CommandExt;
6+
use std::path::{Path, PathBuf};
7+
use std::process::Command;
8+
9+
fn find_pyvenv_cfg(start_path: &Path) -> Option<PathBuf> {
10+
let parent = start_path.parent()?.parent()?;
11+
let cfg_path = parent.join("pyvenv.cfg");
12+
if cfg_path.exists() && cfg_path.is_file() {
13+
Some(cfg_path)
14+
} else {
15+
None
16+
}
17+
}
18+
19+
fn extract_pyvenv_version_info(cfg_path: &Path) -> Result<Option<String>, io::Error> {
20+
let file = fs::File::open(cfg_path)?;
21+
let reader = io::BufReader::new(file);
22+
for line in reader.lines() {
23+
let line = line?;
24+
if let Some((key, value)) = line.split_once("=") {
25+
let key = key.trim();
26+
let value = value.trim();
27+
if key == "version_info" {
28+
return Ok(Some(value.to_string()));
29+
}
30+
}
31+
}
32+
Ok(None)
33+
}
34+
35+
fn parse_version_info(version_str: &str) -> Option<String> {
36+
// To avoid pulling in the regex crate, we're gonna do this by hand.
37+
let parts: Vec<_> = version_str.split(".").collect();
38+
match parts[..] {
39+
[major, minor, ..] => Some(format!("{}.{}", major, minor)),
40+
_ => None,
41+
}
42+
}
43+
44+
fn compare_versions(version_from_cfg: &str, executable_path: &Path) -> bool {
45+
if let Some(file_name) = executable_path.file_name().and_then(|n| n.to_str()) {
46+
return file_name.ends_with(&format!("python{}", version_from_cfg));
47+
} else {
48+
false
49+
}
50+
}
51+
52+
fn find_python_executables(version_from_cfg: &str, exclude_dir: &Path) -> Option<Vec<PathBuf>> {
53+
let python_prefix = format!("python{}", version_from_cfg);
54+
let path_env = env::var_os("PATH")?;
55+
56+
let binaries: Vec<_> = env::split_paths(&path_env)
57+
.filter_map(|path_dir| {
58+
let potential_executable = path_dir.join(&python_prefix);
59+
if potential_executable.exists() && potential_executable.is_file() {
60+
Some(potential_executable)
61+
} else {
62+
None
63+
}
64+
})
65+
.filter(|potential_executable| potential_executable.parent() != Some(exclude_dir))
66+
.filter(|potential_executable| compare_versions(version_from_cfg, &potential_executable))
67+
.collect();
68+
69+
if binaries.len() > 0 {
70+
Some(binaries)
71+
} else {
72+
None
73+
}
74+
}
75+
76+
fn main() -> miette::Result<()> {
77+
let current_exe = env::current_exe().unwrap();
78+
let args: Vec<_> = env::args().collect();
79+
80+
#[cfg(feature = "debug")]
81+
println!("[aspect] Current executable path: {:?}", current_exe);
82+
83+
let pyvenv_cfg_path = match find_pyvenv_cfg(&current_exe) {
84+
Some(path) => {
85+
#[cfg(feature = "debug")]
86+
eprintln!("[aspect] Found pyvenv.cfg at: {:?}", path);
87+
path
88+
}
89+
None => {
90+
return Err(miette!("pyvenv.cfg not found one directory level up."));
91+
}
92+
};
93+
94+
let version_info_result = extract_pyvenv_version_info(&pyvenv_cfg_path).unwrap();
95+
let version_info = match version_info_result {
96+
Some(v) => {
97+
#[cfg(feature = "debug")]
98+
eprintln!("[aspect] version_info from pyvenv.cfg: {}", v);
99+
v
100+
}
101+
None => {
102+
return Err(miette!("version_info key not found in pyvenv.cfg."));
103+
}
104+
};
105+
106+
let target_python_version = match parse_version_info(&version_info) {
107+
Some(v) => {
108+
#[cfg(feature = "debug")]
109+
eprintln!("[aspect] Parsed target Python version (major.minor): {}", v);
110+
v
111+
}
112+
None => {
113+
return Err(miette!("Could not parse version_info as x.y."));
114+
}
115+
};
116+
117+
let exclude_dir = current_exe.parent().unwrap();
118+
if let Some(python_executables) = find_python_executables(&target_python_version, exclude_dir) {
119+
#[cfg(feature = "debug")]
120+
{
121+
eprintln!(
122+
"[aspect] Found potential Python interpreters in PATH with matching version:"
123+
);
124+
for exe in &python_executables {
125+
println!("[aspect] - {:?}", exe);
126+
}
127+
}
128+
129+
let interpreter_path = &python_executables[0];
130+
let exe_path = current_exe.to_string_lossy().into_owned();
131+
let exec_args = &args[1..];
132+
133+
#[cfg(feature = "debug")]
134+
eprintln!(
135+
"[aspect] Attempting to execute: {:?} with argv[0] as {:?} and args as {:?}",
136+
interpreter_path, exe_path, exec_args,
137+
);
138+
139+
let mut cmd = Command::new(interpreter_path);
140+
cmd.args(exec_args);
141+
142+
// Lie about the value of argv0 to hoodwink the interpreter as to its
143+
// location on Linux-based platforms.
144+
if cfg!(target_os = "linux") {
145+
cmd.arg0(&exe_path);
146+
}
147+
148+
// On MacOS however, there are facilities for asking the C runtime/OS
149+
// what the real name of the interpreter executable is, and that value
150+
// is preferred while argv[0] is ignored. So we need to use a different
151+
// mechanism to lie to the target interpreter about its own path.
152+
//
153+
// https://github.com/python/cpython/blob/68e72cf3a80362d0a2d57ff0c9f02553c378e537/Modules/getpath.c#L778
154+
// https://docs.python.org/3/using/cmdline.html#envvar-PYTHONEXECUTABLE
155+
if cfg!(target_os = "macos") {
156+
cmd.env("PYTHONEXECUTABLE", exe_path);
157+
}
158+
159+
let _ = cmd.exec();
160+
161+
return Ok(());
162+
} else {
163+
return Err(miette!(
164+
"No suitable Python interpreter found in PATH matching version '{}'.",
165+
&version_info,
166+
));
167+
}
168+
}

0 commit comments

Comments
 (0)