mirror of
https://github.com/astral-sh/ruff.git
synced 2025-11-02 12:58:27 +00:00
[ty] Support --python=<symlink to executable> (#18827)
## Summary Fixes https://github.com/astral-sh/ty/issues/640. If a user passes `--python=<some-virtual-environment>/bin/python`, we must avoid canonicalizing the path until we've traversed upwards to find the `sys.prefix` directory (`<some-virtual-environment>`). On Unix systems, `<sys.prefix>/bin/python` is often a symlink to a system interpreter; if we resolve the symlink too easily then we'll add the system interpreter's `site-packages` directory as a search path rather than the virtual environment's directory. ## Test Plan I added an integration test to `crates/ty/tests/cli/python_environment.rs` which fails on `main`. I also manually tested locally that running `cargo run -p ty check foo.py --python=.venv/bin/python -vv` now prints this log to the terminal ``` 2025-06-20 18:35:24.57702 DEBUG Resolved site-packages directories for this virtual environment are: SitePackagesPaths({"/Users/alexw/dev/ruff/.venv/lib/python3.13/site-packages"}) ``` Whereas it previously resolved `site-packages` to my system intallation's `site-packages` directory
This commit is contained in:
parent
f32ae94bc3
commit
f24e650dfd
3 changed files with 123 additions and 57 deletions
|
|
@ -656,20 +656,46 @@ impl CliTest {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn write_file(&self, path: impl AsRef<Path>, 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<Path>, 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<Path>,
|
||||
link: impl AsRef<Path>,
|
||||
) -> 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -864,10 +864,47 @@ impl SysPrefixPath {
|
|||
origin: SysPrefixPathOrigin,
|
||||
system: &dyn System,
|
||||
) -> SitePackagesDiscoveryResult<Self> {
|
||||
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 `<sys.prefix>/python.exe`,
|
||||
// whereas virtual environments have their executable at `<sys.prefix>/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 `<sys.prefix>/python.exe`,
|
||||
// whereas virtual environments have their executable at `<sys.prefix>/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 {
|
||||
if !system.is_directory(&sys_prefix) {
|
||||
return Err(SitePackagesDiscoveryError::PathNotExecutableOrDirectory(
|
||||
unvalidated_path.to_path_buf(),
|
||||
origin,
|
||||
None,
|
||||
));
|
||||
};
|
||||
sys_prefix.to_path_buf()
|
||||
} else if system.is_directory(&canonicalized) {
|
||||
canonicalized
|
||||
} else {
|
||||
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<Self> {
|
||||
// No need to check whether `path.parent()` is a directory:
|
||||
// the parent of a canonicalised path that is known to exist
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue