From b6a74d26cbe6b45b3eb2992041714461bd4ceb5b Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Thu, 1 Oct 2026 18:30:32 +0200 Subject: [PATCH] [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. --- crates/ty/tests/file_watching.rs | 923 +++++++++++++++++- crates/ty_project/src/db/changes.rs | 138 ++- crates/ty_project/src/watch.rs | 30 +- .../ty_project/src/watch/project_watcher.rs | 70 +- crates/ty_server/tests/e2e/file_watching.rs | 3 + 5 files changed, 1076 insertions(+), 88 deletions(-) diff --git a/crates/ty/tests/file_watching.rs b/crates/ty/tests/file_watching.rs index 8288346f73..1c0cf45c56 100644 --- a/crates/ty/tests/file_watching.rs +++ b/crates/ty/tests/file_watching.rs @@ -6,6 +6,7 @@ use anyhow::{Context, anyhow}; use insta::assert_snapshot; #[cfg(feature = "test-uv")] use insta::internals::SettingsBindDropGuard; +use ruff_db::Db as _; use ruff_db::diagnostic::{ Diagnostic, DiagnosticFormat, DisplayDiagnosticConfig, DisplayDiagnostics, }; @@ -75,6 +76,40 @@ impl TestCase { &self.db } + /// Starts file-watching and waits until the watcher is ready to deliver events. + /// Panics if the test is already watching. + fn start_watching(&mut self) -> anyhow::Result<()> { + assert!(self.watcher.is_none(), "File watcher is already running"); + + let (sender, receiver) = crossbeam::channel::unbounded(); + let watcher = directory_watcher(move |events| sender.send(events).unwrap()) + .with_context(|| "Failed to create directory watcher")?; + let watcher = ProjectWatcher::new(watcher, &mut self.db); + assert!(!watcher.has_errored_paths()); + self.watcher = Some(watcher); + self.changes_receiver = receiver; + + // Write a sentinel file to confirm the watcher is live and delivering events. + // This + // 1. ensures the watcher is working well, and not e.g. backed up with events unrelated to the current test + // 2. flushes events that are unrelated to the current test + let sentinel_path = self.db.system().current_directory().join(".watcher_ready"); + std::fs::write(sentinel_path.as_std_path(), "ready")?; + + self.try_take_watch_changes(event_for_file(".watcher_ready"), Duration::from_secs(30)) + .expect( + "Watcher failed to deliver sentinel event within 30s \ + — file watching may not be operational", + ); + + // Clean up the sentinel file and drain its deletion event. + let _ = std::fs::remove_file(sentinel_path.as_std_path()); + let _ = self + .try_take_watch_changes(event_for_file(".watcher_ready"), Duration::from_millis(500)); + + Ok(()) + } + /// Stops file-watching and returns the collected change events. /// /// The caller must pass a `MatchEvent` filter that is applied to @@ -118,7 +153,7 @@ impl TestCase { let watcher = self .watcher .take() - .expect("Cannot call `stop_watch` more than once"); + .expect("Cannot call `stop_watch` without a running watcher"); let start = Instant::now(); let mut all_events = Vec::new(); @@ -170,7 +205,7 @@ impl TestCase { let watcher = self .watcher .as_ref() - .expect("Cannot call `try_take_watch_changes` after `stop_watch`"); + .expect("Cannot call `try_take_watch_changes` without a running watcher"); let start = Instant::now(); let mut all_events = Vec::new(); @@ -322,6 +357,7 @@ struct SetupContext<'a> { fallback_options: Option, included_paths: Option>, allow_invalid_settings: bool, + watch: bool, } impl<'a> SetupContext<'a> { @@ -404,6 +440,12 @@ impl<'a> SetupContext<'a> { fn set_included_paths(&mut self, paths: Vec) { self.included_paths = Some(paths); } + + /// Controls whether setup starts watching. Defaults to true. + /// When false, call [`TestCase::start_watching`] after preparing the filesystem. + fn set_watch(&mut self, watch: bool) { + self.watch = watch; + } } impl Setup for [(P, &'static str); N] @@ -479,6 +521,7 @@ where fallback_options: None, included_paths: None, allow_invalid_settings: false, + watch: true, }; setup_files @@ -492,6 +535,7 @@ where fallback_options, included_paths, allow_invalid_settings, + watch, .. } = setup_context; @@ -508,7 +552,12 @@ where } let mut project = if let Some(config_file_override) = config_file_override { - ProjectMetadata::from_config_file(config_file_override, &project_path, &system, UseUv::Off)? + ProjectMetadata::from_config_file( + config_file_override, + &project_path, + &system, + UseUv::from_system(&system), + )? } else { ProjectMetadata::discover(&project_path, &system)? }; @@ -545,17 +594,10 @@ where db.project().set_included_paths(&mut db, included_paths); } - let (sender, receiver) = crossbeam::channel::unbounded(); - let watcher = directory_watcher(move |events| sender.send(events).unwrap()) - .with_context(|| "Failed to create directory watcher")?; - - let watcher = ProjectWatcher::new(watcher, &mut db); - assert!(!watcher.has_errored_paths()); - - let test_case = TestCase { + let mut test_case = TestCase { db, - changes_receiver: receiver, - watcher: Some(watcher), + changes_receiver: crossbeam::channel::never(), + watcher: None, _user_config_directory_override: user_config_directory_override, #[cfg(feature = "test-uv")] snapshot_settings: None, @@ -563,24 +605,9 @@ where root_dir: root_path, }; - // Write a sentinel file to confirm the watcher is live and delivering events. - // This - // 1. ensures the watcher is working well, and not e.g. backed up with events unrelated to the current test - // 2. flushes events that are unrelated to the current test - let sentinel_path = project_path.join(".watcher_ready"); - std::fs::write(sentinel_path.as_std_path(), "ready")?; - - test_case - .try_take_watch_changes(event_for_file(".watcher_ready"), Duration::from_secs(30)) - .expect( - "Watcher failed to deliver sentinel event within 30s \ - — file watching may not be operational", - ); - - // Clean up the sentinel file and drain its deletion event. - let _ = std::fs::remove_file(sentinel_path.as_std_path()); - let _ = test_case - .try_take_watch_changes(event_for_file(".watcher_ready"), Duration::from_millis(500)); + if watch { + test_case.start_watching()?; + } Ok(test_case) } @@ -3288,6 +3315,7 @@ mod uv_metadata { use anyhow::Context; use insta::assert_snapshot; + use ruff_db::Db as _; use ruff_db::files::File; use ruff_db::system::{Command, System, SystemPath}; use ty_module_resolver::system_module_search_paths; @@ -3297,6 +3325,7 @@ mod uv_metadata { use super::{ ChangeEvent, Setup, SetupContext, TestCase, event_for_file, setup_with_system, update_file, + write_file, }; const MANIFEST: &str = r#" @@ -3323,8 +3352,9 @@ mod uv_metadata { ); // uv rejects this setting, but ty must still apply its rule configuration. - update_and_synchronize_project( + update_file_and_wait_for_uv_sync( &mut case, + "pyproject.toml", r#" [project] name = "example" @@ -3346,11 +3376,12 @@ mod uv_metadata { assert_eq!(project.program_settings(case.db()), &program_settings); // If ordinary discovery also fails, keep the last applied settings and warning. - update_and_synchronize_project(&mut case, "[project\n")?; + update_file_and_wait_for_uv_sync(&mut case, "pyproject.toml", "[project\n")?; assert_eq!(case.db().check(), diagnostics); - update_and_synchronize_project( + update_file_and_wait_for_uv_sync( &mut case, + "pyproject.toml", r#" [project] name = "example" @@ -3365,8 +3396,47 @@ mod uv_metadata { Ok(()) } + /// A creation event for `pyproject.toml` must retry uv discovery and clear its initial + /// error, even when `--config-file` bypasses ty's normal configuration discovery. #[test] - fn project_refresh_uses_the_returned_workspace_root() -> anyhow::Result<()> { + fn creating_pyproject_toml_with_config_override_clears_uv_error() -> anyhow::Result<()> { + let mut case = setup_uv_with_system(UseUv::On, |context: &mut SetupContext| { + context.write_project_file("main.py", "value = 1\n")?; + // With --config-file, ty ignores pyproject.toml for configuration, but uv still needs it. + context.write_project_file("ty-override.toml", "")?; + context.set_config_file_override("ty-override.toml"); + // Create the environment first so adding the manifest is enough for metadata to load. + run_uv_with_system( + context.system(), + context.project_path(), + &["venv", "--offline"], + ) + })?; + let diagnostics = case.db().check(); + assert_snapshot!( + case.render_diagnostics(&diagnostics), + @"warning[uv-metadata] Failed to load uv metadata: No `pyproject.toml` found in current directory or any parent directory" + ); + + std::fs::write(case.project_path("pyproject.toml").as_std_path(), MANIFEST)?; + let events = case.take_watch_changes(event_for_file("pyproject.toml")); + apply_changes_and_synchronize_project(&mut case, &events)?; + assert_eq!(case.db().check().as_slice(), &[]); + Ok(()) + } + + /// A change event for the member's `pyproject.toml` after removing `[tool.ty]` makes ty + /// use the enclosing workspace as its root, bringing `workspace.py` into the checked files. + /// + /// ```text + /// / # uv workspace root + /// |-- pyproject.toml # members = ["project"] + /// |-- workspace.py # contains an invalid assignment + /// `-- project/ # initial ty project root + /// `-- pyproject.toml # contains [tool.ty] + /// ``` + #[test] + fn removing_tool_ty_section_checks_workspace_files() -> anyhow::Result<()> { let mut case = setup_uv( UseUv::On, &[ @@ -3388,18 +3458,754 @@ mod uv_metadata { [tool.ty] "#, ), + ("../workspace.py", "value: int = 'wrong'\n"), ], )?; - let project = case.db().project(); assert_eq!( - project.root(case.db()), + case.db().project().root(case.db()), + case.root_path().join("project").as_path() + ); + assert_eq!(case.db().check().as_slice(), &[]); + + // Removing the member's ty configuration lets ty use the enclosing workspace as its root. + update_file_and_wait_for_uv_sync(&mut case, "pyproject.toml", MANIFEST)?; + assert_eq!(case.db().project().root(case.db()), case.root_path()); + assert_snapshot!( + case.render_diagnostics(&case.db().check()), + @r#"/workspace.py:1:14: error[invalid-assignment] Object of type `Literal["wrong"]` is not assignable to `int`"# + ); + Ok(()) + } + + /// Turn `project/member` into a workspace member before starting the watcher, then run + /// `uv lock`. The resulting change events for `uv.lock`, outside ty's root, must make ty + /// recognize that the new member imports `example` without declaring a dependency on it. + /// + /// ```text + /// / # uv workspace root + /// |-- pyproject.toml # initially lists only "project" + /// |-- uv.lock # updated by uv lock + /// `-- project/ # ty project root, fixed by ty.toml + /// |-- ty.toml + /// |-- pyproject.toml # package "example" + /// |-- src/example/__init__.py + /// `-- member/ + /// |-- main.py # imports example + /// `-- pyproject.toml # added during the test + /// ``` + #[test] + fn updating_workspace_lockfile_outside_project_checks_new_member_dependencies() + -> anyhow::Result<()> { + let mut case = setup_uv_with( + UseUv::On, + &[ + ( + "../pyproject.toml", + r#" + [tool.uv.workspace] + members = ["project"] + "#, + ), + ( + "pyproject.toml", + r#" + [project] + name = "example" + version = "0.1.0" + requires-python = ">=3.8" + + [build-system] + requires = ["uv_build"] + build-backend = "uv_build" + "#, + ), + ("src/example/__init__.py", ""), + ("member/main.py", "import example\n"), + ( + "ty.toml", + r#" + [rules] + missing-direct-dependency = "warn" + "#, + ), + ], + |context| { + context.set_watch(false); + Ok(()) + }, + )?; + // The member's ty.toml keeps the workspace lockfile outside ty's project root. + assert_eq!( + case.db().project().root(case.db()), case.root_path().join("project").as_path() ); - // Without its own ty configuration, the member belongs to the enclosing workspace. - update_and_synchronize_project(&mut case, MANIFEST)?; - assert_eq!(case.db().project(), project); - assert_eq!(project.root(case.db()), case.root_path()); + assert_eq!(case.db().check().as_slice(), &[]); + + // Turn the existing directory into a member with its own dependency declarations. + // Its import now needs a dependency on `example`, which was previously its own package. + std::fs::write( + case.project_path("member/pyproject.toml"), + r#" + [project] + name = "member" + version = "0.1.0" + "#, + )?; + update_file( + case.root_path().join("pyproject.toml"), + r#" + [tool.uv.workspace] + members = ["project", "project/member"] + "#, + )?; + assert_eq!(case.db().check().as_slice(), &[]); + + // Observe uv's lockfile update without recording the preparatory manifest changes. + case.start_watching()?; + run_uv(&case, &["lock", "--offline"])?; + let changed = case.take_watch_changes(event_for_file("uv.lock")); + apply_changes_and_synchronize_project(&mut case, &changed)?; + assert_snapshot!( + case.render_diagnostics(&case.db().check()), + @"member/main.py:1:8: warning[missing-direct-dependency] Import of `example` requires a direct dependency on `example`" + ); + Ok(()) + } + + /// A change event for the nested member's `pyproject.toml` must refresh uv metadata, + /// even though that manifest does not configure ty. Removing its dependency makes the + /// unchanged import warn. + /// + /// ```text + /// project/ # ty project and uv workspace root + /// |-- ty.toml # enables missing-direct-dependency + /// |-- pyproject.toml # package "example", members = ["member"] + /// |-- src/example/__init__.py + /// `-- member/ + /// |-- pyproject.toml # declares the dependency removed by the test + /// `-- main.py # imports example + /// ``` + #[test] + fn removing_dependency_from_nested_pyproject_toml_reports_missing_dependency() + -> anyhow::Result<()> { + let mut case = setup_uv( + UseUv::On, + &[ + ( + "pyproject.toml", + r#" + [project] + name = "example" + version = "0.1.0" + requires-python = ">=3.8" + + [build-system] + requires = ["uv_build"] + build-backend = "uv_build" + + [tool.uv.workspace] + members = ["member"] + "#, + ), + ("src/example/__init__.py", ""), + ( + "member/pyproject.toml", + r#" + [project] + name = "member" + version = "0.1.0" + dependencies = ["example"] + + [tool.uv.sources] + example = { workspace = true } + "#, + ), + ("member/main.py", "import example\n"), + ( + "ty.toml", + r#" + [rules] + missing-direct-dependency = "warn" + "#, + ), + ], + )?; + assert_eq!(case.db().check().as_slice(), &[]); + + // This nested manifest is not ty configuration. Refresh uv metadata to notice that + // the member no longer declares the package imported by its unchanged Python file. + update_file_and_wait_for_uv_sync( + &mut case, + "member/pyproject.toml", + r#" + [project] + name = "member" + version = "0.1.0" + "#, + )?; + assert_snapshot!( + case.render_diagnostics(&case.db().check()), + @"member/main.py:1:8: warning[missing-direct-dependency] Import of `example` requires a direct dependency on `example`" + ); + Ok(()) + } + + /// Moving a Python file out of the project clears its diagnostic without refreshing uv + /// metadata, even when the file is edited immediately before the move. + #[test] + fn editing_then_moving_a_file_out_does_not_request_uv_metadata() -> anyhow::Result<()> { + let mut case = setup_uv( + UseUv::On, + &[ + ("pyproject.toml", MANIFEST), + ("module.py", "x: int = 'invalid'"), + ], + )?; + assert_snapshot!( + case.render_diagnostics(&case.db().check()), + @r#"module.py:1:10: error[invalid-assignment] Object of type `Literal["invalid"]` is not assignable to `int`"# + ); + + // Keep the assignment invalid so the diagnostic only clears when the file is removed. + update_file(case.project_path("module.py"), "x: int = 'still invalid'\n")?; + std::fs::rename( + case.project_path("module.py"), + case.root_path().join("module.py"), + )?; + // Wait for the move; the preceding edit alone is not enough to clear the diagnostic. + let changes = case.stop_watch(|event: &ChangeEvent| { + event.is_deleted() && event.file_name() == Some("module.py") + }); + assert!( + case.apply_changes(&changes).project_sync_path().is_none(), + "{changes:?}" + ); + assert_eq!(case.db().check().as_slice(), &[]); + Ok(()) + } + + /// Creating a package directory inside a dependency's source directory does not refresh + /// uv metadata when that directory is outside `src.include`. + /// + /// ```text + /// / + /// |-- project/ # ty project and uv workspace root + /// | |-- pyproject.toml + /// | |-- ty.toml # includes src; adds ../dependency/src as a search path + /// | `-- src/main.py + /// `-- dependency/src/ # module search path + /// `-- package/ # created during the test + /// ``` + #[test] + fn creating_directory_in_excluded_search_path_does_not_request_uv_metadata() + -> anyhow::Result<()> { + let mut case = setup_uv( + UseUv::On, + &[ + ("pyproject.toml", MANIFEST), + ( + "ty.toml", + r#" + [environment] + extra-paths = ["../dependency/src"] + + [src] + include = ["src"] + "#, + ), + ("src/main.py", ""), + ("../dependency/src/.keep", ""), + ], + )?; + + // Changes within a search path outside `src.include` do not affect dependency projects. + std::fs::create_dir(case.root_path().join("dependency/src/package"))?; + let created = case.take_watch_changes(event_for_file("package")); + assert!(case.apply_changes(&created).project_sync_path().is_none()); + Ok(()) + } + + /// The library member is outside ty's `src.include`, but its source directory is a + /// search path. Removing it produces deletion events for directories and their contents; + /// processing that batch must refresh uv metadata and report the broken dependency. + /// + /// ```text + /// / # uv workspace root + /// |-- pyproject.toml # members = ["project", "packages/*"] + /// |-- project/ # ty project root + /// | |-- pyproject.toml # depends on the workspace member "library" + /// | |-- ty.toml # includes src; adds ../packages/library/src as a search path + /// | `-- src/main.py + /// `-- packages/library/ # deleted during the test + /// |-- pyproject.toml + /// `-- src/library/__init__.py + /// ``` + #[test] + fn deleting_excluded_workspace_dependency_reports_uv_error() -> anyhow::Result<()> { + let mut case = setup_uv( + UseUv::On, + &[ + ( + "../pyproject.toml", + r#" + [tool.uv.workspace] + members = ["project", "packages/*"] + "#, + ), + ( + "pyproject.toml", + r#" + [project] + name = "example" + version = "0.1.0" + requires-python = ">=3.8" + dependencies = ["library"] + + [tool.uv.sources] + library = { workspace = true } + "#, + ), + ( + "ty.toml", + r#" + [environment] + extra-paths = ["../packages/library/src"] + + [src] + include = ["src"] + "#, + ), + ("src/main.py", ""), + ( + "../packages/library/pyproject.toml", + r#" + [project] + name = "library" + version = "0.1.0" + + # Use uv's bundled backend so setup works offline with an empty cache. + [build-system] + requires = ["uv_build"] + build-backend = "uv_build" + "#, + ), + ("../packages/library/src/library/__init__.py", ""), + ], + )?; + let library = case.root_path().join("packages/library"); + assert_eq!(case.db().check().as_slice(), &[]); + + // Although `src.include` excludes the member, its source directory is a search path, + // so removing it must refresh uv metadata. Use deletion because on Windows the watch + // on `library/src` holds a directory handle that prevents renaming `library`. + std::fs::remove_dir_all(&library).context("Failed to delete the workspace dependency")?; + let changes = case.take_watch_changes(|event: &ChangeEvent| { + matches!(event, ChangeEvent::Deleted { path, .. } if path.starts_with(&library)) + }); + apply_changes_and_synchronize_project(&mut case, &changes)?; + assert_snapshot!( + case.render_diagnostics(&case.db().check()), + @"/pyproject.toml: warning[uv-metadata] Failed to load uv metadata: Failed to build `example @ file:///project`" + ); + Ok(()) + } + + /// Moving a missing member into the workspace clears the uv error, even when the member + /// is outside the paths selected by `ty check src`. + /// + /// ```text + /// / + /// |-- incoming/ # moved to project/package + /// | `-- pyproject.toml # valid member manifest + /// `-- project/ # ty project and uv workspace root + /// |-- pyproject.toml # requires the member at package/, initially missing + /// |-- .venv/ + /// `-- src/main.py # src is the only path selected for checking + /// ``` + #[test] + fn moving_missing_member_into_workspace_outside_checked_paths_clears_uv_error() + -> anyhow::Result<()> { + let mut case = setup_uv_with_system(UseUv::On, |context: &mut SetupContext| { + // Create the environment before declaring the member that is still missing. + run_uv_with_system( + context.system(), + context.project_path(), + &["venv", "--offline"], + )?; + context.write_project_file( + "pyproject.toml", + r#" + [project] + name = "example" + version = "0.1.0" + requires-python = ">=3.8" + dependencies = ["member"] + + [tool.uv.sources] + member = { workspace = true } + + [tool.uv.workspace] + members = ["package"] + "#, + )?; + context.write_project_file("src/main.py", "")?; + context.write_project_file( + "../incoming/pyproject.toml", + r#" + [project] + name = "member" + version = "0.1.0" + + [tool.uv] + package = false + "#, + )?; + // Restrict checked files, as with `ty check src`, without excluding workspace members. + context.set_included_paths(vec![context.join_project_path("src")]); + Ok(()) + })?; + assert_snapshot!( + case.render_diagnostics(&case.db().check()), + @"pyproject.toml: warning[uv-metadata] Failed to load uv metadata: Failed to build `example @ file:///project`" + ); + + // Move the complete member into place without editing either manifest. + std::fs::rename( + case.root_path().join("incoming"), + case.project_path("package"), + )?; + let created = case.take_watch_changes(event_for_file("package")); + apply_changes_and_synchronize_project(&mut case, &created)?; + assert_eq!(case.db().check().as_slice(), &[]); + Ok(()) + } + + /// Moving an explicitly listed member out of the workspace reports that uv cannot load + /// the missing member. + /// + /// ```text + /// / + /// `-- project/ # ty project and uv workspace root + /// |-- pyproject.toml # requires the member at packages/member/ + /// |-- .venv/ + /// `-- packages/member/ # moved to /member + /// `-- pyproject.toml # valid member manifest + /// ``` + #[test] + fn moving_required_member_out_reports_uv_error() -> anyhow::Result<()> { + let mut case = setup_uv( + UseUv::On, + &[ + ( + "pyproject.toml", + r#" + [project] + name = "example" + version = "0.1.0" + requires-python = ">=3.8" + dependencies = ["member"] + + [tool.uv.sources] + member = { workspace = true } + + [tool.uv.workspace] + members = ["packages/member"] + "#, + ), + ( + "packages/member/pyproject.toml", + r#" + [project] + name = "member" + version = "0.1.0" + + [tool.uv] + package = false + "#, + ), + ], + )?; + assert_eq!(case.db().check().as_slice(), &[]); + + // The workspace still requires this member after its directory is moved away. + std::fs::rename( + case.project_path("packages/member"), + case.root_path().join("member"), + )?; + let deleted = case.take_watch_changes(event_for_file("member")); + apply_changes_and_synchronize_project(&mut case, &deleted)?; + assert_snapshot!( + case.render_diagnostics(&case.db().check()), + @"pyproject.toml: warning[uv-metadata] Failed to load uv metadata: Failed to build `example @ file:///project`" + ); + Ok(()) + } + + /// Creating `.python-version` refreshes workspace membership to include a newly added member. + #[test] + fn creating_python_version_file_refreshes_workspace_members() -> anyhow::Result<()> { + let mut case = setup_uv_with( + UseUv::On, + &[("pyproject.toml", MANIFEST), ("main.py", "")], + |context| { + context.set_watch(false); + Ok(()) + }, + )?; + let file = case.system_file(case.project_path("main.py"))?; + let project_root = case.project_path(""); + let member = case.project_path("member"); + assert_eq!( + case.db() + .dependency_metadata(file) + .context("missing uv metadata")? + .projects + .len(), + 1 + ); + + // Leave a valid member out of the cached metadata before starting the watcher. + // Changing `.python-version` need not change an existing venv; the new member makes + // the refresh observable instead. + write_file( + member.join("pyproject.toml"), + r#" + [project] + name = "member" + version = "0.1.0" + "#, + )?; + update_file( + case.project_path("pyproject.toml"), + r#" + [project] + name = "example" + version = "0.1.0" + requires-python = ">=3.8" + + [tool.uv.workspace] + members = ["member"] + "#, + )?; + case.start_watching()?; + + // Accept the existing Python 3 interpreter so the test needs no additional installation. + std::fs::write(case.project_path(".python-version"), "3\n")?; + let events = case.take_watch_changes(event_for_file(".python-version")); + apply_changes_and_synchronize_project(&mut case, &events)?; + assert_eq!( + case.db() + .dependency_metadata(file) + .context("missing uv metadata")? + .projects + .iter() + .map(|project| project.path.as_path()) + .collect::>(), + [project_root.as_path(), member.as_path()] + ); + Ok(()) + } + + /// Creating `uv.toml` refreshes workspace membership to include a newly added member. + #[test] + fn creating_uv_toml_refreshes_workspace_members() -> anyhow::Result<()> { + let mut case = setup_uv_with( + UseUv::On, + &[("pyproject.toml", MANIFEST), ("main.py", "")], + |context| { + context.set_watch(false); + Ok(()) + }, + )?; + let file = case.system_file(case.project_path("main.py"))?; + let project_root = case.project_path(""); + let member = case.project_path("member"); + assert_eq!( + case.db() + .dependency_metadata(file) + .context("missing uv metadata")? + .projects + .len(), + 1 + ); + + // Leave metadata stale so observing the new member proves that the uv.toml event + // caused a refresh. Prepare the manifests before starting the watcher. + write_file( + member.join("pyproject.toml"), + r#" + [project] + name = "member" + version = "0.1.0" + "#, + )?; + update_file( + case.project_path("pyproject.toml"), + r#" + [project] + name = "example" + version = "0.1.0" + requires-python = ">=3.8" + + [tool.uv.workspace] + members = ["member"] + "#, + )?; + case.start_watching()?; + + std::fs::write(case.project_path("uv.toml"), "offline = true\n")?; + let events = case.take_watch_changes(event_for_file("uv.toml")); + apply_changes_and_synchronize_project(&mut case, &events)?; + assert_eq!( + case.db() + .dependency_metadata(file) + .context("missing uv metadata")? + .projects + .iter() + .map(|project| project.path.as_path()) + .collect::>(), + [project_root.as_path(), member.as_path()] + ); + Ok(()) + } + + /// `uv lock` creates `uv.lock`, but its creation event must not request metadata when + /// ty's uv integration is disabled. + #[test] + fn creating_lockfile_does_not_request_uv_metadata_when_uv_is_disabled() -> anyhow::Result<()> { + let mut case = setup_uv(UseUv::Off, &[("pyproject.toml", MANIFEST)])?; + run_uv(&case, &["lock", "--offline"])?; + + let events = case.take_watch_changes(event_for_file("uv.lock")); + assert!(case.apply_changes(&events).project_sync_path().is_none()); + Ok(()) + } + + /// Deleting the selected environment reports a missing-environment warning. Recreating + /// it with `uv venv` clears that warning. + #[test] + fn recreating_selected_environment_clears_uv_error() -> anyhow::Result<()> { + let mut case = setup_uv(UseUv::On, &[("pyproject.toml", MANIFEST)])?; + assert_eq!(case.db().check().as_slice(), &[]); + // Check the warning while the environment is missing, before recreating it. + std::fs::remove_dir_all(case.project_path(".venv").as_std_path())?; + + let deleted = case.take_watch_changes(event_for_file(".venv")); + apply_changes_and_synchronize_project(&mut case, &deleted)?; + let diagnostics = case.db().check(); + assert_snapshot!( + case.render_diagnostics(&diagnostics), + @"pyproject.toml: warning[uv-metadata] Failed to load uv dependency metadata: uv did not provide a Python environment" + ); + + run_uv(&case, &["venv", "--offline"])?; + let created = case.take_watch_changes(event_for_file(".venv")); + apply_changes_and_synchronize_project(&mut case, &created)?; + assert_eq!(case.db().check().as_slice(), &[]); + Ok(()) + } + + /// Installing the dependency with `uv pip install` clears the unresolved-import diagnostic + /// and uv metadata warning without changing the project's manifest or lockfile. + #[test] + fn uv_pip_install_clears_import_and_metadata_errors() -> anyhow::Result<()> { + let mut case = setup_uv( + UseUv::On, + &[ + ( + "pyproject.toml", + r#" + [project] + name = "example" + version = "0.1.0" + requires-python = ">=3.8" + + [project.optional-dependencies] + extra = ["dependency"] + + [tool.uv.sources] + dependency = { path = "../dependency" } + "#, + ), + ("main.py", "import dependency\n"), + ( + "../dependency/pyproject.toml", + r#" + [project] + name = "dependency" + version = "0.1.0" + + [build-system] + requires = ["uv_build"] + build-backend = "uv_build" + "#, + ), + ("../dependency/src/dependency/__init__.py", ""), + ], + )?; + // The optional dependency is resolved but not installed by setup's `uv sync`. + assert_snapshot!(case.render_diagnostics(&case.db().check()), @" + main.py:1:8: error[unresolved-import] Cannot resolve imported module `dependency` + pyproject.toml: warning[uv-metadata] Failed to load uv dependency metadata: uv metadata has no module ownership or editable source paths + "); + + // Installing a local package changes the environment without editing the project's + // manifest or lockfile. uv's bundled build backend keeps the test offline. + run_uv(&case, &["pip", "install", "--offline", "../dependency"])?; + let created = case.take_watch_changes(event_for_file("dependency-0.1.0.dist-info")); + apply_changes_and_synchronize_project(&mut case, &created)?; + assert_eq!(case.db().check().as_slice(), &[]); + Ok(()) + } + + /// Uninstalling the dependency with `uv pip uninstall` reports an unresolved-import + /// diagnostic and uv metadata warning without changing the project's manifest or lockfile. + #[test] + fn uv_pip_uninstall_reports_import_and_metadata_errors() -> anyhow::Result<()> { + let mut case = setup_uv( + UseUv::On, + &[ + ( + "pyproject.toml", + r#" + [project] + name = "example" + version = "0.1.0" + requires-python = ">=3.8" + dependencies = ["dependency"] + + [tool.uv.sources] + dependency = { path = "../dependency" } + "#, + ), + ("main.py", "import dependency\n"), + ( + "../dependency/pyproject.toml", + r#" + [project] + name = "dependency" + version = "0.1.0" + + [build-system] + requires = ["uv_build"] + build-backend = "uv_build" + "#, + ), + ("../dependency/src/dependency/__init__.py", ""), + ], + )?; + assert_eq!(case.db().check().as_slice(), &[]); + + // Setup installs the dependency before watching starts. Uninstalling it removes its + // `.dist-info` directory without changing the project's manifest or lockfile. + run_uv(&case, &["pip", "uninstall", "--offline", "dependency"])?; + let deleted = case.take_watch_changes(event_for_file("dependency-0.1.0.dist-info")); + apply_changes_and_synchronize_project(&mut case, &deleted)?; + assert_snapshot!(case.render_diagnostics(&case.db().check()), @" + main.py:1:8: error[unresolved-import] Cannot resolve imported module `dependency` + pyproject.toml: warning[uv-metadata] Failed to load uv dependency metadata: uv metadata has no module ownership or editable source paths + "); Ok(()) } @@ -3597,6 +4403,11 @@ mod uv_metadata { Ok(()) } + /// Runs uv in the test's project directory with its configured executable and environment. + fn run_uv(case: &TestCase, args: &[&str]) -> anyhow::Result<()> { + run_uv_with_system(case.db().system(), &case.project_path(""), args) + } + /// Runs uv in `directory` with the system's configured executable and environment. /// Returns an error if uv fails. fn run_uv_with_system( @@ -3646,7 +4457,7 @@ mod uv_metadata { Ok(case) } - /// Creates a watched project with the selected uv mode and test command environment. + /// Creates a project with the selected uv mode and test command environment. /// Lets the test control Python environment creation and synchronization. /// Installs snapshot filters for temporary paths and platform-specific uv output. fn setup_uv_with_system(use_uv: UseUv, setup_files: impl Setup) -> anyhow::Result { @@ -3670,6 +4481,14 @@ mod uv_metadata { })?; let mut settings = insta::Settings::clone_current(); + // File URLs use forward slashes and include a slash before Windows drive letters. + settings.add_filter( + &format!( + "file:///?{}", + regex::escape(&case.root_path().as_str().replace('\\', "/")) + ), + "file://", + ); settings.add_filter(®ex::escape(case.root_path().as_str()), ""); settings.add_filter(r#"\\(\w\w|\s|\.|")"#, "/$1"); settings.add_filter(r"exit code: (\d+)", "exit status: $1"); @@ -3677,13 +4496,18 @@ mod uv_metadata { Ok(case) } - /// Updates `pyproject.toml` and processes its watcher events through project synchronization. - fn update_and_synchronize_project( + /// Updates an existing file, waits for its watcher event, and completes any resulting + /// uv project synchronization. + /// Relative paths are resolved against the current project root. + fn update_file_and_wait_for_uv_sync( case: &mut TestCase, + path: impl AsRef, source: &str, - ) -> anyhow::Result { - update_file(case.project_path("pyproject.toml"), source)?; - let changes = case.take_watch_changes(event_for_file("pyproject.toml")); + ) -> anyhow::Result<()> { + let path = case.project_path(path); + update_file(&path, source)?; + let file_name = path.file_name().context("Expected a file name")?; + let changes = case.take_watch_changes(event_for_file(file_name)); apply_changes_and_synchronize_project(case, &changes) } @@ -3692,7 +4516,7 @@ mod uv_metadata { fn apply_changes_and_synchronize_project( case: &mut TestCase, changes: &[ChangeEvent], - ) -> anyhow::Result { + ) -> anyhow::Result<()> { let changes = case.apply_changes(changes); if let Some(project_path) = changes.project_sync_path() { @@ -3701,7 +4525,8 @@ mod uv_metadata { .request_project_sync(case.db(), project_path, &|_, _| None); } - wait_for_synchronizations(case) + wait_for_synchronizations(case)?; + Ok(()) } fn update_and_synchronize_script(case: &mut TestCase, source: &str) -> anyhow::Result { diff --git a/crates/ty_project/src/db/changes.rs b/crates/ty_project/src/db/changes.rs index a30b452b93..b9b43a5545 100644 --- a/crates/ty_project/src/db/changes.rs +++ b/crates/ty_project/src/db/changes.rs @@ -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, + } +} diff --git a/crates/ty_project/src/watch.rs b/crates/ty_project/src/watch.rs index 4e2b57ab81..1446d80570 100644 --- a/crates/ty_project/src/watch.rs +++ b/crates/ty_project/src/watch.rs @@ -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()) } diff --git a/crates/ty_project/src/watch/project_watcher.rs b/crates/ty_project/src/watch/project_watcher.rs index 7409637475..d39ea470cf 100644 --- a/crates/ty_project/src/watch/project_watcher.rs +++ b/crates/ty_project/src/watch/project_watcher.rs @@ -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(); diff --git a/crates/ty_server/tests/e2e/file_watching.rs b/crates/ty_server/tests/e2e/file_watching.rs index 8992e3582c..b48cdedf6c 100644 --- a/crates/ty_server/tests/e2e/file_watching.rs +++ b/crates/ty_server/tests/e2e/file_watching.rs @@ -181,6 +181,7 @@ fn picks_up_edits_to_unwatched_pth_files() -> Result<()> { [ "file:///src :: **", "file:///venv :: **", + "file:///venv/ :: **", "file:///old :: **" ] "#); @@ -219,6 +220,7 @@ fn picks_up_edits_to_unwatched_pth_files() -> Result<()> { [ "file:///src :: **", "file:///venv :: **", + "file:///venv/ :: **", "file:///old :: **" ] "#); @@ -231,6 +233,7 @@ fn picks_up_edits_to_unwatched_pth_files() -> Result<()> { [ "file:///src :: **", "file:///venv :: **", + "file:///venv/ :: **", "file:///new :: **" ] "#);