Skip to content

Commit 8485dbb

Browse files
authored
[ty] Fix --python argument for Windows, and improve error messages for bad --python arguments (#18457)
## Summary Fixes astral-sh/ty#556. On Windows, system installations have different layouts to virtual environments. In Windows virtual environments, the Python executable is found at `<sys.prefix>/Scripts/python.exe`. But in Windows system installations, the Python executable is found at `<sys.prefix>/python.exe`. That means that Windows users were able to point to Python executables inside virtual environments with the `--python` flag, but they weren't able to point to Python executables inside system installations. This PR fixes that issue. It also makes a couple of other changes: - Nearly all `sys.prefix` resolution is moved inside `site_packages.rs`. That was the original design of the `site-packages` resolution logic, but features implemented since the initial implementation have added some resolution and validation to `resolver.rs` inside the module resolver. That means that we've ended up with a somewhat confusing code structure and a situation where several checks are unnecessarily duplicated between the two modules. - I noticed that we had quite bad error messages if you e.g. pointed to a path that didn't exist on disk with `--python` (we just gave a somewhat impenetrable message saying that we "failed to canonicalize" the path). I improved the error messages here and added CLI tests for `--python` and the `environment.python` configuration setting. ## Test Plan - Existing tests pass - Added new CLI tests - I manually checked that virtual-environment discovery still works if no configuration is given - Micha did some manual testing to check that pointing `--python` to a system-installation executable now works on Windows
1 parent 0858896 commit 8485dbb

File tree

8 files changed

+297
-116
lines changed

8 files changed

+297
-116
lines changed

crates/ruff/tests/analyze_graph.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -566,7 +566,7 @@ fn venv() -> Result<()> {
566566
----- stderr -----
567567
ruff failed
568568
Cause: Invalid search path settings
569-
Cause: Failed to discover the site-packages directory: Invalid `--python` argument: `none` could not be canonicalized
569+
Cause: Failed to discover the site-packages directory: Invalid `--python` argument: `none` does not point to a Python executable or a directory on disk
570570
");
571571
});
572572

crates/ruff_graph/src/db.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use ruff_python_ast::PythonVersion;
1010
use ty_python_semantic::lint::{LintRegistry, RuleSelection};
1111
use ty_python_semantic::{
1212
Db, Program, ProgramSettings, PythonPath, PythonPlatform, PythonVersionSource,
13-
PythonVersionWithSource, SearchPathSettings, default_lint_registry,
13+
PythonVersionWithSource, SearchPathSettings, SysPrefixPathOrigin, default_lint_registry,
1414
};
1515

