From 1d2128f918a2315a5fc81042b5417f9d8cf34282 Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Wed, 20 Aug 2025 09:07:42 -0400 Subject: [PATCH] [ty] distinguish base conda from child conda (#19990) This is a port of the logic in https://github.com/astral-sh/uv/pull/7691 The basic idea is we use CONDA_DEFAULT_ENV as a signal for whether CONDA_PREFIX is just the ambient system conda install, or the user has explicitly activated a custom one. If the former, then the conda is treated like a system install (having lowest priority). If the latter, the conda is treated like an activated venv (having priority over everything but an Actual activated venv). Fixes https://github.com/astral-sh/ty/issues/611 --- crates/ty/tests/cli/python_environment.rs | 521 +++++++++++++++++- .../ty_python_semantic/src/site_packages.rs | 77 ++- crates/ty_static/src/env_vars.rs | 3 + 3 files changed, 567 insertions(+), 34 deletions(-) diff --git a/crates/ty/tests/cli/python_environment.rs b/crates/ty/tests/cli/python_environment.rs index a3362b3875..3c9714d00e 100644 --- a/crates/ty/tests/cli/python_environment.rs +++ b/crates/ty/tests/cli/python_environment.rs @@ -900,59 +900,165 @@ fn defaults_to_a_new_python_version() -> anyhow::Result<()> { /// The `site-packages` directory is used by ty for external import. /// Ty does the following checks to discover the `site-packages` directory in the order: /// 1) If `VIRTUAL_ENV` environment variable is set -/// 2) If `CONDA_PREFIX` environment variable is set +/// 2) If `CONDA_PREFIX` environment variable is set (and .filename != `CONDA_DEFAULT_ENV`) /// 3) If a `.venv` directory exists at the project root +/// 4) If `CONDA_PREFIX` environment variable is set (and .filename == `CONDA_DEFAULT_ENV`) /// -/// This test is aiming at validating the logic around `CONDA_PREFIX`. +/// This test (and the next one) is aiming at validating the logic around these cases. /// -/// A conda-like environment file structure is used -/// We test by first not setting the `CONDA_PREFIX` and expect a fail. -/// Then we test by setting `CONDA_PREFIX` to `conda-env` and expect a pass. +/// To do this we create a program that has these 4 imports: +/// +/// ```python +/// from package1 import ActiveVenv +/// from package1 import ChildConda +/// from package1 import WorkingVenv +/// from package1 import BaseConda +/// ``` +/// +/// We then create 4 different copies of package1. Each copy defines all of these +/// classes... except the one that describes it. Therefore we know we got e.g. +/// the working venv if we get a diagnostic like this: +/// +/// ```text +/// Unresolved import +/// 4 | from package1 import WorkingVenv +/// | ^^^^^^^^^^^ +/// ``` +/// +/// This test uses a directory structure as follows: /// /// ├── project -/// │ └── test.py -/// └── conda-env -/// └── lib -/// └── python3.13 -/// └── site-packages -/// └── package1 -/// └── __init__.py +/// │ ├── test.py +/// │ └── .venv +/// │ ├── pyvenv.cfg +/// │ └── lib +/// │ └── python3.13 +/// │ └── site-packages +/// │ └── package1 +/// │ └── __init__.py +/// ├── myvenv +/// │ ├── pyvenv.cfg +/// │ └── lib +/// │ └── python3.13 +/// │ └── site-packages +/// │ └── package1 +/// │ └── __init__.py +/// ├── conda-env +/// │ └── lib +/// │ └── python3.13 +/// │ └── site-packages +/// │ └── package1 +/// │ └── __init__.py +/// └── conda +/// └── envs +/// └── base +/// └── lib +/// └── python3.13 +/// └── site-packages +/// └── package1 +/// └── __init__.py /// /// test.py imports package1 /// And the command is run in the `child` directory. #[test] -fn check_conda_prefix_var_to_resolve_path() -> anyhow::Result<()> { - let conda_package1_path = if cfg!(windows) { +fn check_venv_resolution_with_working_venv() -> anyhow::Result<()> { + let child_conda_package1_path = if cfg!(windows) { "conda-env/Lib/site-packages/package1/__init__.py" } else { "conda-env/lib/python3.13/site-packages/package1/__init__.py" }; + let base_conda_package1_path = if cfg!(windows) { + "conda/envs/base/Lib/site-packages/package1/__init__.py" + } else { + "conda/envs/base/lib/python3.13/site-packages/package1/__init__.py" + }; + + let working_venv_package1_path = if cfg!(windows) { + "project/.venv/Lib/site-packages/package1/__init__.py" + } else { + "project/.venv/lib/python3.13/site-packages/package1/__init__.py" + }; + + let active_venv_package1_path = if cfg!(windows) { + "myvenv/Lib/site-packages/package1/__init__.py" + } else { + "myvenv/lib/python3.13/site-packages/package1/__init__.py" + }; + let case = CliTest::with_files([ ( "project/test.py", r#" - import package1 + from package1 import ActiveVenv + from package1 import ChildConda + from package1 import WorkingVenv + from package1 import BaseConda "#, ), ( - conda_package1_path, + "project/.venv/pyvenv.cfg", r#" +home = ./ + + "#, + ), + ( + "myvenv/pyvenv.cfg", + r#" +home = ./ + + "#, + ), + ( + active_venv_package1_path, + r#" + class ChildConda: ... + class WorkingVenv: ... + class BaseConda: ... + "#, + ), + ( + child_conda_package1_path, + r#" + class ActiveVenv: ... + class WorkingVenv: ... + class BaseConda: ... + "#, + ), + ( + working_venv_package1_path, + r#" + class ActiveVenv: ... + class ChildConda: ... + class BaseConda: ... + "#, + ), + ( + base_conda_package1_path, + r#" + class ActiveVenv: ... + class ChildConda: ... + class WorkingVenv: ... "#, ), ])?; - assert_cmd_snapshot!(case.command().current_dir(case.root().join("project")), @r" + // Run with nothing set, should find the working venv + assert_cmd_snapshot!(case.command() + .current_dir(case.root().join("project")), @r" success: false exit_code: 1 ----- stdout ----- - error[unresolved-import]: Cannot resolve imported module `package1` - --> test.py:2:8 + error[unresolved-import]: Module `package1` has no member `WorkingVenv` + --> test.py:4:22 | - 2 | import package1 - | ^^^^^^^^ + 2 | from package1 import ActiveVenv + 3 | from package1 import ChildConda + 4 | from package1 import WorkingVenv + | ^^^^^^^^^^^ + 5 | from package1 import BaseConda | - info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment info: rule `unresolved-import` is enabled by default Found 1 diagnostic @@ -961,12 +1067,373 @@ fn check_conda_prefix_var_to_resolve_path() -> anyhow::Result<()> { WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. "); - // do command : CONDA_PREFIX=/conda_env - assert_cmd_snapshot!(case.command().current_dir(case.root().join("project")).env("CONDA_PREFIX", case.root().join("conda-env")), @r" - success: true - exit_code: 0 + // Run with VIRTUAL_ENV set, should find the active venv + assert_cmd_snapshot!(case.command() + .current_dir(case.root().join("project")) + .env("VIRTUAL_ENV", case.root().join("myvenv")), @r" + success: false + exit_code: 1 ----- stdout ----- - All checks passed! + error[unresolved-import]: Module `package1` has no member `ActiveVenv` + --> test.py:2:22 + | + 2 | from package1 import ActiveVenv + | ^^^^^^^^^^ + 3 | from package1 import ChildConda + 4 | from package1 import WorkingVenv + | + 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. + "); + + // run with CONDA_PREFIX set, should find the child conda + assert_cmd_snapshot!(case.command() + .current_dir(case.root().join("project")) + .env("CONDA_PREFIX", case.root().join("conda-env")), @r" + success: false + exit_code: 1 + ----- stdout ----- + error[unresolved-import]: Module `package1` has no member `ChildConda` + --> test.py:3:22 + | + 2 | from package1 import ActiveVenv + 3 | from package1 import ChildConda + | ^^^^^^^^^^ + 4 | from package1 import WorkingVenv + 5 | from package1 import BaseConda + | + 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. + "); + + // run with CONDA_PREFIX and CONDA_DEFAULT_ENV set (unequal), should find child conda + assert_cmd_snapshot!(case.command() + .current_dir(case.root().join("project")) + .env("CONDA_PREFIX", case.root().join("conda-env")) + .env("CONDA_DEFAULT_ENV", "base"), @r" + success: false + exit_code: 1 + ----- stdout ----- + error[unresolved-import]: Module `package1` has no member `ChildConda` + --> test.py:3:22 + | + 2 | from package1 import ActiveVenv + 3 | from package1 import ChildConda + | ^^^^^^^^^^ + 4 | from package1 import WorkingVenv + 5 | from package1 import BaseConda + | + 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. + "); + + // run with CONDA_PREFIX and CONDA_DEFAULT_ENV (unequal) and VIRTUAL_ENV set, + // should find child active venv + assert_cmd_snapshot!(case.command() + .current_dir(case.root().join("project")) + .env("CONDA_PREFIX", case.root().join("conda-env")) + .env("CONDA_DEFAULT_ENV", "base") + .env("VIRTUAL_ENV", case.root().join("myvenv")), @r" + success: false + exit_code: 1 + ----- stdout ----- + error[unresolved-import]: Module `package1` has no member `ActiveVenv` + --> test.py:2:22 + | + 2 | from package1 import ActiveVenv + | ^^^^^^^^^^ + 3 | from package1 import ChildConda + 4 | from package1 import WorkingVenv + | + 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. + "); + + // run with CONDA_PREFIX and CONDA_DEFAULT_ENV (equal!) set, should find working venv + assert_cmd_snapshot!(case.command() + .current_dir(case.root().join("project")) + .env("CONDA_PREFIX", case.root().join("conda/envs/base")) + .env("CONDA_DEFAULT_ENV", "base"), @r" + success: false + exit_code: 1 + ----- stdout ----- + error[unresolved-import]: Module `package1` has no member `WorkingVenv` + --> test.py:4:22 + | + 2 | from package1 import ActiveVenv + 3 | from package1 import ChildConda + 4 | from package1 import WorkingVenv + | ^^^^^^^^^^^ + 5 | from package1 import BaseConda + | + 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(()) +} + +/// The exact same test as above, but without a working venv +/// +/// In this case the Base Conda should be a possible outcome. +#[test] +fn check_venv_resolution_without_working_venv() -> anyhow::Result<()> { + let child_conda_package1_path = if cfg!(windows) { + "conda-env/Lib/site-packages/package1/__init__.py" + } else { + "conda-env/lib/python3.13/site-packages/package1/__init__.py" + }; + + let base_conda_package1_path = if cfg!(windows) { + "conda/envs/base/Lib/site-packages/package1/__init__.py" + } else { + "conda/envs/base/lib/python3.13/site-packages/package1/__init__.py" + }; + + let active_venv_package1_path = if cfg!(windows) { + "myvenv/Lib/site-packages/package1/__init__.py" + } else { + "myvenv/lib/python3.13/site-packages/package1/__init__.py" + }; + + let case = CliTest::with_files([ + ( + "project/test.py", + r#" + from package1 import ActiveVenv + from package1 import ChildConda + from package1 import WorkingVenv + from package1 import BaseConda + "#, + ), + ( + "myvenv/pyvenv.cfg", + r#" +home = ./ + + "#, + ), + ( + active_venv_package1_path, + r#" + class ChildConda: ... + class WorkingVenv: ... + class BaseConda: ... + "#, + ), + ( + child_conda_package1_path, + r#" + class ActiveVenv: ... + class WorkingVenv: ... + class BaseConda: ... + "#, + ), + ( + base_conda_package1_path, + r#" + class ActiveVenv: ... + class ChildConda: ... + class WorkingVenv: ... + "#, + ), + ])?; + + // Run with nothing set, should fail to find anything + assert_cmd_snapshot!(case.command() + .current_dir(case.root().join("project")), @r" + success: false + exit_code: 1 + ----- stdout ----- + error[unresolved-import]: Cannot resolve imported module `package1` + --> test.py:2:6 + | + 2 | from package1 import ActiveVenv + | ^^^^^^^^ + 3 | from package1 import ChildConda + 4 | from package1 import WorkingVenv + | + info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment + info: rule `unresolved-import` is enabled by default + + error[unresolved-import]: Cannot resolve imported module `package1` + --> test.py:3:6 + | + 2 | from package1 import ActiveVenv + 3 | from package1 import ChildConda + | ^^^^^^^^ + 4 | from package1 import WorkingVenv + 5 | from package1 import BaseConda + | + info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment + info: rule `unresolved-import` is enabled by default + + error[unresolved-import]: Cannot resolve imported module `package1` + --> test.py:4:6 + | + 2 | from package1 import ActiveVenv + 3 | from package1 import ChildConda + 4 | from package1 import WorkingVenv + | ^^^^^^^^ + 5 | from package1 import BaseConda + | + info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment + info: rule `unresolved-import` is enabled by default + + error[unresolved-import]: Cannot resolve imported module `package1` + --> test.py:5:6 + | + 3 | from package1 import ChildConda + 4 | from package1 import WorkingVenv + 5 | from package1 import BaseConda + | ^^^^^^^^ + | + info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment + info: rule `unresolved-import` is enabled by default + + Found 4 diagnostics + + ----- stderr ----- + WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. + "); + + // Run with VIRTUAL_ENV set, should find the active venv + assert_cmd_snapshot!(case.command() + .current_dir(case.root().join("project")) + .env("VIRTUAL_ENV", case.root().join("myvenv")), @r" + success: false + exit_code: 1 + ----- stdout ----- + error[unresolved-import]: Module `package1` has no member `ActiveVenv` + --> test.py:2:22 + | + 2 | from package1 import ActiveVenv + | ^^^^^^^^^^ + 3 | from package1 import ChildConda + 4 | from package1 import WorkingVenv + | + 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. + "); + + // run with CONDA_PREFIX set, should find the child conda + assert_cmd_snapshot!(case.command() + .current_dir(case.root().join("project")) + .env("CONDA_PREFIX", case.root().join("conda-env")), @r" + success: false + exit_code: 1 + ----- stdout ----- + error[unresolved-import]: Module `package1` has no member `ChildConda` + --> test.py:3:22 + | + 2 | from package1 import ActiveVenv + 3 | from package1 import ChildConda + | ^^^^^^^^^^ + 4 | from package1 import WorkingVenv + 5 | from package1 import BaseConda + | + 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. + "); + + // run with CONDA_PREFIX and CONDA_DEFAULT_ENV set (unequal), should find child conda + assert_cmd_snapshot!(case.command() + .current_dir(case.root().join("project")) + .env("CONDA_PREFIX", case.root().join("conda-env")) + .env("CONDA_DEFAULT_ENV", "base"), @r" + success: false + exit_code: 1 + ----- stdout ----- + error[unresolved-import]: Module `package1` has no member `ChildConda` + --> test.py:3:22 + | + 2 | from package1 import ActiveVenv + 3 | from package1 import ChildConda + | ^^^^^^^^^^ + 4 | from package1 import WorkingVenv + 5 | from package1 import BaseConda + | + 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. + "); + + // run with CONDA_PREFIX and CONDA_DEFAULT_ENV (unequal) and VIRTUAL_ENV set, + // should find child active venv + assert_cmd_snapshot!(case.command() + .current_dir(case.root().join("project")) + .env("CONDA_PREFIX", case.root().join("conda-env")) + .env("CONDA_DEFAULT_ENV", "base") + .env("VIRTUAL_ENV", case.root().join("myvenv")), @r" + success: false + exit_code: 1 + ----- stdout ----- + error[unresolved-import]: Module `package1` has no member `ActiveVenv` + --> test.py:2:22 + | + 2 | from package1 import ActiveVenv + | ^^^^^^^^^^ + 3 | from package1 import ChildConda + 4 | from package1 import WorkingVenv + | + 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. + "); + + // run with CONDA_PREFIX and CONDA_DEFAULT_ENV (equal!) set, should find base conda + assert_cmd_snapshot!(case.command() + .current_dir(case.root().join("project")) + .env("CONDA_PREFIX", case.root().join("conda/envs/base")) + .env("CONDA_DEFAULT_ENV", "base"), @r" + success: false + exit_code: 1 + ----- stdout ----- + error[unresolved-import]: Module `package1` has no member `BaseConda` + --> test.py:5:22 + | + 3 | from package1 import ChildConda + 4 | from package1 import WorkingVenv + 5 | from package1 import BaseConda + | ^^^^^^^^^ + | + 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. diff --git a/crates/ty_python_semantic/src/site_packages.rs b/crates/ty_python_semantic/src/site_packages.rs index dd01a49771..1e94670365 100644 --- a/crates/ty_python_semantic/src/site_packages.rs +++ b/crates/ty_python_semantic/src/site_packages.rs @@ -139,6 +139,12 @@ pub enum PythonEnvironment { } impl PythonEnvironment { + /// Discover the python environment using the following priorities: + /// + /// 1. activated virtual environment + /// 2. conda (child) + /// 3. working dir virtual environment + /// 4. conda (base) pub fn discover( project_root: &SystemPath, system: &dyn System, @@ -161,13 +167,9 @@ impl PythonEnvironment { .map(Some); } - if let Ok(conda_env) = system.env_var(EnvVars::CONDA_PREFIX) { - return resolve_environment( - system, - SystemPath::new(&conda_env), - SysPrefixPathOrigin::CondaPrefixVar, - ) - .map(Some); + if let Some(conda_env) = conda_environment_from_env(system, CondaEnvironmentKind::Child) { + return resolve_environment(system, &conda_env, SysPrefixPathOrigin::CondaPrefixVar) + .map(Some); } tracing::debug!("Discovering virtual environment in `{project_root}`"); @@ -190,6 +192,11 @@ impl PythonEnvironment { } } + if let Some(conda_env) = conda_environment_from_env(system, CondaEnvironmentKind::Base) { + return resolve_environment(system, &conda_env, SysPrefixPathOrigin::CondaPrefixVar) + .map(Some); + } + Ok(None) } @@ -589,6 +596,62 @@ System stdlib will not be used for module definitions.", } } +/// Different kinds of conda environment +#[derive(Debug, PartialEq, Eq, Copy, Clone)] +pub(crate) enum CondaEnvironmentKind { + /// The base Conda environment; treated like a system Python environment. + Base, + /// Any other Conda environment; treated like a virtual environment. + Child, +} + +impl CondaEnvironmentKind { + /// Compute the kind of `CONDA_PREFIX` we have. + /// + /// When the base environment is used, `CONDA_DEFAULT_ENV` will be set to a name, i.e., `base` or + /// `root` which does not match the prefix, e.g. `/usr/local` instead of + /// `/usr/local/conda/envs/`. + fn from_prefix_path(system: &dyn System, path: &SystemPath) -> Self { + // If we cannot read `CONDA_DEFAULT_ENV`, there's no way to know if the base environment + let Ok(default_env) = system.env_var(EnvVars::CONDA_DEFAULT_ENV) else { + return CondaEnvironmentKind::Child; + }; + + // These are the expected names for the base environment + if default_env != "base" && default_env != "root" { + return CondaEnvironmentKind::Child; + } + + let Some(name) = path.file_name() else { + return CondaEnvironmentKind::Child; + }; + + if name == default_env { + CondaEnvironmentKind::Base + } else { + CondaEnvironmentKind::Child + } + } +} + +/// Read `CONDA_PREFIX` and confirm that it has the expected kind +pub(crate) fn conda_environment_from_env( + system: &dyn System, + kind: CondaEnvironmentKind, +) -> Option { + let dir = system + .env_var(EnvVars::CONDA_PREFIX) + .ok() + .filter(|value| !value.is_empty())?; + let path = SystemPathBuf::from(dir); + + if kind != CondaEnvironmentKind::from_prefix_path(system, &path) { + return None; + } + + Some(path) +} + /// A parser for `pyvenv.cfg` files: metadata files for virtual environments. /// /// Note that a `pyvenv.cfg` file *looks* like a `.ini` file, but actually isn't valid `.ini` syntax! diff --git a/crates/ty_static/src/env_vars.rs b/crates/ty_static/src/env_vars.rs index 178dd46a7e..ebad90e8e5 100644 --- a/crates/ty_static/src/env_vars.rs +++ b/crates/ty_static/src/env_vars.rs @@ -42,6 +42,9 @@ impl EnvVars { /// Used to detect an activated virtual environment. pub const VIRTUAL_ENV: &'static str = "VIRTUAL_ENV"; + /// Used to determine if an active Conda environment is the base environment or not. + pub const CONDA_DEFAULT_ENV: &'static str = "CONDA_DEFAULT_ENV"; + /// Used to detect an activated Conda environment location. /// If both `VIRTUAL_ENV` and `CONDA_PREFIX` are present, `VIRTUAL_ENV` will be preferred. pub const CONDA_PREFIX: &'static str = "CONDA_PREFIX";