[ty] Refresh Python settings when virtual environments change (#28650)

## Summary

This PR is a first step towards updating the project's uv metadata when
the virtual environment changes. The scope of this PR is to add the
non-uv specific virtual environment file watching. That is, it adds the
necessary file watching handling to update the detected virtual
environment and its Python version when:

* a `pyenv.cfg` changes
* the default `.venv` or the explicitly specified `environment.python`
virtual environment gets created or deleted.


Unfortunately, this does require storing the path to the resolved
virtual environment in `ProgramSettings`. Otherwise, we'd have to
resolve it every time we see a related file watcher event. It's
unfortunate because it doesn't feel like a setting. But there isn't
really any better to carry forward this information.


I recommend reviewing this PR commit by commit.


## Test Plan

Native file-watching regression tests pass.
This commit is contained in:
Micha Reiser authored and GitHub committed 2026-09-27 18:48:09 +02:00
1 parent 85077a70e8
commit 226201fef0
19 files changed
+764 -332

No files matched your search

+4 -4
View File
@@ -462,7 +462,7 @@ import colorama
info: Searched in the following paths during module resolution:
info: 1. <temp_dir>/project (first-party code)
info: 2. vendored://stdlib (stdlib typeshed stubs vendored by ty)
info: 3. <temp_dir>/opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/lib/python3.13/site-packages (site-packages)
info: 3. <temp_dir>/opt/homebrew/Cellar/python@3.13/3.13.5/lib/python3.13/site-packages (site-packages)
info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment
error[unresolved-import]: Cannot resolve imported module `colorama`
@@ -473,7 +473,7 @@ import colorama
info: Searched in the following paths during module resolution:
info: 1. <temp_dir>/project (first-party code)
info: 2. vendored://stdlib (stdlib typeshed stubs vendored by ty)
info: 3. <temp_dir>/opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/lib/python3.13/site-packages (site-packages)
info: 3. <temp_dir>/opt/homebrew/Cellar/python@3.13/3.13.5/lib/python3.13/site-packages (site-packages)
info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment
Found 2 diagnostics
@@ -495,7 +495,7 @@ import colorama
info: Searched in the following paths during module resolution:
info: 1. <temp_dir>/project (first-party code)
info: 2. vendored://stdlib (stdlib typeshed stubs vendored by ty)
info: 3. <temp_dir>/opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/lib/python3.13/site-packages (site-packages)
info: 3. <temp_dir>/opt/homebrew/Cellar/python@3.13/3.13.5/lib/python3.13/site-packages (site-packages)
info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment
error[unresolved-import]: Cannot resolve imported module `colorama`
@@ -506,7 +506,7 @@ import colorama
info: Searched in the following paths during module resolution:
info: 1. <temp_dir>/project (first-party code)
info: 2. vendored://stdlib (stdlib typeshed stubs vendored by ty)
info: 3. <temp_dir>/opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/lib/python3.13/site-packages (site-packages)
info: 3. <temp_dir>/opt/homebrew/Cellar/python@3.13/3.13.5/lib/python3.13/site-packages (site-packages)
info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment
Found 2 diagnostics
File diff suppressed because it is too large. Load diff
+5 -11
View File
@@ -419,9 +419,7 @@ mod tests {
use ty_module_resolver::SearchPathSettings;
use ty_project::{Db as _, ProjectMetadata, SemanticDb as _};
use ty_python_core::ProgramFile;
use ty_python_core::platform::PythonPlatform;
use ty_python_core::program::{FallibleStrategy, ProgramSettings};
use ty_python_semantic::PythonVersionWithSource;
use ty_python_core::program::FallibleStrategy;
/// A way to create a simple single-file (named `main.py`) cursor test.
///
@@ -674,14 +672,10 @@ mod tests {
.to_search_paths(db.system(), db.vendored(), &FallibleStrategy)
.expect("valid search paths");
db.project().update_program(
&mut db,
ProgramSettings {
python_version: PythonVersionWithSource::default(),
python_platform: PythonPlatform::default(),
search_paths,
},
);
let project = db.project();
let mut settings = project.program_settings(&db).clone();
settings.search_paths = search_paths;
project.update_program(&mut db, settings);
db.files()
.try_add_root(&db, &project_root, FileRootKind::Project);
+4 -2
View File
@@ -834,10 +834,12 @@ impl SearchPaths {
let mut site_packages: Vec<_> = Vec::with_capacity(site_packages_paths.len());
for path in site_packages_paths {
// Resolve symlinks so site-packages outside the environment keeps its own file watch,
// and imports use the same path as the reported changes.
let path = canonicalize(path, system);
tracing::debug!("Adding site-packages search path `{path}`");
let path = strategy.fallback_opt(
SearchPath::site_packages(system, path.clone())
.map_err(SearchPathSettingsError::from),
SearchPath::site_packages(system, path).map_err(SearchPathSettingsError::from),
|err| {
tracing::debug!("Skipping invalid site-packages search-path: {err}");
},
+8 -15
View File
@@ -740,7 +740,6 @@ pub(crate) mod testing {
use ruff_python_ast::PythonVersion;
use ty_module_resolver::SearchPathSettings;
use ty_python_core::ProgramFile;
use ty_python_core::platform::PythonPlatform;
use ty_python_core::program::{FallibleStrategy, ProgramSettings};
#[cfg(feature = "testing")]
use ty_python_semantic::ProgramEnvironment;
@@ -803,11 +802,8 @@ pub(crate) mod testing {
db.files().try_add_root(&db, &root, FileRootKind::Project);
let program_settings = ProgramSettings {
python_version: PythonVersionWithSource::default(),
python_platform: PythonPlatform::default(),
search_paths,
};
let mut program_settings = ProgramSettings::empty(db.vendored());
program_settings.search_paths = search_paths;
let project = Project::from_metadata(
&db,
project,
@@ -821,16 +817,13 @@ pub(crate) mod testing {
#[cfg(feature = "testing")]
pub fn set_python_version(&mut self, python_version: PythonVersion) {
let program = self.project().program(self);
let settings = ProgramSettings {
python_version: PythonVersionWithSource {
source: ty_python_semantic::PythonVersionSource::Default,
version: python_version,
},
python_platform: program.python_platform(self).clone(),
search_paths: program.search_paths(self).clone(),
let project = self.project();
let mut settings = project.program_settings(self).clone();
settings.python_version = PythonVersionWithSource {
source: ty_python_semantic::PythonVersionSource::Default,
version: python_version,
};
self.project().update_program(self, settings);
project.update_program(self, settings);
}
}
+86 -10
View File
@@ -10,12 +10,12 @@ use ruff_db::files::{File, Files, system_path_to_file};
use ruff_db::system::{SystemPath, SystemPathBuf};
use rustc_hash::FxHashSet;
use ty_python_core::program::FallibleStrategy;
use ty_python_semantic::PythonEnvironment;
/// Represents the result of applying changes to the project database.
pub struct ChangeResult {
project_changed: bool,
project_sync_path: Option<SystemPathBuf>,
custom_stdlib_changed: bool,
changed_files: ChangedFiles,
}
@@ -32,11 +32,6 @@ impl ChangeResult {
self.project_sync_path.as_deref()
}
/// Returns `true` if the custom stdlib's VERSIONS file has changed.
pub fn custom_stdlib_changed(&self) -> bool {
self.custom_stdlib_changed
}
/// Returns the scripts whose environments may need synchronization after these file events.
///
/// Returns no scripts if the project was unindexed when the changes were applied.
@@ -99,6 +94,14 @@ impl ProjectDatabase {
let project = self.project();
let project_root = project.root(self).to_path_buf();
let configuration_paths = ConfigurationPaths::from_metadata(project.metadata(self));
let virtual_environment = project.program_settings(self).virtual_environment.clone();
let python_path = project
.metadata(self)
.configured_python_path(self.system())
.or_else(|| {
PythonEnvironment::virtual_environment_candidate(Some(&project_root), self.system())
.map(|(path, _)| SystemPath::absolute(path, self.system().current_directory()))
});
let program = self.project().program(self);
let custom_stdlib_versions_path = program
.custom_stdlib_search_path(self)
@@ -107,7 +110,6 @@ impl ProjectDatabase {
let mut result = ChangeResult {
project_changed: false,
project_sync_path: None,
custom_stdlib_changed: false,
changed_files: if project.file_set(self).is_lazy() {
ChangedFiles::Unindexed
} else {
@@ -125,6 +127,7 @@ impl ProjectDatabase {
let mut removed_paths = BTreeSet::default();
let mut reload_project = false;
let mut reload_project_files = false;
let mut refresh_program_settings = false;
// TODO: This should be removed once the incremental checker is ported
// over to the `ignore` crate, since the `ignore` crate will respect
// the settings provided in `create_walker`. ---AG
@@ -138,6 +141,12 @@ impl ProjectDatabase {
for change in changes {
tracing::debug!("Handling file watcher change event: {:?}", change);
refresh_program_settings |= affects_python_environment(
change,
virtual_environment.as_deref(),
python_path.as_deref(),
);
if let Some(path) = change.system_path() {
if configuration_paths.is_configuration(path, &project_root) {
File::sync_path(self, path);
@@ -190,7 +199,7 @@ impl ProjectDatabase {
}
if Some(path) == custom_stdlib_versions_path.as_deref() {
result.custom_stdlib_changed = true;
refresh_program_settings = true;
}
}
@@ -300,7 +309,7 @@ impl ProjectDatabase {
.as_ref()
.is_some_and(|versions_path| versions_path.starts_with(path))
{
result.custom_stdlib_changed = true;
refresh_program_settings = true;
}
if configuration_paths.may_contain_configuration(path, &project_root) {
@@ -383,7 +392,7 @@ impl ProjectDatabase {
removed_paths.clear();
}
if result.custom_stdlib_changed {
if refresh_program_settings {
let metadata = project.metadata(self);
let merged_options = metadata.to_merged_options();
match merged_options.to_program_settings(
@@ -488,3 +497,70 @@ impl ConfigurationPaths {
fn is_ignore_file(path: &SystemPath) -> bool {
matches!(path.file_name(), Some(".gitignore" | ".ignore"))
}
/// `pyvenv.cfg` records the environment's Python version and whether imports include system
/// site-packages. Recreating an environment can change both without changing ty's configuration.
///
/// `python_path` is the configured environment or interpreter path,
/// or a virtual environment path (e.g. `VIRTUAL_ENV` or the project's `.venv`).
///
/// uv writes `pyvenv.cfg` before creating `site-packages`. If these events arrive in separate
/// batches, creating `site-packages` must retry resolution. A watcher may only report the creation
/// of the `site-packages` parent `lib` or `lib/pythonX.Y` directory, so we also handle those
/// directories (`lib64` on some Unix systems, `Lib` on Windows). We match `site-packages` by name;
/// an unrelated directory only causes an extra refresh of the same settings.
///
/// Similar to `site-packages`, renaming a directory to `.venv` can change the inferred virtual
/// environment without an event for `pyvenv.cfg`. That's why we need to rediscover the virtual
/// environment when any ancestor path of a valid virtual environment location is created or deleted.
fn affects_python_environment(
change: &ChangeEvent,
virtual_environment: Option<&SystemPath>,
python_path: Option<&SystemPath>,
) -> bool {
let may_be_environment_root = |path: &SystemPath| {
virtual_environment == Some(path)
|| python_path.is_some_and(|python_path| python_path.starts_with(path))
};
match change {
ChangeEvent::Created { path, .. }
| ChangeEvent::Changed { path, .. }
| ChangeEvent::Deleted { path, .. }
if path.file_name() == Some("pyvenv.cfg") =>
{
path.parent().is_some_and(may_be_environment_root)
}
// The configured path can be an interpreter file as well as an environment directory.
ChangeEvent::Created { path, .. } | ChangeEvent::Deleted { path, .. }
if python_path.is_some_and(|python_path| python_path.starts_with(path))
|| virtual_environment.is_some_and(|environment| environment.starts_with(path)) =>
{
true
}
ChangeEvent::Created {
path,
kind: CreatedKind::Directory,
}
| ChangeEvent::Deleted {
path,
kind: DeletedKind::Directory | DeletedKind::Any,
} => {
if path.file_name() == Some("site-packages") {
return true;
}
let is_library =
|path: &SystemPath| matches!(path.file_name(), Some("lib" | "lib64" | "Lib"));
let library = if is_library(path) {
Some(path.as_path())
} else {
path.parent().filter(|parent| is_library(parent))
};
library
.and_then(SystemPath::parent)
.is_some_and(may_be_environment_root)
}
_ => false,
}
}
+7
View File
@@ -549,6 +549,13 @@ impl ProjectMetadata {
.chain(self.fallback_options.as_deref())
}
/// Returns the configured environment or interpreter path, without resolving the full merged options.
pub(crate) fn configured_python_path(&self, system: &dyn System) -> Option<SystemPathBuf> {
self.options_in_precedence_order(&self.options, self.uv_workspace_options.as_deref())
.find_map(|options| options.environment.as_ref()?.python.as_ref())
.map(|path| path.absolute(self.root(), system))
}
/// Loads the lower-precedence options from configuration files.
///
/// This includes:
@@ -306,6 +306,9 @@ impl Options {
python_version,
python_platform,
search_paths,
virtual_environment: python_environment
.filter(PythonEnvironment::is_virtual)
.map(|environment| environment.sys_prefix().to_path_buf()),
},
diagnostics,
))
+38 -10
View File
@@ -178,16 +178,43 @@ impl WatchPaths {
}
}
/// Watches are registered in project, module, then configuration order. On Linux, the last
/// registered watch determines the path reported for overlapping symlinks.
/// Watches are registered in project, Python environment, module, then configuration order.
/// On Linux, the last registered watch determines the path reported for overlapping symlinks.
///
/// Project roots and explicitly included paths come first because project files are discovered
/// by walking them. Module search paths come next so imports are reported relative to their
/// by walking them. A virtual environment outside the project needs its own watch because
/// `pyvenv.cfg` can change the inferred Python version and import search paths. Watching its
/// `site-packages` alone does not cover that file. System environment roots are excluded to
/// avoid recursively watching a system prefix such as `/usr`.
///
/// Module search paths follow the environment so imports are reported relative to their
/// search roots, rather than through symlinks inside the project. Configuration paths come last
/// so their events use the explicit paths checked for configuration changes.
///
/// # Known limitations
///
/// These limitations apply to virtual environments outside the project directory unless another
/// watch already covers the relevant paths. Environments inside the project are covered by the project watch.
///
/// - Creating an initially missing environment: If environment resolution fails, there is no
/// resolved root to add to the watch list. Supporting later creation would require choosing an
/// existing ancestor to watch from an unresolved `environment.python` path, which can name
/// either a directory or an interpreter. We accept this limitation to avoid additional path
/// discovery and watch-registration logic for environments that could not be resolved.
/// - Deleting and recreating an environment: Deleting the environment can remove its native
/// watch, so its recreation goes unnoticed. Watching a surviving parent would cover this, but
/// our watches are recursive: the parent could contain every environment in a shared Poetry
/// directory, or even be the user's home directory. We accept this limitation to avoid watching
/// those unrelated directories. A nonrecursive parent watch would need additional logic to
/// register the environment watch again after recreation.
#[salsa::tracked(returns(ref))]
pub fn watch_paths(db: &dyn Db, project: Project) -> WatchPaths {
let project_path = project.root(db);
let virtual_environment = project
.program_settings(db)
.virtual_environment
.as_deref()
.filter(|environment| !environment.starts_with(project_path));
// Watch both the project root and any paths provided by the user on the CLI (removing any redundant nested paths).
// This is necessary to observe changes to files that are outside the project root.
@@ -212,15 +239,16 @@ pub fn watch_paths(db: &dyn Db, project: Project) -> WatchPaths {
}
}
// The project watch covers search paths inside its root. Deduplicate the others so
// shared or nested search paths are registered once in stable order.
let unique_module_paths = ruff_db::system::deduplicate_nested_paths(
search_paths
.into_iter()
.filter(|path| !path.starts_with(project_path)),
);
// The project and virtual environment watches cover search paths inside their roots.
// Deduplicate the remaining paths so shared or nested search paths are registered once.
let unique_module_paths =
ruff_db::system::deduplicate_nested_paths(search_paths.into_iter().filter(|path| {
!path.starts_with(project_path)
&& virtual_environment.is_none_or(|environment| !path.starts_with(environment))
}));
let paths: Vec<_> = included_paths
.chain(virtual_environment)
.chain(unique_module_paths)
.chain(project.metadata(db).extra_configuration_paths())
.map(SystemPath::to_path_buf)
+1
View File
@@ -166,6 +166,7 @@ pub(crate) mod tests {
.context("Failed to write test files")?;
let program_settings = ProgramSettings {
virtual_environment: None,
python_version: PythonVersionWithSource {
version: self.python_version,
source: PythonVersionSource::default(),
+8 -1
View File
@@ -1,7 +1,7 @@
use crate::{Db, platform::PythonPlatform};
use ruff_db::files::File;
use ruff_db::system::SystemPath;
use ruff_db::system::{SystemPath, SystemPathBuf};
use ruff_db::vendored::VendoredFileSystem;
use ruff_python_ast::PythonVersion;
use ty_module_resolver::{ResolverEnvironment, SearchPaths};
@@ -30,6 +30,7 @@ impl<'db> Program<'db> {
python_version,
python_platform,
search_paths,
virtual_environment: _,
} = settings;
let resolver_environment =
@@ -59,6 +60,11 @@ pub struct ProgramSettings {
pub python_version: PythonVersionWithSource,
pub python_platform: PythonPlatform,
pub search_paths: SearchPaths,
/// The root of the resolved virtual environment, if any. File watchers use this to observe
/// `pyvenv.cfg` and directory changes without resolving the environment again. System Python
/// installations are very unlikely to be deleted and recreated, so we exclude them to avoid
/// recursively watching a system prefix such as `/usr`.
pub virtual_environment: Option<SystemPathBuf>,
}
impl ProgramSettings {
@@ -67,6 +73,7 @@ impl ProgramSettings {
python_version: PythonVersionWithSource::default(),
python_platform: PythonPlatform::default(),
search_paths: SearchPaths::empty(vendored),
virtual_environment: None,
}
}
}
+3
View File
@@ -318,6 +318,9 @@ pub(crate) mod tests {
};
let program_settings = ProgramSettings {
virtual_environment: self
.third_party_packages
.then(|| SystemPathBuf::from("/.venv")),
python_version: PythonVersionWithSource {
version: self.python_version,
source: PythonVersionSource::default(),
@@ -2235,11 +2235,10 @@ impl<'db> KnownClassLookupError<'db> {
mod tests {
use super::*;
use crate::db::tests::{TestDbBuilder, setup_db};
use crate::{PythonVersionSource, PythonVersionWithSource};
use strum::IntoEnumIterator;
use ty_module_resolver::resolve_module_confident;
use ty_python_core::TestProgramDb as _;
use ty_python_core::program::{Program, ProgramSettings};
use ty_python_core::program::Program;
#[test]
fn known_class_roundtrip_from_str() {
@@ -2335,22 +2334,12 @@ mod tests {
classes.sort_unstable_by_key(|(_, version)| *version);
let mut program = db.program();
let mut current_version = program.python_version(&db);
let python_platform = program.python_platform(&db).clone();
let search_paths = program.search_paths(&db).clone();
let mut settings = db.program_settings().clone();
for (class, version_added) in classes {
if version_added != current_version {
let settings = ProgramSettings {
python_version: PythonVersionWithSource {
version: version_added,
source: PythonVersionSource::default(),
},
python_platform: python_platform.clone(),
search_paths: search_paths.clone(),
};
if version_added != settings.python_version.version {
settings.python_version.version = version_added;
program = Program::from_settings(&db, &settings);
current_version = version_added;
}
// Check the class can be looked up successfully
@@ -15,12 +15,11 @@ use ruff_python_ast::PythonVersion;
use salsa::Database as _;
use salsa::plumbing::AsId;
use ty_python_core::definition::Definition;
use ty_python_core::program::{Program, ProgramSettings};
use ty_python_core::program::Program;
use ty_python_core::scope::FileScopeId;
use ty_python_core::{
ProgramFile, TestProgramDb as _, global_scope, place_table, semantic_index, use_def_map,
};
use ty_site_packages::{PythonVersionSource, PythonVersionWithSource};
use super::*;
@@ -119,39 +118,10 @@ fn same_file_at_different_python_versions() -> anyhow::Result<()> {
db.write_dedented("src/py312_dependency.py", "value: int = 312")?;
let file = system_path_to_file(&db, "src/main.py").expect("file to exist");
let default_program = db.program();
let search_paths = default_program.search_paths(&db).clone();
let python_platform = default_program.python_platform(&db).clone();
let py311 = ProgramFile::new(
&db,
file,
Program::from_settings(
&db,
&ProgramSettings {
python_version: PythonVersionWithSource {
version: PythonVersion::PY311,
source: PythonVersionSource::Default,
},
python_platform: python_platform.clone(),
search_paths: search_paths.clone(),
},
),
);
let py312 = ProgramFile::new(
&db,
file,
Program::from_settings(
&db,
&ProgramSettings {
python_version: PythonVersionWithSource {
version: PythonVersion::PY312,
source: PythonVersionSource::Default,
},
python_platform,
search_paths,
},
),
);
let py311 = db.program().program_file(&db, file);
let mut settings = db.program_settings().clone();
settings.python_version.version = PythonVersion::PY312;
let py312 = Program::from_settings(&db, &settings).program_file(&db, file);
let check = |file, expected_type, expect_invalid_syntax, expect_unresolved_import| {
let diagnostics = crate::check_file_unwrap(&db, file);
@@ -204,31 +174,16 @@ fn program_file_changes_with_python_version() -> anyhow::Result<()> {
(program_file.as_id(), program_file.python_file(&db).as_id())
};
let equivalent_program = Program::from_settings(
&db,
&ProgramSettings {
python_version: db.program_settings().python_version.clone(),
python_platform: program.python_platform(&db).clone(),
search_paths: program.search_paths(&db).clone(),
},
);
let mut settings = db.program_settings().clone();
let equivalent_program = Program::from_settings(&db, &settings);
assert_eq!(program, equivalent_program);
assert_eq!(
program_file_id,
equivalent_program.program_file(&db, file).as_id()
);
let py312_program = Program::from_settings(
&db,
&ProgramSettings {
python_version: PythonVersionWithSource {
version: PythonVersion::PY312,
source: PythonVersionSource::Default,
},
python_platform: program.python_platform(&db).clone(),
search_paths: program.search_paths(&db).clone(),
},
);
settings.python_version.version = PythonVersion::PY312;
let py312_program = Program::from_settings(&db, &settings);
let program_file = py312_program.program_file(&db, file);
assert_ne!(program_file_id, program_file.as_id());
+6 -6
View File
@@ -180,8 +180,8 @@ fn picks_up_edits_to_unwatched_pth_files() -> Result<()> {
assert_json_snapshot!(watcher_snapshot(&watches), @r#"
[
"file://<temp_dir>/src :: **",
"file://<temp_dir>/old :: **",
"file://<temp_dir>/venv/<site-packages> :: **"
"file://<temp_dir>/venv :: **",
"file://<temp_dir>/old :: **"
]
"#);
server.open_text_document(main, &source, 1);
@@ -218,8 +218,8 @@ fn picks_up_edits_to_unwatched_pth_files() -> Result<()> {
assert_json_snapshot!(watcher_snapshot(&watches), @r#"
[
"file://<temp_dir>/src :: **",
"file://<temp_dir>/old :: **",
"file://<temp_dir>/venv/<site-packages> :: **"
"file://<temp_dir>/venv :: **",
"file://<temp_dir>/old :: **"
]
"#);
server.acknowledge_request(request_id);
@@ -230,8 +230,8 @@ fn picks_up_edits_to_unwatched_pth_files() -> Result<()> {
assert_json_snapshot!(watcher_snapshot(&watches), @r#"
[
"file://<temp_dir>/src :: **",
"file://<temp_dir>/new :: **",
"file://<temp_dir>/venv/<site-packages> :: **"
"file://<temp_dir>/venv :: **",
"file://<temp_dir>/new :: **"
]
"#);
assert_eq!(server.acknowledge_unregistration()?, stale_pth_id);
+24 -25
View File
@@ -296,35 +296,16 @@ impl PythonEnvironment {
PythonEnvironment::new(path, origin, system)
}
if let Ok(virtual_env) = system.env_var(EnvVars::VIRTUAL_ENV) {
return resolve_environment(
system,
SystemPath::new(&virtual_env),
SysPrefixPathOrigin::VirtualEnvVar,
)
.map(Some);
}
if let Some(conda_env) = conda_environment_from_env(system, CondaEnvironmentKind::Child) {
return resolve_environment(system, &conda_env, SysPrefixPathOrigin::CondaPrefixVar)
.map(Some);
}
if let Some(project_root) = project_root {
tracing::debug!("Discovering virtual environment in `{project_root}`");
let virtual_env_directory = project_root.join(".venv");
match PythonEnvironment::new(
&virtual_env_directory,
SysPrefixPathOrigin::LocalVenv,
system,
) {
if let Some((path, origin)) = Self::virtual_environment_candidate(project_root, system) {
let is_local_venv = matches!(origin, SysPrefixPathOrigin::LocalVenv);
match resolve_environment(system, &path, origin) {
Ok(environment) => return Ok(Some(environment)),
Err(err) if !is_local_venv => return Err(err),
Err(err) => {
if system.is_directory(&virtual_env_directory) {
if system.is_directory(&path) {
tracing::debug!(
"Ignoring automatically detected virtual environment at `{}`: {}",
&virtual_env_directory,
&path,
err
);
}
@@ -346,6 +327,24 @@ impl PythonEnvironment {
Ok(None)
}
/// Returns the virtual environment location to try and its origin, without checking whether
/// it exists. `VIRTUAL_ENV` takes precedence over a child Conda environment, followed by the
/// project's `.venv`. System Python fallbacks are handled by [`Self::discover`].
pub fn virtual_environment_candidate(
project_root: Option<&SystemPath>,
system: &dyn System,
) -> Option<(SystemPathBuf, SysPrefixPathOrigin)> {
if let Ok(virtual_env) = system.env_var(EnvVars::VIRTUAL_ENV) {
return Some((virtual_env.into(), SysPrefixPathOrigin::VirtualEnvVar));
}
if let Some(conda_env) = conda_environment_from_env(system, CondaEnvironmentKind::Child) {
return Some((conda_env, SysPrefixPathOrigin::CondaPrefixVar));
}
project_root.map(|root| (root.join(".venv"), SysPrefixPathOrigin::LocalVenv))
}
pub fn new(
path: impl AsRef<SystemPath>,
origin: SysPrefixPathOrigin,
+19 -20
View File
@@ -246,29 +246,25 @@ fn run_test(
let configuration = test.configuration();
let site_packages_paths = if configuration.dependencies().is_some() {
let python = if configuration.dependencies().is_some() {
// If dependencies were specified, use the venv we just set up
let environment = PythonEnvironment::new(
&venv_for_external_dependencies,
SysPrefixPathOrigin::PythonCliFlag,
db.system(),
)
.expect("Python environment to point to a valid path");
environment
.site_packages_paths(db.system())
.expect("Python environment to be valid")
.into_vec()
} else if let Some(python) = configuration.python() {
let environment =
PythonEnvironment::new(python, SysPrefixPathOrigin::PythonCliFlag, db.system())
.expect("Python environment to point to a valid path");
environment
.site_packages_paths(db.system())
.expect("Python environment to be valid")
.into_vec()
Some(venv_for_external_dependencies.as_path())
} else {
vec![]
configuration.python()
};
let python_environment = python.map(|python| {
PythonEnvironment::new(python, SysPrefixPathOrigin::PythonCliFlag, db.system())
.expect("Python environment to point to a valid path")
});
let site_packages_paths = python_environment
.as_ref()
.map(|environment| {
environment
.site_packages_paths(db.system())
.expect("Python environment to be valid")
.into_vec()
})
.unwrap_or_default();
// Make any relative extra-paths be relative to src_path
let extra_paths = configuration
@@ -286,6 +282,9 @@ fn run_test(
.collect();
let settings = ProgramSettings {
virtual_environment: python_environment
.filter(PythonEnvironment::is_virtual)
.map(|environment| environment.sys_prefix().to_path_buf()),
python_version: PythonVersionWithSource {
version: python_version,
source: PythonVersionSource::Cli,
+5 -10
View File
@@ -16,7 +16,6 @@ use ruff_db::system::{
use ruff_db::vendored::VendoredFileSystem;
use ruff_python_parser::{Mode, ParseOptions, parse_unchecked};
use ty_module_resolver::{Db as ModuleResolverDb, SearchPathSettings};
use ty_python_core::platform::PythonPlatform;
use ty_python_core::program::{FallibleStrategy, ProgramSettings};
use ty_python_core::{Db as _, ProgramFile, TestProgramDb};
use ty_python_semantic::dependency::DependencyMetadata;
@@ -65,15 +64,11 @@ impl TestDb {
.create_directory_all(&src_root)
.unwrap();
let program_settings = ProgramSettings {
python_version: PythonVersionWithSource::default(),
python_platform: PythonPlatform::default(),
search_paths: SearchPathSettings::new(vec![src_root])
.to_search_paths(db.system(), db.vendored(), &FallibleStrategy)
.expect("Valid search path settings"),
};
program_settings.search_paths.try_register_static_roots(&db);
db.program_settings = program_settings;
let search_paths = SearchPathSettings::new(vec![src_root])
.to_search_paths(db.system(), db.vendored(), &FallibleStrategy)
.expect("Valid search path settings");
search_paths.try_register_static_roots(&db);
db.program_settings.search_paths = search_paths;
db
}
-8
View File
@@ -574,14 +574,6 @@ kind = "inherent_method"
level = "expect"
reason = "TextSize intentionally provides both checked and saturating arithmetic"
[[override]]
lint = "hawk::dead_public"
crate = "ty_project"
item = "db::changes::ChangeResult::custom_stdlib_changed"
kind = "inherent_method"
level = "expect"
reason = "change results expose both project and custom-stdlib invalidation state"
[[override]]
lint = "hawk::dead_public"
crate = "ruff_python_ast"