[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
This commit is contained in:
Aria Desires 2025-08-20 09:07:42 -04:00 committed by GitHub
parent 276405b44e
commit 1d2128f918
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 567 additions and 34 deletions

View file

@ -900,59 +900,165 @@ fn defaults_to_a_new_python_version() -> anyhow::Result<()> {
/// The `site-packages` directory is used by ty for external import. /// 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: /// Ty does the following checks to discover the `site-packages` directory in the order:
/// 1) If `VIRTUAL_ENV` environment variable is set /// 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 /// 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 /// To do this we create a program that has these 4 imports:
/// 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. /// ```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 /// ├── project
/// │ └── test.py /// │ ├── test.py
/// └── conda-env /// │ └── .venv
/// └── lib /// │ ├── pyvenv.cfg
/// └── python3.13 /// │ └── lib
/// └── site-packages /// │ └── python3.13
/// └── package1 /// │ └── site-packages
/// └── __init__.py /// │ └── 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 /// test.py imports package1
/// And the command is run in the `child` directory. /// And the command is run in the `child` directory.
#[test] #[test]
fn check_conda_prefix_var_to_resolve_path() -> anyhow::Result<()> { fn check_venv_resolution_with_working_venv() -> anyhow::Result<()> {
let conda_package1_path = if cfg!(windows) { let child_conda_package1_path = if cfg!(windows) {
"conda-env/Lib/site-packages/package1/__init__.py" "conda-env/Lib/site-packages/package1/__init__.py"
} else { } else {
"conda-env/lib/python3.13/site-packages/package1/__init__.py" "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([ let case = CliTest::with_files([
( (
"project/test.py", "project/test.py",
r#" 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#" 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 success: false
exit_code: 1 exit_code: 1
----- stdout ----- ----- stdout -----
error[unresolved-import]: Cannot resolve imported module `package1` error[unresolved-import]: Module `package1` has no member `WorkingVenv`
--> test.py:2:8 --> 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 info: rule `unresolved-import` is enabled by default
Found 1 diagnostic 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. 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=<temp_dir>/conda_env // Run with VIRTUAL_ENV set, should find the active venv
assert_cmd_snapshot!(case.command().current_dir(case.root().join("project")).env("CONDA_PREFIX", case.root().join("conda-env")), @r" assert_cmd_snapshot!(case.command()
success: true .current_dir(case.root().join("project"))
exit_code: 0 .env("VIRTUAL_ENV", case.root().join("myvenv")), @r"
success: false
exit_code: 1
----- stdout ----- ----- 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 ----- ----- stderr -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.

View file

@ -139,6 +139,12 @@ pub enum PythonEnvironment {
} }
impl 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( pub fn discover(
project_root: &SystemPath, project_root: &SystemPath,
system: &dyn System, system: &dyn System,
@ -161,13 +167,9 @@ impl PythonEnvironment {
.map(Some); .map(Some);
} }
if let Ok(conda_env) = system.env_var(EnvVars::CONDA_PREFIX) { if let Some(conda_env) = conda_environment_from_env(system, CondaEnvironmentKind::Child) {
return resolve_environment( return resolve_environment(system, &conda_env, SysPrefixPathOrigin::CondaPrefixVar)
system, .map(Some);
SystemPath::new(&conda_env),
SysPrefixPathOrigin::CondaPrefixVar,
)
.map(Some);
} }
tracing::debug!("Discovering virtual environment in `{project_root}`"); 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) 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/<name>`.
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<SystemPathBuf> {
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. /// 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! /// Note that a `pyvenv.cfg` file *looks* like a `.ini` file, but actually isn't valid `.ini` syntax!

View file

@ -42,6 +42,9 @@ impl EnvVars {
/// Used to detect an activated virtual environment. /// Used to detect an activated virtual environment.
pub const VIRTUAL_ENV: &'static str = "VIRTUAL_ENV"; 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. /// Used to detect an activated Conda environment location.
/// If both `VIRTUAL_ENV` and `CONDA_PREFIX` are present, `VIRTUAL_ENV` will be preferred. /// If both `VIRTUAL_ENV` and `CONDA_PREFIX` are present, `VIRTUAL_ENV` will be preferred.
pub const CONDA_PREFIX: &'static str = "CONDA_PREFIX"; pub const CONDA_PREFIX: &'static str = "CONDA_PREFIX";