Skip to content

Commit 26fda2f

Browse files
committed
Patch sysconfig
1 parent f80ddf1 commit 26fda2f

9 files changed

Lines changed: 704 additions & 2 deletions

File tree

Cargo.lock

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

crates/uv-python/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ windows-result = { workspace = true }
7272
anyhow = { version = "1.0.89" }
7373
assert_fs = { version = "1.1.2" }
7474
indoc = { workspace = true }
75+
insta = { version = "1.40.0" }
7576
itertools = { version = "0.13.0" }
7677
temp-env = { version = "0.3.6" }
7778
tempfile = { workspace = true }

crates/uv-python/src/installation.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ impl PythonInstallation {
163163

164164
let installed = ManagedPythonInstallation::new(path)?;
165165
installed.ensure_externally_managed()?;
166+
installed.ensure_sysconfig_patched()?;
166167
installed.ensure_canonical_executables()?;
167168

168169
Ok(Self {

crates/uv-python/src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ pub use crate::discovery::{
88
find_python_installations, EnvironmentPreference, Error as DiscoveryError, PythonDownloads,
99
PythonNotFound, PythonPreference, PythonRequest, PythonSource, PythonVariant, VersionRequest,
1010
};
11-
pub use crate::environment::{InvalidEnvironment, InvalidEnvironmentKind, PythonEnvironment};
11+
pub use crate::environment::{InvalidEnvironmentKind, PythonEnvironment};
1212
pub use crate::implementation::ImplementationName;
1313
pub use crate::installation::{PythonInstallation, PythonInstallationKey};
1414
pub use crate::interpreter::{Error as InterpreterError, Interpreter};
@@ -39,6 +39,7 @@ mod prefix;
3939
#[cfg(windows)]
4040
mod py_launcher;
4141
mod python_version;
42+
mod sysconfig;
4243
mod target;
4344
mod version_files;
4445
mod virtualenv;

crates/uv-python/src/managed.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ use crate::libc::LibcDetectionError;
2525
use crate::platform::Error as PlatformError;
2626
use crate::platform::{Arch, Libc, Os};
2727
use crate::python_version::PythonVersion;
28-
use crate::{PythonRequest, PythonVariant};
28+
use crate::{sysconfig, PythonRequest, PythonVariant};
2929
#[derive(Error, Debug)]
3030
pub enum Error {
3131
#[error(transparent)]
@@ -40,6 +40,8 @@ pub enum Error {
4040
InvalidPythonVersion(String),
4141
#[error(transparent)]
4242
ExtractError(#[from] uv_extract::Error),
43+
#[error(transparent)]
44+
SysconfigError(#[from] sysconfig::Error),
4345
#[error("Failed to copy to: {0}", to.user_display())]
4446
CopyError {
4547
to: PathBuf,
@@ -491,6 +493,18 @@ impl ManagedPythonInstallation {
491493
Ok(())
492494
}
493495

496+
/// Ensure that the `sysconfig` data is patched to match the installation path.
497+
pub fn ensure_sysconfig_patched(&self) -> Result<(), Error> {
498+
if cfg!(unix) {
499+
sysconfig::update_sysconfig(
500+
self.path(),
501+
self.version().major(),
502+
self.version().minor(),
503+
)?;
504+
}
505+
Ok(())
506+
}
507+
494508
/// Create a link to the managed Python executable.
495509
///
496510
/// If the file already exists at the target path, an error will be returned.
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
#![allow(dead_code)]
2+
3+
use std::str::Chars;
4+
5+
pub(super) const EOF_CHAR: char = '\0';
6+
7+
/// A cursor represents a pointer in the source code.
8+
///
9+
/// Based on [`rustc`'s `Cursor`](https://github.com/rust-lang/rust/blob/d1b7355d3d7b4ead564dbecb1d240fcc74fff21b/compiler/rustc_lexer/src/cursor.rs)
10+
#[derive(Clone, Debug)]
11+
pub(super) struct Cursor<'src> {
12+
/// An iterator over the [`char`]'s of the source code.
13+
chars: Chars<'src>,
14+
15+
/// Stores the previous character for debug assertions.
16+
#[cfg(debug_assertions)]
17+
prev_char: char,
18+
}
19+
20+
impl<'src> Cursor<'src> {
21+
pub(super) fn new(source: &'src str) -> Self {
22+
Self {
23+
chars: source.chars(),
24+
#[cfg(debug_assertions)]
25+
prev_char: EOF_CHAR,
26+
}
27+
}
28+
29+
/// Returns the previous character. Useful for debug assertions.
30+
#[cfg(debug_assertions)]
31+
pub(super) const fn previous(&self) -> char {
32+
self.prev_char
33+
}
34+
35+
/// Peeks the next character from the input stream without consuming it.
36+
/// Returns [`EOF_CHAR`] if the position is past the end of the file.
37+
pub(super) fn first(&self) -> char {
38+
self.chars.clone().next().unwrap_or(EOF_CHAR)
39+
}
40+
41+
/// Peeks the second character from the input stream without consuming it.
42+
/// Returns [`EOF_CHAR`] if the position is past the end of the file.
43+
pub(super) fn second(&self) -> char {
44+
let mut chars = self.chars.clone();
45+
chars.next();
46+
chars.next().unwrap_or(EOF_CHAR)
47+
}
48+
49+
/// Returns the remaining text to lex.
50+
///
51+
/// Use [`Cursor::text_len`] to get the length of the remaining text.
52+
pub(super) fn rest(&self) -> &'src str {
53+
self.chars.as_str()
54+
}
55+
56+
/// Returns `true` if the cursor is at the end of file.
57+
pub(super) fn is_eof(&self) -> bool {
58+
self.chars.as_str().is_empty()
59+
}
60+
61+
/// Moves the cursor to the next character, returning the previous character.
62+
/// Returns [`None`] if there is no next character.
63+
pub(super) fn bump(&mut self) -> Option<char> {
64+
let prev = self.chars.next()?;
65+
66+
#[cfg(debug_assertions)]
67+
{
68+
self.prev_char = prev;
69+
}
70+
71+
Some(prev)
72+
}
73+
74+
pub(super) fn eat_char(&mut self, c: char) -> bool {
75+
if self.first() == c {
76+
self.bump();
77+
true
78+
} else {
79+
false
80+
}
81+
}
82+
83+
pub(super) fn eat_char2(&mut self, c1: char, c2: char) -> bool {
84+
let mut chars = self.chars.clone();
85+
if chars.next() == Some(c1) && chars.next() == Some(c2) {
86+
self.bump();
87+
self.bump();
88+
true
89+
} else {
90+
false
91+
}
92+
}
93+
94+
pub(super) fn eat_char3(&mut self, c1: char, c2: char, c3: char) -> bool {
95+
let mut chars = self.chars.clone();
96+
if chars.next() == Some(c1) && chars.next() == Some(c2) && chars.next() == Some(c3) {
97+
self.bump();
98+
self.bump();
99+
self.bump();
100+
true
101+
} else {
102+
false
103+
}
104+
}
105+
106+
pub(super) fn eat_if<F>(&mut self, mut predicate: F) -> Option<char>
107+
where
108+
F: FnMut(char) -> bool,
109+
{
110+
if predicate(self.first()) && !self.is_eof() {
111+
self.bump()
112+
} else {
113+
None
114+
}
115+
}
116+
117+
/// Eats symbols while predicate returns true or until the end of file is reached.
118+
#[inline]
119+
pub(super) fn eat_while(&mut self, mut predicate: impl FnMut(char) -> bool) {
120+
// It was tried making optimized version of this for eg. line comments, but
121+
// LLVM can inline all of this and compile it down to fast iteration over bytes.
122+
while predicate(self.first()) && !self.is_eof() {
123+
self.bump();
124+
}
125+
}
126+
127+
/// Skips the next `count` bytes.
128+
///
129+
/// ## Panics
130+
/// - If `count` is larger than the remaining bytes in the input stream.
131+
/// - If `count` indexes into a multi-byte character.
132+
pub(super) fn skip_bytes(&mut self, count: usize) {
133+
#[cfg(debug_assertions)]
134+
{
135+
self.prev_char = self.chars.as_str()[..count]
136+
.chars()
137+
.next_back()
138+
.unwrap_or('\0');
139+
}
140+
141+
self.chars = self.chars.as_str()[count..].chars();
142+
}
143+
144+
/// Skips to the end of the input stream.
145+
pub(super) fn skip_to_end(&mut self) {
146+
self.chars = "".chars();
147+
}
148+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
//! Patch `sysconfig` data in a Python installation.
2+
//!
3+
//! Inspired by: <https://github.com/bluss/sysconfigpatcher/blob/c1ebf8ab9274dcde255484d93ce0f1fd1f76a248/src/sysconfigpatcher.py#L137C1-L140C100>,
4+
//! available under the MIT license:
5+
//!
6+
//! ```text
7+
//! Copyright 2024 Ulrik Sverdrup "bluss"
8+
//!
9+
//! Permission is hereby granted, free of charge, to any person obtaining a copy of
10+
//! this software and associated documentation files (the "Software"), to deal in
11+
//! the Software without restriction, including without limitation the rights to
12+
//! use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
13+
//! the Software, and to permit persons to whom the Software is furnished to do so,
14+
//! subject to the following conditions:
15+
//!
16+
//! The above copyright notice and this permission notice shall be included in all
17+
//! copies or substantial portions of the Software.
18+
//!
19+
//! THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20+
//! IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
21+
//! FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
22+
//! COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
23+
//! IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
24+
//! CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
25+
//! ```
26+
27+
use std::io::Write;
28+
use std::path::{Path, PathBuf};
29+
use std::str::FromStr;
30+
use tracing::trace;
31+
32+
use crate::sysconfig::parser::{Error as ParseError, SysconfigData, Value};
33+
34+
mod cursor;
35+
mod parser;
36+
37+
/// Update the `sysconfig` data in a Python installation.
38+
pub(crate) fn update_sysconfig(install_root: &Path, major: u8, minor: u8) -> Result<(), Error> {
39+
// Find the `_sysconfigdata_` file in the Python installation.
40+
let real_prefix = std::path::absolute(install_root)?;
41+
let sysconfigdata = find_sysconfigdata(&real_prefix, major, minor)?;
42+
trace!(
43+
"Discovered `sysconfig` data at: {}",
44+
sysconfigdata.display()
45+
);
46+
47+
// Update the `_sysconfigdata_` file in-memory.
48+
let contents = std::fs::read_to_string(&sysconfigdata)?;
49+
let data = patch_sysconfigdata(&contents, &real_prefix)?;
50+
let contents = data.to_string_pretty()?;
51+
52+
// Write the updated `_sysconfigdata_` file.
53+
let mut file = std::fs::OpenOptions::new()
54+
.write(true)
55+
.truncate(true)
56+
.create(true)
57+
.open(&sysconfigdata)?;
58+
file.write_all(contents.as_bytes())?;
59+
file.sync_data()?;
60+
61+
Ok(())
62+
}
63+
64+
/// Find the `_sysconfigdata_` file in a Python installation.
65+
///
66+
/// For example, on macOS, returns `{real_prefix}/lib/python3.12/_sysconfigdata__darwin_darwin.py"`.
67+
fn find_sysconfigdata(real_prefix: &Path, major: u8, minor: u8) -> Result<PathBuf, Error> {
68+
// Find the `lib` directory in the Python installation.
69+
let lib = real_prefix
70+
.join("lib")
71+
.join(format!("python{major}.{minor}"));
72+
if !lib.exists() {
73+
return Err(Error::MissingLib);
74+
}
75+
76+
// Probe the `lib` directory for `_sysconfigdata_`.
77+
for entry in lib.read_dir()? {
78+
let entry = entry?;
79+
80+
if entry.path().extension().is_none_or(|ext| ext != "py") {
81+
continue;
82+
}
83+
84+
if !entry
85+
.path()
86+
.file_stem()
87+
.and_then(|stem| stem.to_str())
88+
.is_some_and(|stem| stem.starts_with("_sysconfigdata_"))
89+
{
90+
continue;
91+
}
92+
93+
let metadata = entry.metadata()?;
94+
if metadata.is_symlink() {
95+
continue;
96+
};
97+
98+
if metadata.is_file() {
99+
return Ok(entry.path());
100+
}
101+
}
102+
103+
Err(Error::MissingSysconfigdata)
104+
}
105+
106+
/// Patch the given `_sysconfigdata_` contents.
107+
fn patch_sysconfigdata(contents: &str, real_prefix: &Path) -> Result<SysconfigData, Error> {
108+
/// Update the `/install` prefix in a whitespace-separated string.
109+
fn update_prefix(s: &str, real_prefix: &Path) -> String {
110+
s.split_whitespace()
111+
.map(|part| {
112+
if let Some(rest) = part.strip_prefix("/install") {
113+
if rest.is_empty() {
114+
real_prefix.display().to_string()
115+
} else {
116+
real_prefix.join(&rest[1..]).display().to_string()
117+
}
118+
} else {
119+
part.to_string()
120+
}
121+
})
122+
.collect::<Vec<_>>()
123+
.join(" ")
124+
}
125+
126+
// Parse the `_sysconfigdata_` file.
127+
let mut data = SysconfigData::from_str(contents)?;
128+
129+
// Patch each value, as needed.
130+
let mut count = 0;
131+
for (key, value) in data.iter_mut() {
132+
let Value::String(value) = value else {
133+
continue;
134+
};
135+
let patched = update_prefix(value, real_prefix);
136+
if *value != patched {
137+
trace!("Updated `{key}` from `{value}` to `{patched}`");
138+
count += 1;
139+
*value = patched;
140+
}
141+
}
142+
143+
match count {
144+
0 => trace!("No updates required"),
145+
1 => trace!("Updated 1 value"),
146+
n => trace!("Updated {n} values"),
147+
}
148+
149+
Ok(data)
150+
}
151+
152+
#[derive(thiserror::Error, Debug)]
153+
pub enum Error {
154+
#[error(transparent)]
155+
Io(#[from] std::io::Error),
156+
#[error("Python installation is missing a `lib` directory")]
157+
MissingLib,
158+
#[error("Python installation is missing a `_sysconfigdata_` file")]
159+
MissingSysconfigdata,
160+
#[error(transparent)]
161+
Parse(#[from] ParseError),
162+
#[error(transparent)]
163+
Json(#[from] serde_json::Error),
164+
}

0 commit comments

Comments
 (0)