Skip to content

Commit d4f926c

Browse files
committed
[ty] Support ephemeral uv virtual environments
1 parent 8d5655a commit d4f926c

File tree

2 files changed

+96
-26
lines changed

2 files changed

+96
-26
lines changed

crates/ty_python_semantic/src/module_resolver/resolver.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use std::iter::FusedIterator;
44
use std::str::Split;
55

66
use compact_str::format_compact;
7+
use indexmap::IndexSet;
78
use rustc_hash::{FxBuildHasher, FxHashSet};
89

910
use ruff_db::files::{File, FilePath, FileRootKind};
@@ -289,7 +290,7 @@ impl SearchPaths {
289290
virtual_env_path,
290291
error
291292
);
292-
vec![]
293+
IndexSet::default()
293294
};
294295

295296
match PythonEnvironment::new(
@@ -304,7 +305,7 @@ impl SearchPaths {
304305
}
305306
} else {
306307
tracing::debug!("No virtual environment found");
307-
vec![]
308+
IndexSet::default()
308309
}
309310
}
310311

crates/ty_python_semantic/src/site_packages.rs

Lines changed: 93 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use std::io;
1414
use std::num::NonZeroUsize;
1515
use std::ops::Deref;
1616

17+
use indexmap::IndexSet;
1718
use ruff_db::system::{System, SystemPath, SystemPathBuf};
1819
use ruff_python_ast::PythonVersion;
1920

