[ty] Refresh uv project metadata when uv files change (#28529)

## Summary

Refresh the `uv workspace metadata` when a user manually changes the
project structure, a `pyproject.toml`, `uv.toml`, or after changing the
active environment using e.g. `uv add`, `uv sync`, or `uv pip`.


ty now watches refetches the uv metadata when:

* a `uv.toml`, `pyproject.toml`, `uv.lock` or `.python-version` file
changes
* a `.dist-info` directory is created or deleted, same for
`site-packages`
* A new included directory is created or deleted (could be a new
workspace member)

I intentionally limited this PR to workspace layouts where all members
are within the workspace root. Members outside aren't watched, to keep
the implementation simpler (we also couldn't watch those paths when `uv`
initially fails because ty then doesn't even know the member's path).

It was also my goal to balance the watcher complexity with avoiding
unnecessary `uv workspace metadata` calls. In most cases, I preferred a
simpler watcher implementation over avoiding unnecessary calls because a
`uv workspace metadata` call for an unchanged workspace should be very
cheap.

## Review

I recommend reviewing this PR commit by commit.
This commit is contained in:
Micha Reiser authored and GitHub committed 2026-10-01 18:30:32 +02:00
1 parent 41d30df6fd
commit b6a74d26cb
5 files changed
+1076 -88

No files matched your search

File diff suppressed because it is too large. Load diff
+117 -21
View File
@@ -1,7 +1,7 @@
use crate::db::{Db, ProjectDatabase};
use crate::script::script_tag;
use crate::watch::{ChangeEvent, CreatedKind, DeletedKind};
use crate::{ProjectMetadata, ProjectReloadResult};
use crate::{GlobFilterCheckMode, ProjectMetadata, ProjectReloadResult};
use std::collections::BTreeSet;
use crate::walk::{ProjectFilesWalker, create_walker_builder};
@@ -9,6 +9,7 @@ use ruff_db::Db as _;
use ruff_db::files::{File, Files, system_path_to_file};
use ruff_db::system::{SystemPath, SystemPathBuf};
use rustc_hash::FxHashSet;
use ty_module_resolver::system_module_search_paths;
use ty_python_core::program::FallibleStrategy;
use ty_python_semantic::PythonEnvironment;
@@ -93,15 +94,15 @@ impl ProjectDatabase {
pub fn apply_changes(&mut self, changes: &[ChangeEvent]) -> ChangeResult {
let project = self.project();
let project_root = project.root(self).to_path_buf();
let configuration_paths = ConfigurationPaths::from_metadata(project.metadata(self));
let metadata = project.metadata(self);
let configuration_paths = ConfigurationPaths::from_metadata(metadata);
// The initial uv metadata request may have failed before a workspace could be discovered.
let uv_enabled = metadata.use_uv().workspace_discovery_enabled();
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 python_path = metadata.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)
@@ -138,16 +139,31 @@ impl ProjectDatabase {
Some(create_walker_builder(self, walk_roots)?.incremental_matcher())
});
for change in changes {
// Classify the whole batch before syncing files. An earlier event for a deleted file
// can mark it as missing in the cache, preventing us from recognizing its deletion.
let changes: Vec<_> = changes.iter().map(|change| change.resolve(self)).collect();
for change in &changes {
let change = change.as_ref();
tracing::debug!("Handling file watcher change event: {:?}", change);
refresh_program_settings |= affects_python_environment(
let environment_changed = affects_python_environment(
change,
virtual_environment.as_deref(),
python_path.as_deref(),
);
refresh_program_settings |= environment_changed;
// Recreating an environment can change uv's reported interpreter and installed
// module ownership without changing the lockfile.
if uv_enabled
&& !reload_project
&& (environment_changed || affects_uv_metadata(self, change))
{
reload_project = true;
}
if let Some(path) = change.system_path() {
// Configuration changes can alter ty's settings and project root.
if configuration_paths.is_configuration(path, &project_root) {
File::sync_path(self, path);
reload_project = true;
@@ -284,16 +300,7 @@ impl ProjectDatabase {
}
ChangeEvent::Deleted { kind, path } => {
let is_file = match kind {
DeletedKind::File => true,
DeletedKind::Directory => false,
DeletedKind::Any => self
.files
.try_system(self, path)
.is_some_and(|file| file.exists(self)),
};
if is_file {
if *kind == DeletedKind::File {
if synced_files.insert(path.to_path_buf()) {
File::sync_path(self, path);
}
@@ -564,3 +571,92 @@ fn affects_python_environment(
_ => false,
}
}
fn affects_uv_metadata(db: &dyn Db, change: &ChangeEvent) -> bool {
// `uv workspace metadata` checks and may update the lockfile before exporting it. It also
// reads the selected environment:
// - `pyproject.toml` defines workspace membership and dependencies, which affect
// `workspace_root`, `members`, and `resolution`. A new file can also make this a uv
// project for the first time. uv reads these files even when `--config-file` replaces
// ty's project configuration, or when they belong to nested workspace members.
// - `uv.lock` supplies `members` and the dependency `resolution`. For example, `uv add` can
// change the lockfile before the environment is synchronized.
// - `uv.toml` supplies resolver settings such as package indexes. If those settings make the
// lockfile stale, the metadata command can resolve again and return a different `resolution`.
// - `.python-version` selects the interpreter uv uses to check or update the lockfile.
// When the workspace has no `requires-python`, uv infers a lower bound from that interpreter.
// This can change `resolution`: pinning 3.13 instead of 3.12 can remove dependencies
// guarded by `python_version < '3.13'`.
// A matching name in an unrelated watched path may also trigger a refresh. The event path is
// not passed to uv, so it cannot make ty use the other project's metadata. If this project's
// metadata and settings are unchanged, the false positive only costs a no-op uv workspace metadata call.
match change {
ChangeEvent::Created { path, .. }
| ChangeEvent::Changed { path, .. }
| ChangeEvent::Deleted { path, .. }
if matches!(
path.file_name(),
Some("pyproject.toml" | "uv.lock" | "uv.toml" | ".python-version")
) =>
{
true
}
// uv derives `module_owners` from installed distributions in `site-packages`, not just the
// lockfile. `uv pip uninstall` or `uv sync --frozen` can remove or create a `.dist-info`
// directory without updating `uv.lock`. Require `site-packages` to be the immediate
// parent: installed packages can contain vendored `.dist-info` directories that uv does
// not treat as installed distributions. Matching the parent by name also handles
// events through aliases such as `lib64` without filesystem reads. An unrelated
// environment only causes an extra refresh of this project's metadata.
ChangeEvent::Created { path, .. } | ChangeEvent::Deleted { path, .. }
if path
.file_name()
.is_some_and(|name| name.ends_with(".dist-info"))
&& path
.parent()
.is_some_and(|parent| parent.file_name() == Some("site-packages")) =>
{
true
}
// Moving a workspace member can produce only a directory event, with no separate
// event for its `pyproject.toml`. Refresh metadata because adding or removing a member
// can change `members` and `resolution`:
// - Check directories in the uv workspace against ty's include/exclude settings. A member
// can affect resolution even when it is outside the paths passed to `ty check`.
// If metadata loading failed, use ty's project root so directory changes can trigger a retry.
// - Check directories that contain a search path to detect changes to local dependencies.
// For example, deleting `/dependency` removes the sources at `/dependency/src`.
//
// Using the project's include/exclude settings avoids reading ignore files on each
// event, at the cost of occasionally requesting unchanged metadata.
ChangeEvent::Created {
path,
kind: CreatedKind::Directory | CreatedKind::Any,
}
| ChangeEvent::Deleted {
path,
kind: DeletedKind::Directory | DeletedKind::Any,
} => {
let project = db.project();
let workspace_root = project
.metadata(db)
.uv_workspace()
.map_or(project.root(db), |workspace| workspace.workspace_root());
let is_included_workspace_directory = path.starts_with(workspace_root)
&& project
.settings(db)
.src()
.files
.is_directory_maybe_included(path, GlobFilterCheckMode::Adhoc)
.is_included();
let environment = project.program(db).resolver_environment(db);
let contains_search_path = system_module_search_paths(db, environment)
.any(|search_path| search_path.starts_with(path));
is_included_workspace_directory || contains_search_path
}
_ => false,
}
}
+28 -2
View File
@@ -1,6 +1,7 @@
use std::fs;
use std::{borrow::Cow, fs};
pub use project_watcher::{ProjectWatcher, WatchPaths, watch_paths};
use ruff_db::Db;
use ruff_db::system::{System, SystemPath, SystemPathBuf, SystemVirtualPathBuf};
pub use watcher::{EventHandler, Watcher, directory_watcher};
@@ -20,7 +21,7 @@ mod watcher;
/// ## Renaming a directory
/// It's up to the file watcher implementation to aggregate the rename event for a directory to a single rename
/// event instead of emitting an event for each file or subdirectory in that path.
#[derive(Debug, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ChangeEvent {
/// A new or existing file was opened in an editor.
///
@@ -73,6 +74,31 @@ impl ChangeEvent {
}
}
/// Refines ambiguous deletions using the project's cached file state.
///
/// Created and modified paths can be classified from the filesystem, but deleted paths
/// no longer exist. If the cache records an existing file, classify its deletion as `File`.
/// Otherwise, keep `Any`: the path could have been an uncached file or a directory.
/// Resolve every event in a batch before updating any cached file state.
pub(crate) fn resolve(&self, db: &dyn Db) -> Cow<'_, Self> {
if let Self::Deleted {
path,
kind: DeletedKind::Any,
} = self
&& db
.files()
.try_system(db, path)
.is_some_and(|file| file.exists(db))
{
Cow::Owned(Self::Deleted {
path: path.clone(),
kind: DeletedKind::File,
})
} else {
Cow::Borrowed(self)
}
}
pub fn file_name(&self) -> Option<&str> {
self.system_path().and_then(|path| path.file_name())
}
+54 -16
View File
@@ -12,6 +12,7 @@ use ty_module_resolver::system_module_search_paths;
use crate::Project;
use crate::db::{Db, ProjectDatabase};
use crate::script::Script;
use crate::uv::UvMetadata;
use crate::watch::Watcher;
/// Wrapper around a [`Watcher`] that watches the relevant paths of a project.
@@ -178,11 +179,17 @@ impl WatchPaths {
}
}
/// Watches are registered in project, Python environment, module, then configuration order.
/// On Linux, the last registered watch determines the path reported for overlapping symlinks.
/// Workspace and project watches come first, followed by Python environment, module, and
/// configuration watches. 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. A virtual environment outside the project needs its own watch because
/// uv discovers the workspace root from `pyproject.toml`, so a successful metadata request tells
/// us where to watch even if `uv.lock` is created later. A member's ty project root may be inside
/// that workspace; changes to the lockfile at the workspace root can then change `members` and
/// `resolution` in `uv workspace metadata`.
///
/// The workspace root, project root, and explicitly included paths share recursive watches where
/// possible. 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`.
@@ -193,6 +200,8 @@ impl WatchPaths {
///
/// # Known limitations
///
/// ## Virtual environments
///
/// 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.
///
@@ -207,20 +216,50 @@ impl WatchPaths {
/// 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.
///
/// ## uv workspace metadata
///
/// Metadata refreshes use the existing workspace, project, environment, and module search-path
/// watches. We do not register additional watches for every local dependency's source directory.
/// Deleting or moving an ancestor of an external search path may go unnoticed unless another
/// watch, such as the workspace watch, covers that ancestor.
///
/// - Non-editable local dependencies: For a dependency installed from `../helper`, the search
/// path points to its installed copy in `site-packages`. Moving or deleting `../helper` can
/// affect uv's resolution without producing an event on a watched path.
/// - Initial metadata failure: uv may fail before reporting the workspace root or dependencies.
/// Creating or repairing an external dependency then goes unnoticed unless an existing watch
/// covers it. Directory changes under ty's project root can still request a retry.
/// - Excluded workspace members: We use `src.include` and `src.exclude` to avoid refreshing uv
/// metadata for unrelated directory changes, such as creating a package directory inside `.venv`.
/// We intentionally make an exception for directories containing a module search path:
/// excluding a dependency from checking does not stop the application from importing it.
/// For example, an app can import an editable dependency at `packages/lib/src` while
/// `src.include` selects only `packages/app`. Deleting `packages/lib` removes dependency
/// sources and can change uv's resolution, so it should trigger a refresh.
/// For excluded members with no module search path, a directory event alone does not trigger
/// a refresh, so the cached uv member list can become stale. A separate event for the member's
/// `pyproject.toml` or the workspace's `uv.lock` still triggers a refresh.
///
/// We accept these gaps to avoid retaining additional dependency paths, repeating uv's discovery
/// after errors, or refreshing metadata for every directory change under the workspace root.
#[salsa::tracked(returns(ref))]
pub fn watch_paths(db: &dyn Db, project: Project) -> WatchPaths {
let project_path = project.root(db);
let workspace_root = project
.metadata(db)
.uv_workspace()
.map(UvMetadata::workspace_root);
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.
// We always need to watch the project root to observe changes to its configuration.
// Watch the workspace and project roots and any paths provided by the user on the CLI.
// A recursive parent watch also covers nested paths, including member projects.
let included_paths = ruff_db::system::deduplicate_nested_paths(
std::iter::once(project_path).chain(
std::iter::once(project_path).chain(workspace_root).chain(
project
.included_paths_list(db)
.iter()
@@ -239,17 +278,16 @@ pub fn watch_paths(db: &dyn Db, project: Project) -> WatchPaths {
}
}
// 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))
}));
// Register module search paths after their parents even when a recursive watch already
// covers them. On Linux, a parent watch can report changes through another symlink,
// such as `.venv/lib64` instead of `.venv/lib`, leaving import resolution stale.
let module_paths = search_paths
.into_iter()
.filter(|path| *path != project_path);
let paths: Vec<_> = included_paths
.chain(virtual_environment)
.chain(unique_module_paths)
.chain(module_paths)
.chain(project.metadata(db).extra_configuration_paths())
.map(SystemPath::to_path_buf)
.collect();
@@ -181,6 +181,7 @@ fn picks_up_edits_to_unwatched_pth_files() -> Result<()> {
[
"file://<temp_dir>/src :: **",
"file://<temp_dir>/venv :: **",
"file://<temp_dir>/venv/<site-packages> :: **",
"file://<temp_dir>/old :: **"
]
"#);
@@ -219,6 +220,7 @@ fn picks_up_edits_to_unwatched_pth_files() -> Result<()> {
[
"file://<temp_dir>/src :: **",
"file://<temp_dir>/venv :: **",
"file://<temp_dir>/venv/<site-packages> :: **",
"file://<temp_dir>/old :: **"
]
"#);
@@ -231,6 +233,7 @@ fn picks_up_edits_to_unwatched_pth_files() -> Result<()> {
[
"file://<temp_dir>/src :: **",
"file://<temp_dir>/venv :: **",
"file://<temp_dir>/venv/<site-packages> :: **",
"file://<temp_dir>/new :: **"
]
"#);