1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
use std::collections::{BTreeMap, BTreeSet};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::SystemTime;
use fs_err as fs;
use itertools::Itertools;
use rustc_hash::FxHashMap;
use tracing::{debug, instrument};
use uv_distribution_filename::WheelFilename;
use uv_fs::Simplified;
use uv_fs::link::{CopyLocks, LinkOptions, OnExistingDirectory, link_dir};
use uv_preview::{Preview, PreviewFeature};
use uv_warnings::warn_user;
use crate::Error;
use crate::wheel::ValidatedWheel;
pub use uv_fs::link::LinkMode;
/// Shared state for concurrent wheel installations.
#[derive(Debug, Default)]
pub struct InstallState {
/// Directory-level locks to prevent concurrent write corruption.
locks: CopyLocks,
/// Top level files and directories in site-packages, stored as relative path, and wheels they
/// are from, with the absolute paths in the unpacked wheel.
site_packages_paths: Mutex<FxHashMap<PathBuf, BTreeSet<(WheelFilename, PathBuf)>>>,
/// Preview settings for feature flags.
preview: Preview,
}
impl InstallState {
/// Create a new `InstallState` with the given preview settings.
pub fn new(preview: Preview) -> Self {
Self {
locks: CopyLocks::default(),
site_packages_paths: Mutex::new(FxHashMap::default()),
preview,
}
}
/// Get the underlying copy locks for use with [`uv_fs::link::link_dir`] functions.
fn copy_locks(&self) -> &CopyLocks {
&self.locks
}
/// Register which package installs which (top level) path.
///
/// This is later used warn when different files at the same path exist in multiple packages.
///
/// The first non-self argument is the target path relative to site-packages, the second is the
/// source path in the unpacked wheel.
fn register_installed_path(&self, relative: &Path, absolute: &Path, wheel: &WheelFilename) {
debug_assert!(!relative.is_absolute());
debug_assert!(absolute.is_absolute());
// Only register top level entries, these are the only ones we have reliably as cloning
// a directory on macOS traverses outside our code.
if relative.components().count() != 1 {
return;
}
self.site_packages_paths
.lock()
.unwrap()
.entry(relative.to_path_buf())
.or_default()
.insert((wheel.clone(), absolute.to_path_buf()));
}
/// Warn when the same file with different contents exists in multiple packages.
///
/// The intent is to detect different variants of the same package installed over each other,
/// or different packages using the same top-level module name, which cause non-deterministic
/// failures only surfacing at runtime. See <https://github.com/astral-sh/uv/pull/13437> for a
/// list of cases.
///
/// The check has some false negatives. It is rather too lenient than too strict, and includes
/// support for namespace packages that include the same `__init__.py` file, e.g., gpu-a and
/// gpu-b both including the same `gpu/__init__.py`.
///
/// We assume that all wheels of a package have the same module(s), so a conflict between
/// installing two unpacked wheels is a conflict between two packages.
///
/// # Performance
///
/// When there are no namespace packages, this method is a Mutex lock and a hash map iteration.
///
/// When there are namespace packages, we only traverse into directories shared by at least two
/// packages. For example, for namespace packages gpu-a, gpu-b, and gpu-c with
/// `gpu/a/__init__.py`, `gpu/b/__init__.py`, and `gpu/c/__init__.py` respectively, we only
/// need to read the `gpu` directory. If there is a deeper shared directory, we only recurse
/// down to this directory. As packages without conflicts generally do not share many
/// directories, we do not recurse far.
///
/// For each directory, we analyze all packages sharing the directory at the same time, reading
/// the directory in each unpacked wheel only once. Effectively, we perform a parallel directory
/// walk with early exit.
///
/// We avoid reading the actual file contents and assume they are the same when their file
/// length matches. This also excludes the same empty `__init__.py` files being reported as
/// conflicting.
pub fn warn_package_conflicts(self) -> Result<(), io::Error> {
// This warning is currently in preview.
if !self
.preview
.is_enabled(PreviewFeature::DetectModuleConflicts)
{
return Ok(());
}
for (relative, wheels) in &*self.site_packages_paths.lock().unwrap() {
// Fast path: Only one package is using this module name, no conflicts.
let mut wheel_iter = wheels.iter();
let Some(first_wheel) = wheel_iter.next() else {
debug_assert!(false, "at least one wheel");
continue;
};
if wheel_iter.next().is_none() {
continue;
}
// TODO(konsti): This assumes a path is either a file or a directory in all wheels.
let file_type = fs_err::metadata(&first_wheel.1)?.file_type();
if file_type.is_file() {
// Handle conflicts between files directly in site-packages without a module
// directory enclosing them.
let files: BTreeSet<(&WheelFilename, u64)> = wheels
.iter()
.map(|(wheel, absolute)| Ok((wheel, absolute.metadata()?.len())))
.collect::<Result<_, io::Error>>()?;
Self::warn_file_conflict(relative, &files);
} else if file_type.is_dir() {
// Don't early return if the method returns true, so we show warnings for each
// top-level module.
Self::warn_directory_conflict(relative, wheels)?;
} else {
// We don't expect any other file type, but it's ok if this check has false
// negatives.
}
}
Ok(())
}
/// Analyze a directory for conflicts.
///
/// If there are any non-identical files (checked by size) included in more than one wheel,
/// report this file and return.
///
/// If there are any directories included in more than one wheel, recurse to analyze whether
/// the directories contain conflicting files.
///
/// Returns `true` if a warning was emitted.
fn warn_directory_conflict(
directory: &Path,
wheels: &BTreeSet<(WheelFilename, PathBuf)>,
) -> Result<bool, io::Error> {
// The files in the directory, as paths relative to the site-packages, with their origin and
// size.
let mut files: BTreeMap<PathBuf, BTreeSet<(&WheelFilename, u64)>> = BTreeMap::default();
// The directories in the directory, as paths relative to the site-packages, with their
// origin and absolute path.
let mut subdirectories: BTreeMap<PathBuf, BTreeSet<(WheelFilename, PathBuf)>> =
BTreeMap::default();
// Read the shared directory in each unpacked wheel.
for (wheel, absolute) in wheels {
for dir_entry in fs_err::read_dir(absolute)? {
let dir_entry = dir_entry?;
let relative = directory.join(dir_entry.file_name());
let file_type = dir_entry.file_type()?;
if file_type.is_file() {
files
.entry(relative)
.or_default()
.insert((wheel, dir_entry.metadata()?.len()));
} else if file_type.is_dir() {
subdirectories
.entry(relative)
.or_default()
.insert((wheel.clone(), dir_entry.path()));
} else {
// We don't expect any other file type, but it's ok if this check has false
// negatives.
}
}
}
for (file, file_wheels) in files {
if Self::warn_file_conflict(&file, &file_wheels) {
return Ok(true);
}
}
for (subdirectory, subdirectory_wheels) in subdirectories {
if subdirectory_wheels.len() == 1 {
continue;
}
// If there are directories shared between multiple wheels, recurse to check them
// for shared files.
if Self::warn_directory_conflict(&subdirectory, &subdirectory_wheels)? {
return Ok(true);
}
}
Ok(false)
}
/// Check if all files are the same size, if so assume they are identical.
///
/// It's unlikely that two modules overlap with different contents but their files all have
/// the same length, so we use this heuristic in this performance critical path to avoid
/// reading potentially large files.
fn warn_file_conflict(file: &Path, file_wheels: &BTreeSet<(&WheelFilename, u64)>) -> bool {
let Some((_, file_len)) = file_wheels.first() else {
debug_assert!(false, "Always at least one element");
return false;
};
if !file_wheels
.iter()
.any(|(_, file_len_other)| file_len_other != file_len)
{
return false;
}
let packages = file_wheels
.iter()
.map(|(wheel_filename, _file_len)| {
format!("* {} ({})", wheel_filename.name, wheel_filename)
})
.join("\n");
warn_user!(
"The file `{}` is provided by more than one package, \
which causes an install race condition and can result in a broken module. \
Packages containing the file:\n{}",
file.user_display(),
packages
);
// Assumption: There is generally two packages that have a conflict. The output is
// more helpful with a single message that calls out the packages
// rather than being comprehensive about the conflicting files.
true
}
}
/// Extract a wheel by linking all of its files into site packages.
#[instrument(skip_all)]
pub(crate) fn link_wheel_files(
link_mode: LinkMode,
site_packages: impl AsRef<Path>,
wheel: &ValidatedWheel<'_>,
state: &InstallState,
filename: &WheelFilename,
) -> Result<(), Error> {
let wheel = wheel.as_path();
let site_packages = site_packages.as_ref();
register_installed_paths(wheel, state, filename)?;
// The `RECORD` file is modified during installation, so it needs a real
// copy rather than a link back to the cache.
let options = LinkOptions::new(link_mode)
.with_mutable_copy_filter(|p: &Path| p.ends_with("RECORD"))
.with_copy_locks(state.copy_locks())
.with_on_existing_directory(OnExistingDirectory::Merge);
let used_link_mode = link_dir(wheel, site_packages, &options)?;
if used_link_mode == LinkMode::Clone {
// The directory mtime is not updated when cloning and the mtime is
// used by CPython's import mechanisms to determine if it should look
// for new packages in a directory. Force an update so packages are
// importable without manual cache invalidation.
//
// <https://github.com/python/cpython/blob/8336cb2b6f428246803b02a4e97fce49d0bb1e09/Lib/importlib/_bootstrap_external.py#L1601>
update_site_packages_mtime(site_packages);
}
Ok(())
}
/// Update the mtime of the site-packages directory to the current time.
fn update_site_packages_mtime(site_packages: &Path) {
let now = SystemTime::now();
match fs::File::open(site_packages) {
Ok(dir) => {
if let Err(err) = dir.set_modified(now) {
debug!(
"Failed to update mtime for {}: {err}",
site_packages.display()
);
}
}
Err(err) => debug!(
"Failed to open {} to update mtime: {err}",
site_packages.display()
),
}
}
/// Register top-level wheel paths for conflict detection.
fn register_installed_paths(
wheel: &Path,
state: &InstallState,
filename: &WheelFilename,
) -> Result<(), Error> {
for entry in fs::read_dir(wheel)? {
let entry = entry?;
let path = entry.path();
let relative = PathBuf::from(entry.file_name());
state.register_installed_path(&relative, &path, filename);
}
Ok(())
}