1616
static EMPTY_VENDORED: std::sync::LazyLock<VendoredFileSystem> = std::sync::LazyLock::new(|| {
@@ -37,7 +37,8 @@ impl ModuleDb {
3737
) -> Result<Self> {
3838
let mut search_paths = SearchPathSettings::new(src_roots);
3939
if let Some(venv_path) = venv_path {
40-
search_paths.python_path = PythonPath::from_cli_flag(venv_path);
40+
search_paths.python_path =
41+
PythonPath::sys_prefix(venv_path, SysPrefixPathOrigin::PythonCliFlag);
4142
}
4243

4344
let db = Self::default();

crates/ty/tests/cli.rs

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -918,6 +918,156 @@ fn cli_unknown_rules() -> anyhow::Result<()> {
918918
Ok(())
919919
}
920920

921+
#[test]
922+
fn python_cli_argument_virtual_environment() -> anyhow::Result<()> {
923+
let path_to_executable = if cfg!(windows) {
924+
"my-venv/Scripts/python.exe"
925+
} else {
926+
"my-venv/bin/python"
927+
};
928+
929+
let other_venv_path = "my-venv/foo/some_other_file.txt";
930+
931+
let case = TestCase::with_files([
932+
("test.py", ""),
933+
(
934+
if cfg!(windows) {
935+
"my-venv/Lib/site-packages/foo.py"
936+
} else {
937+
"my-venv/lib/python3.13/site-packages/foo.py"
938+
},
939+
"",
940+
),
941+
(path_to_executable, ""),
942+
(other_venv_path, ""),
943+
])?;
944+
945+
// Passing a path to the installation works
946+
assert_cmd_snapshot!(case.command().arg("--python").arg("my-venv"), @r"
947+
success: true
948+
exit_code: 0
949+
----- stdout -----
950+
All checks passed!
951+
952+
----- stderr -----
953+
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
954+
");
955+
956+
// And so does passing a path to the executable inside the installation
957+
assert_cmd_snapshot!(case.command().arg("--python").arg(path_to_executable), @r"
958+
success: true
959+
exit_code: 0
960+
----- stdout -----
961+
All checks passed!
962+
963+
----- stderr -----
964+
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
965+
");
966+
967+
// But random other paths inside the installation are rejected
968+
assert_cmd_snapshot!(case.command().arg("--python").arg(other_venv_path), @r"
969+
success: false
970+
exit_code: 2
971+
----- stdout -----
972+
973+
----- stderr -----
974+
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
975+
ty failed
976+
Cause: Invalid search path settings
977+
Cause: Failed to discover the site-packages directory: Invalid `--python` argument: `<temp_dir>/my-venv/foo/some_other_file.txt` does not point to a Python executable or a directory on disk
978+
");
979+
980+
// And so are paths that do not exist on disk
981+
assert_cmd_snapshot!(case.command().arg("--python").arg("not-a-directory-or-executable"), @r"
982+
success: false
983+
exit_code: 2
984+
----- stdout -----
985+
986+
----- stderr -----
987+
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
988+
ty failed
989+
Cause: Invalid search path settings
990+
Cause: Failed to discover the site-packages directory: Invalid `--python` argument: `<temp_dir>/not-a-directory-or-executable` does not point to a Python executable or a directory on disk
991+
");
992+
993+
Ok(())
994+
}
995+
996+
#[test]
997+
fn python_cli_argument_system_installation() -> anyhow::Result<()> {
998+
let path_to_executable = if cfg!(windows) {
999+
"Python3.11/python.exe"
1000+
} else {
1001+
"Python3.11/bin/python"
1002+
};
1003+
1004+
let case = TestCase::with_files([
1005+
("test.py", ""),
1006+
(
1007+
if cfg!(windows) {
1008+
"Python3.11/Lib/site-packages/foo.py"
1009+
} else {
1010+
"Python3.11/lib/python3.11/site-packages/foo.py"
1011+
},
1012+
"",
1013+
),
1014+
(path_to_executable, ""),
1015+
])?;
1016+
1017+
// Passing a path to the installation works
1018+
assert_cmd_snapshot!(case.command().arg("--python").arg("Python3.11"), @r"
1019+
success: true
1020+
exit_code: 0
1021+
----- stdout -----
1022+
All checks passed!
1023+
1024+
----- stderr -----
1025+
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
1026+
");
1027+
1028+
// And so does passing a path to the executable inside the installation
1029+
assert_cmd_snapshot!(case.command().arg("--python").arg(path_to_executable), @r"
1030+
success: true
1031+
exit_code: 0
1032+
----- stdout -----
1033+
All checks passed!
1034+
1035+
----- stderr -----
1036+
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
1037+
");
1038+
1039+
Ok(())
1040+
}
1041+
1042+
#[test]
1043+
fn config_file_broken_python_setting() -> anyhow::Result<()> {
1044+
let case = TestCase::with_files([
1045+
(
1046+
"pyproject.toml",
1047+
r#"
1048+
[tool.ty.environment]
1049+
python = "not-a-directory-or-executable"
1050+
"#,
1051+
),
1052+
("test.py", ""),
1053+
])?;
1054+
1055+
// TODO: this error message should say "invalid `python` configuration setting" rather than "invalid `--python` argument"
1056+
assert_cmd_snapshot!(case.command(), @r"
1057+
success: false
1058+
exit_code: 2
1059+
----- stdout -----
1060+
1061+
----- stderr -----
1062+
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
1063+
ty failed
1064+
Cause: Invalid search path settings
1065+
Cause: Failed to discover the site-packages directory: Invalid `--python` argument: `<temp_dir>/not-a-directory-or-executable` does not point to a Python executable or a directory on disk
1066+
");
1067+
1068+
Ok(())
1069+
}
1070+
9211071
#[test]
9221072
fn exit_code_only_warnings() -> anyhow::Result<()> {
9231073
let case = TestCase::with_file("test.py", r"print(x) # [unresolved-reference]")?;

crates/ty_project/src/metadata/options.rs

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use thiserror::Error;
1212
use ty_python_semantic::lint::{GetLintError, Level, LintSource, RuleSelection};
1313
use ty_python_semantic::{
1414
ProgramSettings, PythonPath, PythonPlatform, PythonVersionFileSource, PythonVersionSource,
15-
PythonVersionWithSource, SearchPathSettings,
15+
PythonVersionWithSource, SearchPathSettings, SysPrefixPathOrigin,
1616
};
1717

1818
use super::settings::{Settings, TerminalSettings};
@@ -182,19 +182,27 @@ impl Options {
182182
custom_typeshed: typeshed.map(|path| path.absolute(project_root, system)),
183183
python_path: python
184184
.map(|python_path| {
185-
PythonPath::from_cli_flag(python_path.absolute(project_root, system))
185+
PythonPath::sys_prefix(
186+
python_path.absolute(project_root, system),
187+
SysPrefixPathOrigin::PythonCliFlag,
188+
)
186189
})
187190
.or_else(|| {
188-
std::env::var("VIRTUAL_ENV")
189-
.ok()
190-
.map(PythonPath::from_virtual_env_var)
191+
std::env::var("VIRTUAL_ENV").ok().map(|virtual_env| {
192+
PythonPath::sys_prefix(virtual_env, SysPrefixPathOrigin::VirtualEnvVar)
193+
})
191194
})
192195
.or_else(|| {
193-
std::env::var("CONDA_PREFIX")
194-
.ok()
195-
.map(PythonPath::from_conda_prefix_var)
196+
std::env::var("CONDA_PREFIX").ok().map(|path| {
197+
PythonPath::sys_prefix(path, SysPrefixPathOrigin::CondaPrefixVar)
198+
})
196199
})
197-
.unwrap_or_else(|| PythonPath::Discover(project_root.to_path_buf())),
200+
.unwrap_or_else(|| {
201+
PythonPath::sys_prefix(
202+
project_root.to_path_buf(),
203+
SysPrefixPathOrigin::LocalVenv,
204+
)
205+
}),
198206
}
199207
}
200208

crates/ty_python_semantic/src/module_resolver/resolver.rs

Lines changed: 20 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -139,15 +139,6 @@ pub(crate) fn search_paths(db: &dyn Db) -> SearchPathIterator {
139139
Program::get(db).search_paths(db).iter(db)
140140
}
141141

142-
/// Searches for a `.venv` directory in `project_root` that contains a `pyvenv.cfg` file.
143-
fn discover_venv_in(system: &dyn System, project_root: &SystemPath) -> Option<SystemPathBuf> {
144-
let virtual_env_directory = project_root.join(".venv");
145-
146-
system
147-
.is_file(&virtual_env_directory.join("pyvenv.cfg"))
148-
.then_some(virtual_env_directory)
149-
}
150-
151142
#[derive(Debug, PartialEq, Eq)]
152143
pub struct SearchPaths {
153144
/// Search paths that have been statically determined purely from reading Ruff's configuration settings.
@@ -243,68 +234,34 @@ impl SearchPaths {
243234
static_paths.push(stdlib_path);
244235

245236
let (site_packages_paths, python_version) = match python_path {
246-
PythonPath::SysPrefix(sys_prefix, origin) => {
247-
tracing::debug!(
248-
"Discovering site-packages paths from sys-prefix `{sys_prefix}` ({origin}')"
249-
);
250-
// TODO: We may want to warn here if the venv's python version is older
251-
// than the one resolved in the program settings because it indicates
252-
// that the `target-version` is incorrectly configured or that the
253-
// venv is out of date.
254-
PythonEnvironment::new(sys_prefix, *origin, system)?.into_settings(system)?
255-
}
256-
257-
PythonPath::Resolve(target, origin) => {
258-
tracing::debug!("Resolving {origin}: {target}");
259-
260-
let root = system
261-
// If given a file, assume it's a Python executable, e.g., `.venv/bin/python3`,
262-
// and search for a virtual environment in the root directory. Ideally, we'd
263-
// invoke the target to determine `sys.prefix` here, but that's more complicated
264-
// and may be deferred to uv.
265-
.is_file(target)
266-
.then(|| target.as_path())
267-
.take_if(|target| {
268-
// Avoid using the target if it doesn't look like a Python executable, e.g.,
269-
// to deny cases like `.venv/bin/foo`
270-
target
271-
.file_name()
272-
.is_some_and(|name| name.starts_with("python"))
273-
})
274-
.and_then(SystemPath::parent)
275-
.and_then(SystemPath::parent)
276-
// If not a file, use the path as given and allow let `PythonEnvironment::new`
277-
// handle the error.
278-
.unwrap_or(target);
279-
280-
PythonEnvironment::new(root, *origin, system)?.into_settings(system)?
281-
}
282-
283-
PythonPath::Discover(root) => {
284-
tracing::debug!("Discovering virtual environment in `{root}`");
285-
discover_venv_in(db.system(), root)
286-
.and_then(|virtual_env_path| {
287-
tracing::debug!("Found `.venv` folder at `{}`", virtual_env_path);
288-
289-
PythonEnvironment::new(
290-
virtual_env_path.clone(),
291-
SysPrefixPathOrigin::LocalVenv,
292-
system,
293-
)
294-
.and_then(|env| env.into_settings(system))
295-
.inspect_err(|err| {
237+
PythonPath::IntoSysPrefix(path, origin) => {
238+
if *origin == SysPrefixPathOrigin::LocalVenv {
239+
tracing::debug!("Discovering virtual environment in `{path}`");
240+
let virtual_env_directory = path.join(".venv");
241+
242+
PythonEnvironment::new(
243+
&virtual_env_directory,
244+
SysPrefixPathOrigin::LocalVenv,
245+
system,
246+
)
247+
.and_then(|venv| venv.into_settings(system))
248+
.inspect_err(|err| {
249+
if system.is_directory(&virtual_env_directory) {
296250
tracing::debug!(
297251
"Ignoring automatically detected virtual environment at `{}`: {}",
298-
virtual_env_path,
252+
&virtual_env_directory,
299253
err
300254
);
301-
})
302-
.ok()
255+
}
303256
})
304-
.unwrap_or_else(|| {
257+
.unwrap_or_else(|_| {
305258
tracing::debug!("No virtual environment found");
306259
(SitePackagesPaths::default(), None)
307260
})
261+
} else {
262+
tracing::debug!("Resolving {origin}: {path}");
263+
PythonEnvironment::new(path, *origin, system)?.into_settings(system)?
264+
}
308265
}
309266

310267
PythonPath::KnownSitePackages(paths) => (

crates/ty_python_semantic/src/program.rs

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -262,8 +262,10 @@ impl SearchPathSettings {
262262
#[derive(Debug, Clone, Eq, PartialEq)]
263263
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
264264
pub enum PythonPath {
265-
/// A path that represents the value of [`sys.prefix`] at runtime in Python
266-
/// for a given Python executable.
265+
/// A path that either represents the value of [`sys.prefix`] at runtime in Python
266+
/// for a given Python executable, or which represents a path relative to `sys.prefix`
267+
/// that we will attempt later to resolve into `sys.prefix`. Exactly which this variant
268+
/// represents depends on the [`SysPrefixPathOrigin`] element in the tuple.
267269
///
268270
/// For the case of a virtual environment, where a
269271
/// Python binary is at `/.venv/bin/python`, `sys.prefix` is the path to
@@ -275,13 +277,7 @@ pub enum PythonPath {
275277
/// `/opt/homebrew/lib/python3.X/site-packages`.
276278
///
277279
/// [`sys.prefix`]: https://docs.python.org/3/library/sys.html#sys.prefix
278-
SysPrefix(SystemPathBuf, SysPrefixPathOrigin),
279-
280-
/// Resolve a path to an executable (or environment directory) into a usable environment.
281-
Resolve(SystemPathBuf, SysPrefixPathOrigin),
282-
283-
/// Tries to discover a virtual environment in the given path.
284-
Discover(SystemPathBuf),
280+
IntoSysPrefix(SystemPathBuf, SysPrefixPathOrigin),
285281

286282
/// Resolved site packages paths.
287283
///
@@ -291,16 +287,8 @@ pub enum PythonPath {
291287
}
292288

293289
impl PythonPath {
294-
pub fn from_virtual_env_var(path: impl Into<SystemPathBuf>) -> Self {
295-
Self::SysPrefix(path.into(), SysPrefixPathOrigin::VirtualEnvVar)
296-
}
297-
298-
pub fn from_conda_prefix_var(path: impl Into<SystemPathBuf>) -> Self {
299-
Self::Resolve(path.into(), SysPrefixPathOrigin::CondaPrefixVar)
300-
}
301-
302-
pub fn from_cli_flag(path: SystemPathBuf) -> Self {
303-
Self::Resolve(path, SysPrefixPathOrigin::PythonCliFlag)
290+
pub fn sys_prefix(path: impl Into<SystemPathBuf>, origin: SysPrefixPathOrigin) -> Self {
291+
Self::IntoSysPrefix(path.into(), origin)
304292
}
305293
}
306294

0 commit comments

Comments
 (0)