@@ -35,7 +36,7 @@ impl PythonEnvironment {
3536

3637
// Attempt to inspect as a virtual environment first
3738
// TODO(zanieb): Consider avoiding the clone here by checking for `pyvenv.cfg` ahead-of-time
38-
match VirtualEnvironment::new(path.clone(), origin, system) {
39+
match VirtualEnvironment::new(path.clone(), system) {
3940
Ok(venv) => Ok(Self::Virtual(venv)),
4041
// If there's not a `pyvenv.cfg` marker, attempt to inspect as a system environment
4142
//
@@ -54,7 +55,7 @@ impl PythonEnvironment {
5455
pub(crate) fn site_packages_directories(
5556
&self,
5657
system: &dyn System,
57-
) -> SitePackagesDiscoveryResult<Vec<SystemPathBuf>> {
58+
) -> SitePackagesDiscoveryResult<IndexSet<SystemPathBuf>> {
5859
match self {
5960
Self::Virtual(env) => env.site_packages_directories(system),
6061
Self::System(env) => env.site_packages_directories(system),
@@ -111,12 +112,19 @@ pub(crate) struct VirtualEnvironment {
111112
/// This field will be `None` if so.
112113
version: Option<PythonVersion>,
113114
implementation: PythonImplementation,
115+
116+
/// If this virtual environment was created using uv,
117+
/// it may be an "ephemeral" virtual environment that dynamically adds the `site-packages`
118+
/// directories of its parent environment to `sys.path` at runtime.
119+
/// Newer versions of uv record the parent environment in the `pyvenv.cfg` file;
120+
/// we'll want to add the `site-packages` directories of the parent environment
121+
/// as search paths as well as the `site-packages` directories of this virtual environment.
122+
parent_environment: Option<Box<VirtualEnvironment>>,
114123
}
115124

116125
impl VirtualEnvironment {
117126
pub(crate) fn new(
118127
path: SysPrefixPath,
119-
origin: SysPrefixPathOrigin,
120128
system: &dyn System,
121129
) -> SitePackagesDiscoveryResult<Self> {
122130
fn pyvenv_cfg_line_number(index: usize) -> NonZeroUsize {
@@ -128,12 +136,14 @@ impl VirtualEnvironment {
128136

129137
let pyvenv_cfg = system
130138
.read_to_string(&pyvenv_cfg_path)
131-
.map_err(|io_err| SitePackagesDiscoveryError::NoPyvenvCfgFile(origin, io_err))?;
139+
.map_err(|io_err| SitePackagesDiscoveryError::NoPyvenvCfgFile(path.origin, io_err))?;
132140

133141
let mut include_system_site_packages = false;
134142
let mut base_executable_home_path = None;
135143
let mut version_info_string = None;
136144
let mut implementation = PythonImplementation::Unknown;
145+
let mut created_with_uv = false;
146+
let mut parent_environment = None;
137147

138148
// A `pyvenv.cfg` file *looks* like a `.ini` file, but actually isn't valid `.ini` syntax!
139149
// The Python standard-library's `site` module parses these files by splitting each line on
@@ -178,6 +188,8 @@ impl VirtualEnvironment {
178188
_ => PythonImplementation::Unknown,
179189
};
180190
}
191+
"uv" => created_with_uv = true,
192+
"extends-environment" => parent_environment = Some(value),
181193
_ => continue,
182194
}
183195
}
@@ -196,11 +208,35 @@ impl VirtualEnvironment {
196208
let base_executable_home_path = PythonHomePath::new(base_executable_home_path, system)
197209
.map_err(|io_err| {
198210
SitePackagesDiscoveryError::PyvenvCfgParseError(
199-
pyvenv_cfg_path,
211+
pyvenv_cfg_path.clone(),
200212
PyvenvCfgParseErrorKind::InvalidHomeValue(io_err),
201213
)
202214
})?;
203215

216+
let parent_environment = if created_with_uv {
217+
parent_environment
218+
.and_then(|sys_prefix| {
219+
SysPrefixPath::new(
220+
sys_prefix,
221+
SysPrefixPathOrigin::DerivedFromPyvenvCfg,
222+
system,
223+
)
224+
.and_then(|sys_prefix|Self::new(sys_prefix, system))
225+
.inspect_err(|err| {
226+
tracing::warn!(
227+
"Failed to resolve the parent virtual environment of this ephemeral uv virtual environment \
228+
from the `extends-environment` value specified in the `pyvenv.cfg` file at {pyvenv_cfg_path}. \
229+
Packages installed into the parent environment will not be resolved correctly. \
230+
Underlying error: {err}",
231+
);
232+
})
233+
.ok()
234+
})
235+
.map(Box::new)
236+
} else {
237+
None
238+
};
239+
204240
// but the `version`/`version_info` key is not read by the standard library,
205241
// and is provided under different keys depending on which virtual-environment creation tool
206242
// created the `pyvenv.cfg` file. Lenient parsing is appropriate here:
@@ -218,6 +254,7 @@ impl VirtualEnvironment {
218254
include_system_site_packages,
219255
version,
220256
implementation,
257+
parent_environment,
221258
};
222259

223260
tracing::trace!("Resolved metadata for virtual environment: {metadata:?}");
@@ -230,21 +267,38 @@ impl VirtualEnvironment {
230267
pub(crate) fn site_packages_directories(
231268
&self,
232269
system: &dyn System,
233-
) -> SitePackagesDiscoveryResult<Vec<SystemPathBuf>> {
270+
) -> SitePackagesDiscoveryResult<IndexSet<SystemPathBuf>> {
234271
let VirtualEnvironment {
235272
root_path,
236273
base_executable_home_path,
237274
include_system_site_packages,
238275
implementation,
239276
version,
277+
parent_environment,
240278
} = self;
241279

242-
let mut site_packages_directories = vec![site_packages_directory_from_sys_prefix(
243-
root_path,
244-
*version,
245-
*implementation,
246-
system,
247-
)?];
280+
let mut site_packages_directories =
281+
IndexSet::from([site_packages_directory_from_sys_prefix(
282+
root_path,
283+
*version,
284+
*implementation,
285+
system,
286+
)?]);
287+
288+
if let Some(parent_env_site_packages) = parent_environment.as_deref() {
289+
match parent_env_site_packages.site_packages_directories(system) {
290+
Ok(parent_environment_site_packages) => {
291+
site_packages_directories.extend(parent_environment_site_packages);
292+
}
293+
Err(err) => {
294+
tracing::warn!(
295+
"Failed to resolve the site-packages directories of this ephemeral uv virtual environment's \
296+
parent environment. Packages installed into the parent environment will not be resolved correctly. \
297+
Underlying error: {err}"
298+
);
299+
}
300+
}
301+
}
248302

249303
if *include_system_site_packages {
250304
let system_sys_prefix =
@@ -261,7 +315,7 @@ impl VirtualEnvironment {
261315
system,
262316
) {
263317
Ok(site_packages_directory) => {
264-
site_packages_directories.push(site_packages_directory);
318+
site_packages_directories.insert(site_packages_directory);
265319
}
266320
Err(error) => tracing::warn!(
267321
"{error}. System site-packages will not be used for module resolution."
@@ -309,15 +363,15 @@ impl SystemEnvironment {
309363
pub(crate) fn site_packages_directories(
310364
&self,
311365
system: &dyn System,
312-
) -> SitePackagesDiscoveryResult<Vec<SystemPathBuf>> {
366+
) -> SitePackagesDiscoveryResult<IndexSet<SystemPathBuf>> {
313367
let SystemEnvironment { root_path } = self;
314368

315-
let site_packages_directories = vec![site_packages_directory_from_sys_prefix(
369+
let site_packages_directories = IndexSet::from([site_packages_directory_from_sys_prefix(
316370
root_path,
317371
None,
318372
PythonImplementation::Unknown,
319373
system,
320-
)?];
374+
)?]);
321375

322376
tracing::debug!(
323377
"Resolved site-packages directories for this environment are: {site_packages_directories:?}"
@@ -550,12 +604,12 @@ impl SysPrefixPath {
550604
if cfg!(target_os = "windows") {
551605
Some(Self {
552606
inner: path.to_path_buf(),
553-
origin: SysPrefixPathOrigin::Derived,
607+
origin: SysPrefixPathOrigin::DerivedFromPyvenvCfg,
554608
})
555609
} else {
556610
path.parent().map(|path| Self {
557611
inner: path.to_path_buf(),
558-
origin: SysPrefixPathOrigin::Derived,
612+
origin: SysPrefixPathOrigin::DerivedFromPyvenvCfg,
559613
})
560614
}
561615
}
@@ -575,13 +629,22 @@ impl fmt::Display for SysPrefixPath {
575629
}
576630
}
577631

632+
/// Enumeration of sources a `sys.prefix` path can come from.
578633
#[derive(Debug, PartialEq, Eq, Copy, Clone)]
579634
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
580635
pub enum SysPrefixPathOrigin {
636+
/// The `sys.prefix` path came from a `--python` CLI flag
581637
PythonCliFlag,
638+
/// The `sys.prefix` path came from the `VIRTUAL_ENV` environment variable
582639
VirtualEnvVar,
640+
/// The `sys.prefix` path came from the `CONDA_PREFIX` environment variable
583641
CondaPrefixVar,
584-
Derived,
642+
/// The `sys.prefix` path was derived from a value in a `pyvenv.cfg` file:
643+
/// either the value associated with the `home` key
644+
/// or the value associated with the `extends-environment` key
645+
DerivedFromPyvenvCfg,
646+
/// A `.venv` directory was found in the current working directory,
647+
/// and the `sys.prefix` path is the path to that virtual environment.
585648
LocalVenv,
586649
}
587650

@@ -591,7 +654,7 @@ impl SysPrefixPathOrigin {
591654
pub(crate) fn must_be_virtual_env(self) -> bool {
592655
match self {
593656
Self::LocalVenv | Self::VirtualEnvVar => true,
594-
Self::PythonCliFlag | Self::Derived | Self::CondaPrefixVar => false,
657+
Self::PythonCliFlag | Self::DerivedFromPyvenvCfg | Self::CondaPrefixVar => false,
595658
}
596659
}
597660
}
@@ -602,7 +665,7 @@ impl Display for SysPrefixPathOrigin {
602665
Self::PythonCliFlag => f.write_str("`--python` argument"),
603666
Self::VirtualEnvVar => f.write_str("`VIRTUAL_ENV` environment variable"),
604667
Self::CondaPrefixVar => f.write_str("`CONDA_PREFIX` environment variable"),
605-
Self::Derived => f.write_str("derived `sys.prefix` path"),
668+
Self::DerivedFromPyvenvCfg => f.write_str("derived `sys.prefix` path"),
606669
Self::LocalVenv => f.write_str("local virtual environment"),
607670
}
608671
}
@@ -901,11 +964,14 @@ mod tests {
901964

902965
if self_venv.system_site_packages {
903966
assert_eq!(
904-
&site_packages_directories,
967+
site_packages_directories.as_slice(),
905968
&[expected_venv_site_packages, expected_system_site_packages]
906969
);
907970
} else {
908-
assert_eq!(&site_packages_directories, &[expected_venv_site_packages]);
971+
assert_eq!(
972+
site_packages_directories.as_slice(),
973+
&[expected_venv_site_packages]
974+
);
909975
}
910976
}
911977

@@ -946,7 +1012,10 @@ mod tests {
9461012
))
9471013
};
9481014

949-
assert_eq!(&site_packages_directories, &[expected_site_packages]);
1015+
assert_eq!(
1016+
site_packages_directories.as_slice(),
1017+
&[expected_site_packages]
1018+
);
9501019
}
9511020
}
9521021

0 commit comments

Comments
 (0)