diff --git a/crates/ty/tests/cli/main.rs b/crates/ty/tests/cli/main.rs index d314b4731a..de98e33660 100644 --- a/crates/ty/tests/cli/main.rs +++ b/crates/ty/tests/cli/main.rs @@ -656,20 +656,46 @@ impl CliTest { Ok(()) } - pub(crate) fn write_file(&self, path: impl AsRef, content: &str) -> anyhow::Result<()> { - let path = path.as_ref(); - let path = self.project_dir.join(path); - + fn ensure_parent_directory(path: &Path) -> anyhow::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("Failed to create directory `{}`", parent.display()))?; } + Ok(()) + } + + pub(crate) fn write_file(&self, path: impl AsRef, content: &str) -> anyhow::Result<()> { + let path = path.as_ref(); + let path = self.project_dir.join(path); + + Self::ensure_parent_directory(&path)?; + std::fs::write(&path, &*ruff_python_trivia::textwrap::dedent(content)) .with_context(|| format!("Failed to write file `{path}`", path = path.display()))?; Ok(()) } + #[cfg(unix)] + pub(crate) fn write_symlink( + &self, + original: impl AsRef, + link: impl AsRef, + ) -> anyhow::Result<()> { + let link = link.as_ref(); + let link = self.project_dir.join(link); + + let original = original.as_ref(); + let original = self.project_dir.join(original); + + Self::ensure_parent_directory(&link)?; + + std::os::unix::fs::symlink(original, &link) + .with_context(|| format!("Failed to write symlink `{link}`", link = link.display()))?; + + Ok(()) + } + pub(crate) fn root(&self) -> &Path { &self.project_dir } diff --git a/crates/ty/tests/cli/python_environment.rs b/crates/ty/tests/cli/python_environment.rs index bcaad42d67..8a63e5e267 100644 --- a/crates/ty/tests/cli/python_environment.rs +++ b/crates/ty/tests/cli/python_environment.rs @@ -292,6 +292,59 @@ fn python_version_inferred_from_system_installation() -> anyhow::Result<()> { Ok(()) } +/// On Unix systems, it's common for a Python installation at `.venv/bin/python` to only be a symlink +/// to a system Python installation. We must be careful not to resolve the symlink too soon! +/// If we do, we will incorrectly add the system installation's `site-packages` as a search path, +/// when we should be adding the virtual environment's `site-packages` directory as a search path instead. +#[cfg(unix)] +#[test] +fn python_argument_points_to_symlinked_executable() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ( + "system-installation/lib/python3.13/site-packages/foo.py", + "", + ), + ("system-installation/bin/python", ""), + ( + "strange-venv-location/lib/python3.13/site-packages/bar.py", + "", + ), + ( + "test.py", + "\ +import foo +import bar", + ), + ])?; + + case.write_symlink( + "system-installation/bin/python", + "strange-venv-location/bin/python", + )?; + + assert_cmd_snapshot!(case.command().arg("--python").arg("strange-venv-location/bin/python"), @r" + success: false + exit_code: 1 + ----- stdout ----- + error[unresolved-import]: Cannot resolve imported module `foo` + --> test.py:1:8 + | + 1 | import foo + | ^^^ + 2 | import bar + | + info: make sure your Python environment is properly configured: https://github.com/astral-sh/ty/blob/main/docs/README.md#python-environment + info: rule `unresolved-import` is enabled by default + + Found 1 diagnostic + + ----- stderr ----- + WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. + "); + + Ok(()) +} + #[test] fn pyvenv_cfg_file_annotation_showing_where_python_version_set() -> anyhow::Result<()> { let case = CliTest::with_files([ diff --git a/crates/ty_python_semantic/src/site_packages.rs b/crates/ty_python_semantic/src/site_packages.rs index bc3f6e626b..8a3e91b864 100644 --- a/crates/ty_python_semantic/src/site_packages.rs +++ b/crates/ty_python_semantic/src/site_packages.rs @@ -864,10 +864,47 @@ impl SysPrefixPath { origin: SysPrefixPathOrigin, system: &dyn System, ) -> SitePackagesDiscoveryResult { + let sys_prefix = if !origin.must_point_directly_to_sys_prefix() + && system.is_file(unvalidated_path) + && unvalidated_path + .file_name() + .is_some_and(|name| name.starts_with("python")) + { + // It looks like they passed us a path to a Python executable, e.g. `.venv/bin/python3`. + // Try to figure out the `sys.prefix` value from the Python executable. + let sys_prefix = if cfg!(windows) { + // On Windows, the relative path to the Python executable from `sys.prefix` + // is different depending on whether it's a virtual environment or a system installation. + // System installations have their executable at `/python.exe`, + // whereas virtual environments have their executable at `/Scripts/python.exe`. + unvalidated_path.parent().and_then(|parent| { + if parent.file_name() == Some("Scripts") { + parent.parent() + } else { + Some(parent) + } + }) + } else { + // On Unix, `sys.prefix` is always the grandparent directory of the Python executable, + // regardless of whether it's a virtual environment or a system installation. + unvalidated_path.ancestors().nth(2) + }; + let Some(sys_prefix) = sys_prefix else { + return Err(SitePackagesDiscoveryError::PathNotExecutableOrDirectory( + unvalidated_path.to_path_buf(), + origin, + None, + )); + }; + sys_prefix + } else { + unvalidated_path + }; + // It's important to resolve symlinks here rather than simply making the path absolute, // since system Python installations often only put symlinks in the "expected" // locations for `home` and `site-packages` - let canonicalized = match system.canonicalize_path(unvalidated_path) { + let sys_prefix = match system.canonicalize_path(sys_prefix) { Ok(path) => path, Err(io_err) => { let unvalidated_path = unvalidated_path.to_path_buf(); @@ -888,69 +925,19 @@ impl SysPrefixPath { } }; - if origin.must_point_directly_to_sys_prefix() { - return if system.is_directory(&canonicalized) { - Ok(Self { - inner: canonicalized, - origin, - }) - } else { - Err(SitePackagesDiscoveryError::PathNotExecutableOrDirectory( - unvalidated_path.to_path_buf(), - origin, - None, - )) - }; - } - - let sys_prefix = if system.is_file(&canonicalized) - && canonicalized - .file_name() - .is_some_and(|name| name.starts_with("python")) - { - // It looks like they passed us a path to a Python executable, e.g. `.venv/bin/python3`. - // Try to figure out the `sys.prefix` value from the Python executable. - let sys_prefix = if cfg!(windows) { - // On Windows, the relative path to the Python executable from `sys.prefix` - // is different depending on whether it's a virtual environment or a system installation. - // System installations have their executable at `/python.exe`, - // whereas virtual environments have their executable at `/Scripts/python.exe`. - canonicalized.parent().and_then(|parent| { - if parent.file_name() == Some("Scripts") { - parent.parent() - } else { - Some(parent) - } - }) - } else { - // On Unix, `sys.prefix` is always the grandparent directory of the Python executable, - // regardless of whether it's a virtual environment or a system installation. - canonicalized.ancestors().nth(2) - }; - let Some(sys_prefix) = sys_prefix else { - return Err(SitePackagesDiscoveryError::PathNotExecutableOrDirectory( - unvalidated_path.to_path_buf(), - origin, - None, - )); - }; - sys_prefix.to_path_buf() - } else if system.is_directory(&canonicalized) { - canonicalized - } else { + if !system.is_directory(&sys_prefix) { return Err(SitePackagesDiscoveryError::PathNotExecutableOrDirectory( unvalidated_path.to_path_buf(), origin, None, )); - }; + } Ok(Self { inner: sys_prefix, origin, }) } - fn from_executable_home_path(path: &PythonHomePath) -> Option { // No need to check whether `path.parent()` is a directory: // the parent of a canonicalised path that is known to exist