diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 919a926eddcf..1b67f802f5ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -275,6 +275,7 @@ jobs: 3.10 3.11 3.12 + 3.13 - uses: Swatinem/rust-cache@v2 with: diff --git a/.python-versions b/.python-versions index c78335fbdd4d..013b3f8a9fb9 100644 --- a/.python-versions +++ b/.python-versions @@ -1,3 +1,4 @@ +3.13.0 3.12.6 3.11.10 3.10.15 diff --git a/Cargo.lock b/Cargo.lock index b8c50ccffd24..b9eb34e3692c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -911,15 +911,6 @@ dependencies = [ "crypto-common", ] -[[package]] -name = "directories" -version = "5.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" -dependencies = [ - "dirs-sys", -] - [[package]] name = "dirs" version = "5.0.1" @@ -947,6 +938,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "dunce" version = "1.0.5" @@ -4139,6 +4136,7 @@ dependencies = [ "clap", "console", "ctrlc", + "dotenvy", "etcetera", "filetime", "flate2", @@ -4317,6 +4315,7 @@ dependencies = [ "tokio", "toml_edit", "tracing", + "uv-cache", "uv-configuration", "uv-distribution", "uv-distribution-types", @@ -4536,8 +4535,6 @@ dependencies = [ name = "uv-dirs" version = "0.0.1" dependencies = [ - "directories", - "dirs-sys", "etcetera", "uv-static", ] @@ -5178,7 +5175,7 @@ version = "0.0.1" dependencies = [ "assert_fs", "clap", - "dirs-sys", + "etcetera", "fs-err", "indoc", "schemars", @@ -5289,6 +5286,7 @@ dependencies = [ "pathdiff", "thiserror", "tracing", + "uv-cache", "uv-fs", "uv-platform-tags", "uv-pypi-types", @@ -5549,7 +5547,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 36618fe2b8e2..c88dd7c40207 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,8 +91,7 @@ csv = { version = "1.3.0" } ctrlc = { version = "3.4.5" } dashmap = { version = "6.1.0" } data-encoding = { version = "2.6.0" } -directories = { version = "5.0.1" } -dirs-sys = { version = "0.4.1" } +dotenvy = { version = "0.15.7" } dunce = { version = "1.0.5" } either = { version = "1.13.0" } encoding_rs_io = { version = "0.1.7" } diff --git a/crates/uv-build-frontend/Cargo.toml b/crates/uv-build-frontend/Cargo.toml index a0e8fd89a560..07694337eda6 100644 --- a/crates/uv-build-frontend/Cargo.toml +++ b/crates/uv-build-frontend/Cargo.toml @@ -17,6 +17,7 @@ doctest = false workspace = true [dependencies] +uv-cache = { workspace = true } uv-configuration = { workspace = true } uv-distribution = { workspace = true } uv-distribution-types = { workspace = true } diff --git a/crates/uv-build-frontend/src/lib.rs b/crates/uv-build-frontend/src/lib.rs index ca7eded39348..58b8d9f33976 100644 --- a/crates/uv-build-frontend/src/lib.rs +++ b/crates/uv-build-frontend/src/lib.rs @@ -26,7 +26,7 @@ use tokio::io::AsyncBufReadExt; use tokio::process::Command; use tokio::sync::{Mutex, Semaphore}; use tracing::{debug, info_span, instrument, Instrument}; - +use uv_cache::Cache; use uv_configuration::{BuildKind, BuildOutput, ConfigSettings, LowerBound, SourceStrategy}; use uv_distribution::RequiresDist; use uv_distribution_types::{IndexLocations, Resolution}; @@ -260,6 +260,7 @@ impl SourceBuild { mut environment_variables: FxHashMap, level: BuildOutput, concurrent_builds: usize, + cache: &Cache, ) -> Result { let temp_dir = build_context.cache().environment()?; @@ -306,6 +307,7 @@ impl SourceBuild { false, false, false, + cache, )? }; diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 865a04f6a195..d01c9e084527 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -179,7 +179,7 @@ pub struct GlobalArgs { conflicts_with = "no_color", value_name = "COLOR_CHOICE" )] - pub color: ColorChoice, + pub color: Option, /// Whether to load TLS certificates from the platform's native certificate store. /// @@ -618,6 +618,9 @@ pub enum ProjectCommand { /// arguments to uv. All options to uv must be provided before the command, /// e.g., `uv run --verbose foo`. A `--` can be used to separate the command /// from uv options for clarity, e.g., `uv run --python 3.12 -- python`. + /// + /// Respects `.env` files in the current directory unless `--no-env-file` is + /// provided. #[command( after_help = "Use `uv help run` for more details.", after_long_help = "" @@ -2628,6 +2631,19 @@ pub struct RunArgs { #[arg(long)] pub no_editable: bool, + /// Load environment variables from a `.env` file. + /// + /// Can be provided multiple times, with subsequent files overriding values defined in + /// previous files. + /// + /// Defaults to reading `.env` in the current working directory. + #[arg(long, env = EnvVars::UV_ENV_FILE)] + pub env_file: Vec, + + /// Avoid reading environment variables from a `.env` file. + #[arg(long, conflicts_with = "env_file", value_parser = clap::builder::BoolishValueParser::new(), env = EnvVars::UV_NO_ENV_FILE)] + pub no_env_file: bool, + /// The command to run. /// /// If the path to a Python script (i.e., ending in `.py`), it will be diff --git a/crates/uv-dirs/Cargo.toml b/crates/uv-dirs/Cargo.toml index d32a4a4e3ccf..408c101fd3f2 100644 --- a/crates/uv-dirs/Cargo.toml +++ b/crates/uv-dirs/Cargo.toml @@ -19,6 +19,4 @@ workspace = true [dependencies] uv-static = { workspace = true } -dirs-sys = { workspace = true } -directories = { workspace = true } etcetera = { workspace = true } diff --git a/crates/uv-dirs/src/lib.rs b/crates/uv-dirs/src/lib.rs index 759a4df75dd2..464f95eaed2c 100644 --- a/crates/uv-dirs/src/lib.rs +++ b/crates/uv-dirs/src/lib.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::{ffi::OsString, path::PathBuf}; use etcetera::BaseStrategy; @@ -20,19 +20,15 @@ use uv_static::EnvVars; pub fn user_executable_directory(override_variable: Option<&'static str>) -> Option { override_variable .and_then(std::env::var_os) - .and_then(dirs_sys::is_absolute_path) - .or_else(|| std::env::var_os(EnvVars::XDG_BIN_HOME).and_then(dirs_sys::is_absolute_path)) + .and_then(parse_path) + .or_else(|| std::env::var_os(EnvVars::XDG_BIN_HOME).and_then(parse_path)) .or_else(|| { std::env::var_os(EnvVars::XDG_DATA_HOME) - .and_then(dirs_sys::is_absolute_path) + .and_then(parse_path) .map(|path| path.join("../bin")) }) .or_else(|| { - // See https://github.com/dirs-dev/dirs-rs/blob/50b50f31f3363b7656e5e63b3fa1060217cbc844/src/win.rs#L5C58-L5C78 - #[cfg(windows)] - let home_dir = dirs_sys::known_folder_profile(); - #[cfg(not(windows))] - let home_dir = dirs_sys::home_dir(); + let home_dir = etcetera::home_dir().ok(); home_dir.map(|path| path.join(".local").join("bin")) }) } @@ -51,7 +47,16 @@ pub fn user_cache_dir() -> Option { /// Uses `/Users/user/Library/Application Support/uv` on macOS, in contrast to the new preference /// for using the XDG directories on all Unix platforms. pub fn legacy_user_cache_dir() -> Option { - directories::ProjectDirs::from("", "", "uv").map(|dirs| dirs.cache_dir().to_path_buf()) + etcetera::base_strategy::choose_native_strategy() + .ok() + .map(|dirs| dirs.cache_dir().join("uv")) + .map(|dir| { + if cfg!(windows) { + dir.join("cache") + } else { + dir + } + }) } /// Returns an appropriate user-level directory for storing application state. @@ -68,5 +73,18 @@ pub fn user_state_dir() -> Option { /// Uses `/Users/user/Library/Application Support/uv` on macOS, in contrast to the new preference /// for using the XDG directories on all Unix platforms. pub fn legacy_user_state_dir() -> Option { - directories::ProjectDirs::from("", "", "uv").map(|dirs| dirs.data_dir().to_path_buf()) + etcetera::base_strategy::choose_native_strategy() + .ok() + .map(|dirs| dirs.data_dir().join("uv")) + .map(|dir| if cfg!(windows) { dir.join("data") } else { dir }) +} + +/// Return a [`PathBuf`] if the given [`OsString`] is an absolute path. +fn parse_path(path: OsString) -> Option { + let path = PathBuf::from(path); + if path.is_absolute() { + Some(path) + } else { + None + } } diff --git a/crates/uv-dispatch/src/lib.rs b/crates/uv-dispatch/src/lib.rs index 6fc529a2864b..49879f4ebc8e 100644 --- a/crates/uv-dispatch/src/lib.rs +++ b/crates/uv-dispatch/src/lib.rs @@ -368,6 +368,7 @@ impl<'a> BuildContext for BuildDispatch<'a> { self.build_extra_env_vars.clone(), build_output, self.concurrency.builds, + self.cache, ) .boxed_local() .await?; diff --git a/crates/uv-fs/src/path.rs b/crates/uv-fs/src/path.rs index 54941df43540..8c080370f6e8 100644 --- a/crates/uv-fs/src/path.rs +++ b/crates/uv-fs/src/path.rs @@ -1,4 +1,5 @@ use std::borrow::Cow; +use std::io; use std::path::{Component, Path, PathBuf}; use std::sync::LazyLock; @@ -236,7 +237,7 @@ pub fn normalize_path(path: &Path) -> PathBuf { } /// Like `fs_err::canonicalize`, but avoids attempting to resolve symlinks on Windows. -pub fn canonicalize_executable(path: impl AsRef) -> std::io::Result { +pub fn canonicalize_executable(path: impl AsRef) -> io::Result { let path = path.as_ref(); debug_assert!( path.is_absolute(), @@ -250,6 +251,24 @@ pub fn canonicalize_executable(path: impl AsRef) -> std::io::Result Result, io::Error> { + let Ok(link) = executable.read_link() else { + return Ok(None); + }; + if link.is_absolute() { + Ok(Some(link)) + } else { + executable + .parent() + .map(|parent| parent.join(link)) + .as_deref() + .map(normalize_absolute_path) + .transpose() + } +} + /// Compute a path describing `path` relative to `base`. /// /// `lib/python/site-packages/foo/__init__.py` and `lib/python/site-packages` -> `foo/__init__.py` diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index 549eb3808f86..aee120ab25fa 100644 --- a/crates/uv-python/src/discovery.rs +++ b/crates/uv-python/src/discovery.rs @@ -26,8 +26,8 @@ use crate::microsoft_store::find_microsoft_store_pythons; #[cfg(windows)] use crate::py_launcher::{registry_pythons, WindowsPython}; use crate::virtualenv::{ - conda_prefix_from_env, virtualenv_from_env, virtualenv_from_working_dir, - virtualenv_python_executable, + conda_environment_from_env, virtualenv_from_env, virtualenv_from_working_dir, + virtualenv_python_executable, CondaEnvironmentKind, }; use crate::{Interpreter, PythonVersion}; @@ -179,6 +179,8 @@ pub enum PythonSource { ActiveEnvironment, /// A conda environment was active e.g. via `CONDA_PREFIX` CondaPrefix, + /// A base conda environment was active e.g. via `CONDA_PREFIX` + BaseCondaPrefix, /// An environment was discovered e.g. via `.venv` DiscoveredEnvironment, /// An executable was found in the search path i.e. `PATH` @@ -227,18 +229,17 @@ pub enum Error { SourceNotAllowed(PythonRequest, PythonSource, PythonPreference), } -/// Lazily iterate over Python executables in mutable environments. +/// Lazily iterate over Python executables in mutable virtual environments. /// /// The following sources are supported: /// /// - Active virtual environment (via `VIRTUAL_ENV`) -/// - Active conda environment (via `CONDA_PREFIX`) /// - Discovered virtual environment (e.g. `.venv` in a parent directory) /// /// Notably, "system" environments are excluded. See [`python_executables_from_installed`]. -fn python_executables_from_environments<'a>( +fn python_executables_from_virtual_environments<'a>( ) -> impl Iterator> + 'a { - let from_virtual_environment = std::iter::once_with(|| { + let from_active_environment = std::iter::once_with(|| { virtualenv_from_env() .into_iter() .map(virtualenv_python_executable) @@ -246,8 +247,9 @@ fn python_executables_from_environments<'a>( }) .flatten(); + // N.B. we prefer the conda environment over discovered virtual environments let from_conda_environment = std::iter::once_with(|| { - conda_prefix_from_env() + conda_environment_from_env(CondaEnvironmentKind::Child) .into_iter() .map(virtualenv_python_executable) .map(|path| Ok((PythonSource::CondaPrefix, path))) @@ -265,7 +267,7 @@ fn python_executables_from_environments<'a>( }) .flatten_ok(); - from_virtual_environment + from_active_environment .chain(from_conda_environment) .chain(from_discovered_environment) } @@ -400,23 +402,35 @@ fn python_executables<'a>( }) .flatten(); - let from_environments = python_executables_from_environments(); + // Check if the the base conda environment is active + let from_base_conda_environment = std::iter::once_with(|| { + conda_environment_from_env(CondaEnvironmentKind::Base) + .into_iter() + .map(virtualenv_python_executable) + .map(|path| Ok((PythonSource::BaseCondaPrefix, path))) + }) + .flatten(); + + let from_virtual_environments = python_executables_from_virtual_environments(); let from_installed = python_executables_from_installed(version, implementation, preference); // Limit the search to the relevant environment preference; we later validate that they match // the preference but queries are expensive and we query less interpreters this way. match environments { EnvironmentPreference::OnlyVirtual => { - Box::new(from_parent_interpreter.chain(from_environments)) + Box::new(from_parent_interpreter.chain(from_virtual_environments)) } EnvironmentPreference::ExplicitSystem | EnvironmentPreference::Any => Box::new( from_parent_interpreter - .chain(from_environments) + .chain(from_virtual_environments) + .chain(from_base_conda_environment) + .chain(from_installed), + ), + EnvironmentPreference::OnlySystem => Box::new( + from_parent_interpreter + .chain(from_base_conda_environment) .chain(from_installed), ), - EnvironmentPreference::OnlySystem => { - Box::new(from_parent_interpreter.chain(from_installed)) - } } } @@ -611,8 +625,8 @@ fn satisfies_environment_preference( ) -> bool { match ( preference, - // Conda environments are not conformant virtual environments but we treat them as such - interpreter.is_virtualenv() || matches!(source, PythonSource::CondaPrefix), + // Conda environments are not conformant virtual environments but we treat them as such. + interpreter.is_virtualenv() || (matches!(source, PythonSource::CondaPrefix)), ) { (EnvironmentPreference::Any, _) => true, (EnvironmentPreference::OnlyVirtual, true) => true, @@ -1493,6 +1507,7 @@ impl PythonSource { Self::Managed | Self::Registry | Self::MicrosoftStore => false, Self::SearchPath | Self::CondaPrefix + | Self::BaseCondaPrefix | Self::ProvidedPath | Self::ParentInterpreter | Self::ActiveEnvironment @@ -1505,6 +1520,7 @@ impl PythonSource { match self { Self::Managed | Self::Registry | Self::SearchPath | Self::MicrosoftStore => false, Self::CondaPrefix + | Self::BaseCondaPrefix | Self::ProvidedPath | Self::ParentInterpreter | Self::ActiveEnvironment @@ -1826,6 +1842,7 @@ impl VersionRequest { Self::Default => match source { PythonSource::ParentInterpreter | PythonSource::CondaPrefix + | PythonSource::BaseCondaPrefix | PythonSource::ProvidedPath | PythonSource::DiscoveredEnvironment | PythonSource::ActiveEnvironment => Self::Any, @@ -2217,7 +2234,7 @@ impl fmt::Display for PythonSource { match self { Self::ProvidedPath => f.write_str("provided path"), Self::ActiveEnvironment => f.write_str("active virtual environment"), - Self::CondaPrefix => f.write_str("conda prefix"), + Self::CondaPrefix | Self::BaseCondaPrefix => f.write_str("conda prefix"), Self::DiscoveredEnvironment => f.write_str("virtual environment"), Self::SearchPath => f.write_str("search path"), Self::Registry => f.write_str("registry"), diff --git a/crates/uv-python/src/tests.rs b/crates/uv-python/src/tests.rs index ad5f2d495b1c..2c8a6475f742 100644 --- a/crates/uv-python/src/tests.rs +++ b/crates/uv-python/src/tests.rs @@ -949,6 +949,74 @@ fn find_python_from_conda_prefix() -> Result<()> { "We should allow the active conda python" ); + let baseenv = context.tempdir.child("base"); + TestContext::mock_conda_prefix(&baseenv, "3.12.1")?; + + // But not if it's a base environment + let result = context.run_with_vars( + &[ + ("CONDA_PREFIX", Some(baseenv.as_os_str())), + ("CONDA_DEFAULT_ENV", Some(&OsString::from("base"))), + ], + || { + find_python_installation( + &PythonRequest::Default, + EnvironmentPreference::OnlyVirtual, + PythonPreference::OnlySystem, + &context.cache, + ) + }, + )?; + + assert!( + matches!(result, Err(PythonNotFound { .. })), + "We should not allow the non-virtual environment; got {result:?}" + ); + + // Unless, system interpreters are included... + let python = context.run_with_vars( + &[ + ("CONDA_PREFIX", Some(baseenv.as_os_str())), + ("CONDA_DEFAULT_ENV", Some(&OsString::from("base"))), + ], + || { + find_python_installation( + &PythonRequest::Default, + EnvironmentPreference::OnlySystem, + PythonPreference::OnlySystem, + &context.cache, + ) + }, + )??; + + assert_eq!( + python.interpreter().python_full_version().to_string(), + "3.12.1", + "We should find the base conda environment" + ); + + // If the environment name doesn't match the default, we should not treat it as system + let python = context.run_with_vars( + &[ + ("CONDA_PREFIX", Some(condaenv.as_os_str())), + ("CONDA_DEFAULT_ENV", Some(&OsString::from("base"))), + ], + || { + find_python_installation( + &PythonRequest::Default, + EnvironmentPreference::OnlyVirtual, + PythonPreference::OnlySystem, + &context.cache, + ) + }, + )??; + + assert_eq!( + python.interpreter().python_full_version().to_string(), + "3.12.0", + "We should find the conda environment" + ); + Ok(()) } diff --git a/crates/uv-python/src/virtualenv.rs b/crates/uv-python/src/virtualenv.rs index 865aabf2096a..5fdfc094f029 100644 --- a/crates/uv-python/src/virtualenv.rs +++ b/crates/uv-python/src/virtualenv.rs @@ -57,15 +57,56 @@ pub(crate) fn virtualenv_from_env() -> Option { None } +#[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 { + /// Whether the given `CONDA_PREFIX` path is the base Conda environment. + /// + /// 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(path: &Path) -> Self { + // If we cannot read `CONDA_DEFAULT_ENV`, there's no way to know if the base environment + let Ok(default_env) = 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.to_str().is_some_and(|name| name == default_env) { + CondaEnvironmentKind::Base + } else { + CondaEnvironmentKind::Child + } + } +} + /// Locate an active conda environment by inspecting environment variables. /// -/// Supports `CONDA_PREFIX`. -pub(crate) fn conda_prefix_from_env() -> Option { - if let Some(dir) = env::var_os(EnvVars::CONDA_PREFIX).filter(|value| !value.is_empty()) { - return Some(PathBuf::from(dir)); - } +/// If `base` is true, the active environment must be the base environment or `None` is returned, +/// and vice-versa. +pub(crate) fn conda_environment_from_env(kind: CondaEnvironmentKind) -> Option { + let dir = env::var_os(EnvVars::CONDA_PREFIX).filter(|value| !value.is_empty())?; + let path = PathBuf::from(dir); - None + if kind != CondaEnvironmentKind::from_prefix_path(&path) { + return None; + }; + + Some(path) } /// Locate a virtual environment by searching the file system. diff --git a/crates/uv-resolver/src/prerelease.rs b/crates/uv-resolver/src/prerelease.rs index c452858775f1..c207186d0a9d 100644 --- a/crates/uv-resolver/src/prerelease.rs +++ b/crates/uv-resolver/src/prerelease.rs @@ -1,9 +1,9 @@ use uv_pypi_types::RequirementSource; -use uv_normalize::PackageName; - use crate::resolver::ForkSet; use crate::{DependencyMode, Manifest, ResolverMarkers}; +use uv_normalize::PackageName; +use uv_pep440::Operator; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields, rename_all = "kebab-case")] @@ -84,6 +84,9 @@ impl PrereleaseStrategy { if specifier .iter() + .filter(|spec| { + !matches!(spec.operator(), Operator::NotEqual | Operator::NotEqualStar) + }) .any(uv_pep440::VersionSpecifier::any_prerelease) { packages.add(&requirement, ()); diff --git a/crates/uv-settings/Cargo.toml b/crates/uv-settings/Cargo.toml index 1aca3d09c1f5..afee1de22f05 100644 --- a/crates/uv-settings/Cargo.toml +++ b/crates/uv-settings/Cargo.toml @@ -32,7 +32,7 @@ uv-static = { workspace = true } uv-warnings = { workspace = true } clap = { workspace = true } -dirs-sys = { workspace = true } +etcetera = { workspace = true } fs-err = { workspace = true } schemars = { workspace = true, optional = true } serde = { workspace = true } diff --git a/crates/uv-settings/src/lib.rs b/crates/uv-settings/src/lib.rs index 80ce9fd16f62..c493bbc66d23 100644 --- a/crates/uv-settings/src/lib.rs +++ b/crates/uv-settings/src/lib.rs @@ -2,6 +2,7 @@ use std::env; use std::ops::Deref; use std::path::{Path, PathBuf}; +use etcetera::BaseStrategy; use tracing::debug; use uv_fs::Simplified; @@ -176,23 +177,12 @@ impl From for FilesystemOptions { /// Returns the path to the user configuration directory. /// -/// This is similar to the `config_dir()` returned by the `dirs` crate, but it uses the -/// `XDG_CONFIG_HOME` environment variable on both Linux _and_ macOS, rather than the -/// `Application Support` directory on macOS. +/// On Windows, use, e.g., C:\Users\Alice\AppData\Roaming +/// On Linux and macOS, use `XDG_CONFIG_HOME` or $HOME/.config, e.g., /home/alice/.config. fn user_config_dir() -> Option { - // On Windows, use, e.g., `C:\Users\Alice\AppData\Roaming`. - #[cfg(windows)] - { - dirs_sys::known_folder_roaming_app_data() - } - - // On Linux and macOS, use, e.g., `/home/alice/.config`. - #[cfg(not(windows))] - { - env::var_os(EnvVars::XDG_CONFIG_HOME) - .and_then(dirs_sys::is_absolute_path) - .or_else(|| dirs_sys::home_dir().map(|path| path.join(".config"))) - } + etcetera::choose_base_strategy() + .map(|dirs| dirs.config_dir()) + .ok() } #[cfg(not(windows))] diff --git a/crates/uv-static/src/env_vars.rs b/crates/uv-static/src/env_vars.rs index fa5eb8de45e6..590c054297cf 100644 --- a/crates/uv-static/src/env_vars.rs +++ b/crates/uv-static/src/env_vars.rs @@ -247,6 +247,9 @@ impl EnvVars { /// Used to detect an activated Conda environment. pub const CONDA_PREFIX: &'static str = "CONDA_PREFIX"; + /// Used to determine if an active Conda environment is the base environment or not. + pub const CONDA_DEFAULT_ENV: &'static str = "CONDA_DEFAULT_ENV"; + /// Disables prepending virtual environment name to the terminal prompt. pub const VIRTUAL_ENV_DISABLE_PROMPT: &'static str = "VIRTUAL_ENV_DISABLE_PROMPT"; @@ -383,4 +386,10 @@ impl EnvVars { /// Used to set test credentials for keyring tests. pub const KEYRING_TEST_CREDENTIALS: &'static str = "KEYRING_TEST_CREDENTIALS"; + + /// Used to overwrite path for loading `.env` files when executing `uv run` commands. + pub const UV_ENV_FILE: &'static str = "UV_ENV_FILE"; + + /// Used to ignore `.env` files when executing `uv run` commands. + pub const UV_NO_ENV_FILE: &'static str = "UV_NO_ENV_FILE"; } diff --git a/crates/uv-tool/src/lib.rs b/crates/uv-tool/src/lib.rs index 631d6c026be8..415c455af0c7 100644 --- a/crates/uv-tool/src/lib.rs +++ b/crates/uv-tool/src/lib.rs @@ -1,5 +1,4 @@ use core::fmt; - use fs_err as fs; use uv_dirs::user_executable_directory; @@ -242,6 +241,7 @@ impl InstalledTools { &self, name: &PackageName, interpreter: Interpreter, + cache: &Cache, ) -> Result { let environment_path = self.tool_dir(name); @@ -271,6 +271,7 @@ impl InstalledTools { false, false, false, + cache, )?; Ok(venv) diff --git a/crates/uv-virtualenv/Cargo.toml b/crates/uv-virtualenv/Cargo.toml index 959a17e20361..4b7ee31089b8 100644 --- a/crates/uv-virtualenv/Cargo.toml +++ b/crates/uv-virtualenv/Cargo.toml @@ -20,6 +20,7 @@ doctest = false workspace = true [dependencies] +uv-cache = { workspace = true } uv-fs = { workspace = true } uv-platform-tags = { workspace = true } uv-pypi-types = { workspace = true } diff --git a/crates/uv-virtualenv/src/lib.rs b/crates/uv-virtualenv/src/lib.rs index 2dd164993e0c..ef1651a1ae67 100644 --- a/crates/uv-virtualenv/src/lib.rs +++ b/crates/uv-virtualenv/src/lib.rs @@ -2,7 +2,7 @@ use std::io; use std::path::Path; use thiserror::Error; - +use uv_cache::Cache; use uv_platform_tags::PlatformError; use uv_python::{Interpreter, PythonEnvironment}; @@ -12,6 +12,8 @@ mod virtualenv; pub enum Error { #[error(transparent)] Io(#[from] io::Error), + #[error(transparent)] + Interpreter(#[from] uv_python::InterpreterError), #[error("Failed to determine Python interpreter to use")] Discovery(#[from] uv_python::DiscoveryError), #[error("Failed to determine Python interpreter to use")] @@ -55,6 +57,7 @@ pub fn create_venv( allow_existing: bool, relocatable: bool, seed: bool, + cache: &Cache, ) -> Result { // Create the virtualenv at the given location. let virtualenv = virtualenv::create( @@ -65,6 +68,7 @@ pub fn create_venv( allow_existing, relocatable, seed, + cache, )?; // Create the corresponding `PythonEnvironment`. diff --git a/crates/uv-virtualenv/src/virtualenv.rs b/crates/uv-virtualenv/src/virtualenv.rs index 70f0ca22e06d..56d5f1d07683 100644 --- a/crates/uv-virtualenv/src/virtualenv.rs +++ b/crates/uv-virtualenv/src/virtualenv.rs @@ -9,7 +9,7 @@ use fs_err as fs; use fs_err::File; use itertools::Itertools; use tracing::debug; - +use uv_cache::Cache; use uv_fs::{cachedir, Simplified, CWD}; use uv_pypi_types::Scheme; use uv_python::{Interpreter, VirtualEnvironment}; @@ -52,15 +52,40 @@ pub(crate) fn create( allow_existing: bool, relocatable: bool, seed: bool, + cache: &Cache, ) -> Result { // Determine the base Python executable; that is, the Python executable that should be // considered the "base" for the virtual environment. This is typically the Python executable // from the [`Interpreter`]; however, if the interpreter is a virtual environment itself, then // the base Python executable is the Python executable of the interpreter's base interpreter. let base_python = if cfg!(unix) { - // On Unix, follow symlinks to resolve the base interpreter, since the Python executable in - // a virtual environment is a symlink to the base interpreter. - uv_fs::canonicalize_executable(interpreter.sys_executable())? + // If we're in virtual environment, resolve symlinks until we find a non-virtual interpreter. + if interpreter.is_virtualenv() { + if let Some(base_executable) = + uv_fs::read_executable_link(interpreter.sys_executable())? + { + let mut base_interpreter = Interpreter::query(base_executable, cache)?; + while base_interpreter.is_virtualenv() { + let Some(base_executable) = + uv_fs::read_executable_link(base_interpreter.sys_executable())? + else { + break; + }; + base_interpreter = Interpreter::query(base_executable, cache)?; + } + base_interpreter.sys_executable().to_path_buf() + } else { + // If the interpreter isn't a symlink, use `sys._base_executable` or, as a last + // resort, `sys.executable`. + if let Some(base_executable) = interpreter.sys_base_executable() { + base_executable.to_path_buf() + } else { + interpreter.sys_executable().to_path_buf() + } + } + } else { + interpreter.sys_executable().to_path_buf() + } } else if cfg!(windows) { // On Windows, follow `virtualenv`. If we're in a virtual environment, use // `sys._base_executable` if it exists; if not, use `sys.base_prefix`. For example, with diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml index f2a286a46656..6458cabc3841 100644 --- a/crates/uv/Cargo.toml +++ b/crates/uv/Cargo.toml @@ -63,6 +63,7 @@ axoupdater = { workspace = true, features = [ clap = { workspace = true, features = ["derive", "string", "wrap_help"] } console = { workspace = true } ctrlc = { workspace = true } +dotenvy = { workspace = true } flate2 = { workspace = true, default-features = false } fs-err = { workspace = true, features = ["tokio"] } futures = { workspace = true } diff --git a/crates/uv/src/commands/project/environment.rs b/crates/uv/src/commands/project/environment.rs index c49711576174..f3b144ca9c5c 100644 --- a/crates/uv/src/commands/project/environment.rs +++ b/crates/uv/src/commands/project/environment.rs @@ -105,6 +105,7 @@ impl CachedEnvironment { false, true, false, + cache, )?; sync_environment( diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index ed6fd4a01ab5..771677e8a2e7 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -593,6 +593,7 @@ pub(crate) async fn get_or_init_environment( false, false, false, + cache, )?) } } diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index ed94cc20a8d7..efe13a6cacb0 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -8,7 +8,7 @@ use std::path::{Path, PathBuf}; use anstream::eprint; use anyhow::{anyhow, bail, Context}; use futures::StreamExt; -use itertools::Itertools; +use itertools::{Either, Itertools}; use owo_colors::OwoColorize; use tokio::process::Command; use tracing::{debug, warn}; @@ -79,6 +79,8 @@ pub(crate) async fn run( native_tls: bool, cache: &Cache, printer: Printer, + env_file: Vec, + no_env_file: bool, ) -> anyhow::Result { // These cases seem quite complex because (in theory) they should change the "current package". // Let's ban them entirely for now. @@ -105,6 +107,63 @@ pub(crate) async fn run( // Initialize any shared state. let state = SharedState::default(); + // Read from the `.env` file, if necessary. + if !no_env_file { + let env_file_paths = if env_file.is_empty() { + Either::Left(std::iter::once(Path::new(".env"))) + } else { + Either::Right(env_file.iter().rev().map(PathBuf::as_path)) + }; + for env_file_path in env_file_paths { + match dotenvy::from_path(env_file_path) { + Err(dotenvy::Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => { + if env_file.is_empty() { + debug!( + "No environment file found at: `{}`", + env_file_path.simplified_display() + ); + } else { + bail!( + "No environment file found at: `{}`", + env_file_path.simplified_display() + ); + } + } + Err(dotenvy::Error::Io(err)) => { + if env_file.is_empty() { + debug!( + "Failed to read environment file `{}`: {err}", + env_file_path.simplified_display() + ); + } else { + bail!( + "Failed to read environment file `{}`: {err}", + env_file_path.simplified_display() + ); + } + } + Err(dotenvy::Error::LineParse(content, position)) => { + warn_user!( + "Failed to parse environment file `{}` at position {position}: {content}", + env_file_path.simplified_display(), + ); + } + Err(err) => { + warn_user!( + "Failed to parse environment file `{}`: {err}", + env_file_path.simplified_display(), + ); + } + Ok(()) => { + debug!( + "Read environment file at: `{}`", + env_file_path.simplified_display() + ); + } + } + } + } + // Initialize any output reporters. let download_reporter = PythonDownloadReporter::single(printer); @@ -313,6 +372,7 @@ pub(crate) async fn run( false, false, false, + cache, )?; Some(environment.into_interpreter()) @@ -511,6 +571,7 @@ pub(crate) async fn run( false, false, false, + cache, )? } else { // If we're not isolating the environment, reuse the base environment for the @@ -658,6 +719,7 @@ pub(crate) async fn run( false, false, false, + cache, )?; venv.into_interpreter() } else { @@ -707,6 +769,7 @@ pub(crate) async fn run( false, false, false, + cache, )? } Some(spec) => { diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index adb6f884ef3e..5b4722cb3eeb 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -382,7 +382,7 @@ pub(crate) async fn install( ) .await?; - let environment = installed_tools.create_environment(&from.name, interpreter)?; + let environment = installed_tools.create_environment(&from.name, interpreter, &cache)?; // At this point, we removed any existing environment, so we should remove any of its // executables. diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index 2296c62f88b6..42580db5e5b6 100644 --- a/crates/uv/src/commands/tool/upgrade.rs +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -265,7 +265,7 @@ async fn upgrade_tool( ) .await?; - let environment = installed_tools.create_environment(name, interpreter.clone())?; + let environment = installed_tools.create_environment(name, interpreter.clone(), cache)?; let environment = sync_environment( environment, diff --git a/crates/uv/src/commands/venv.rs b/crates/uv/src/commands/venv.rs index 0900eacbaf36..a1ff9fcaefcd 100644 --- a/crates/uv/src/commands/venv.rs +++ b/crates/uv/src/commands/venv.rs @@ -270,6 +270,7 @@ async fn venv_impl( allow_existing, relocatable, seed, + cache, ) .map_err(VenvError::Creation)?; diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index e9380b82872e..95afa9149cf9 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1,4 +1,5 @@ use std::borrow::Cow; +use std::env; use std::ffi::OsString; use std::fmt::Write; use std::io::stdout; @@ -1295,6 +1296,8 @@ async fn run_project( globals.native_tls, &cache, printer, + args.env_file, + args.no_env_file, )) .await } diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 1b7d57088482..c47109089c85 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -74,11 +74,14 @@ impl GlobalSettings { Self { quiet: args.quiet, verbose: args.verbose, - color: if args.no_color - || std::env::var_os(EnvVars::NO_COLOR) - .filter(|v| !v.is_empty()) - .is_some() + color: if let Some(color_choice) = args.color { + // If `--color` is passed explicitly, use its value. + color_choice + } else if std::env::var_os(EnvVars::NO_COLOR) + .filter(|v| !v.is_empty()) + .is_some() { + // If the `NO_COLOR` is set, disable color output. ColorChoice::Never } else if std::env::var_os(EnvVars::FORCE_COLOR) .filter(|v| !v.is_empty()) @@ -87,9 +90,10 @@ impl GlobalSettings { .filter(|v| !v.is_empty()) .is_some() { + // If `FORCE_COLOR` or `CLICOLOR_FORCE` is set, always enable color output. ColorChoice::Always } else { - args.color + ColorChoice::Auto }, native_tls: flag(args.native_tls, args.no_native_tls) .combine(workspace.and_then(|workspace| workspace.globals.native_tls)) @@ -240,6 +244,8 @@ pub(crate) struct RunSettings { pub(crate) python: Option, pub(crate) refresh: Refresh, pub(crate) settings: ResolverInstallerSettings, + pub(crate) env_file: Vec, + pub(crate) no_env_file: bool, } impl RunSettings { @@ -271,6 +277,8 @@ impl RunSettings { no_project, python, show_resolution, + env_file, + no_env_file, } = args; Self { @@ -299,6 +307,8 @@ impl RunSettings { resolver_installer_options(installer, build), filesystem, ), + env_file, + no_env_file, } } } diff --git a/crates/uv/tests/it/pip_compile.rs b/crates/uv/tests/it/pip_compile.rs index 964542dfe2da..68239d83b5ce 100644 --- a/crates/uv/tests/it/pip_compile.rs +++ b/crates/uv/tests/it/pip_compile.rs @@ -12418,3 +12418,39 @@ fn prune_unreachable() -> Result<()> { Ok(()) } + +#[test] +fn negation_not_imply_prerelease() -> Result<()> { + let context = TestContext::new("3.12"); + + let requirements_in = context.temp_dir.child("requirements.in"); + requirements_in.write_str("flask<2.0.1, !=2.0.0rc1")?; + uv_snapshot!(context + .pip_compile() + .arg("requirements.in"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv pip compile --cache-dir [CACHE_DIR] requirements.in + click==8.1.7 + # via flask + flask==2.0.0 + # via -r requirements.in + itsdangerous==2.1.2 + # via flask + jinja2==3.1.3 + # via flask + markupsafe==2.1.5 + # via + # jinja2 + # werkzeug + werkzeug==3.0.1 + # via flask + + ----- stderr ----- + Resolved 6 packages in [TIME] + "###); + + Ok(()) +} diff --git a/crates/uv/tests/it/run.rs b/crates/uv/tests/it/run.rs index f59837fb1705..a1d963c2af39 100644 --- a/crates/uv/tests/it/run.rs +++ b/crates/uv/tests/it/run.rs @@ -2373,9 +2373,7 @@ fn run_stdin_with_pep723() -> Result<()> { "# })?; - let mut command = context.run(); - let command_with_args = command.stdin(std::fs::File::open(test_script)?).arg("-"); - uv_snapshot!(context.filters(), command_with_args, @r###" + uv_snapshot!(context.filters(), context.run().stdin(std::fs::File::open(test_script)?).arg("-"), @r###" success: true exit_code: 0 ----- stdout ----- @@ -2391,3 +2389,246 @@ fn run_stdin_with_pep723() -> Result<()> { Ok(()) } + +#[test] +fn run_with_env() -> Result<()> { + let context = TestContext::new("3.12"); + + context.temp_dir.child("test.py").write_str(indoc! { " + import os + print(os.environ.get('THE_EMPIRE_VARIABLE')) + print(os.environ.get('REBEL_1')) + print(os.environ.get('REBEL_2')) + print(os.environ.get('REBEL_3')) + " + })?; + + context.temp_dir.child(".env").write_str(indoc! { " + THE_EMPIRE_VARIABLE=palpatine + REBEL_1=leia_organa + REBEL_2=obi_wan_kenobi + REBEL_3=C3PO + " + })?; + + uv_snapshot!(context.filters(), context.run().arg("test.py"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + palpatine + leia_organa + obi_wan_kenobi + C3PO + + ----- stderr ----- + "###); + + Ok(()) +} + +#[test] +fn run_with_env_file() -> Result<()> { + let context = TestContext::new("3.12"); + + context.temp_dir.child("test.py").write_str(indoc! { " + import os + print(os.environ.get('THE_EMPIRE_VARIABLE')) + print(os.environ.get('REBEL_1')) + print(os.environ.get('REBEL_2')) + print(os.environ.get('REBEL_3')) + " + })?; + + context.temp_dir.child(".file").write_str(indoc! { " + THE_EMPIRE_VARIABLE=palpatine + REBEL_1=leia_organa + REBEL_2=obi_wan_kenobi + REBEL_3=C3PO + " + })?; + + uv_snapshot!(context.filters(), context.run().arg("--env-file").arg(".file").arg("test.py"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + palpatine + leia_organa + obi_wan_kenobi + C3PO + + ----- stderr ----- + "###); + + Ok(()) +} + +#[test] +fn run_with_multiple_env_files() -> Result<()> { + let context = TestContext::new("3.12"); + + context.temp_dir.child("test.py").write_str(indoc! { " + import os + print(os.environ.get('THE_EMPIRE_VARIABLE')) + print(os.environ.get('REBEL_1')) + print(os.environ.get('REBEL_2')) + " + })?; + + context.temp_dir.child(".env1").write_str(indoc! { " + THE_EMPIRE_VARIABLE=palpatine + REBEL_1=leia_organa + " + })?; + + context.temp_dir.child(".env2").write_str(indoc! { " + THE_EMPIRE_VARIABLE=palpatine + REBEL_1=obi_wan_kenobi + REBEL_2=C3PO + " + })?; + + uv_snapshot!(context.filters(), context.run().arg("--env-file").arg(".env1").arg("--env-file").arg(".env2").arg("test.py"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + palpatine + obi_wan_kenobi + C3PO + + ----- stderr ----- + "###); + + uv_snapshot!(context.filters(), context.run().arg("test.py").env("UV_ENV_FILE", ".env1 .env2"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No environment file found at: `.env1 .env2` + "###); + + Ok(()) +} + +#[test] +fn run_with_env_omitted() -> Result<()> { + let context = TestContext::new("3.12"); + + context.temp_dir.child("test.py").write_str(indoc! { " + import os + print(os.environ.get('THE_EMPIRE_VARIABLE')) + " + })?; + + context.temp_dir.child(".env").write_str(indoc! { " + THE_EMPIRE_VARIABLE=palpatine + " + })?; + + uv_snapshot!(context.filters(), context.run().arg("--no-env-file").arg("test.py"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + None + + ----- stderr ----- + "###); + + Ok(()) +} + +#[test] +fn run_with_parent_env() -> Result<()> { + let context = TestContext::new("3.12"); + + context + .temp_dir + .child("test") + .child("test.py") + .write_str(indoc! { " + import os + print(os.environ.get('THE_EMPIRE_VARIABLE')) + print(os.environ.get('REBEL_1')) + print(os.environ.get('REBEL_2')) + print(os.environ.get('REBEL_3')) + " + })?; + + context.temp_dir.child(".env").write_str(indoc! { " + THE_EMPIRE_VARIABLE=palpatine + REBEL_1=leia_organa + REBEL_2=obi_wan_kenobi + REBEL_3=C3PO + " + })?; + + uv_snapshot!(context.filters(), context.run().arg("test.py").current_dir(context.temp_dir.child("test")), @r###" + success: true + exit_code: 0 + ----- stdout ----- + None + None + None + None + + ----- stderr ----- + "###); + + Ok(()) +} + +#[test] +fn run_with_malformed_env() -> Result<()> { + let context = TestContext::new("3.12"); + + context.temp_dir.child("test.py").write_str(indoc! { " + import os + print(os.environ.get('THE_EMPIRE_VARIABLE')) + " + })?; + + context.temp_dir.child(".env").write_str(indoc! { " + THE_^EMPIRE_VARIABLE=darth_vader + " + })?; + + uv_snapshot!(context.filters(), context.run().arg("test.py"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + None + + ----- stderr ----- + warning: Failed to parse environment file `.env` at position 4: THE_^EMPIRE_VARIABLE=darth_vader + "###); + + Ok(()) +} + +#[test] +fn run_with_not_existing_env_file() -> Result<()> { + let context = TestContext::new("3.12"); + + context.temp_dir.child("test.py").write_str(indoc! { " + import os + print(os.environ.get('THE_EMPIRE_VARIABLE')) + " + })?; + + let mut filters = context.filters(); + filters.push(( + r"(?m)^error: Failed to read environment file `.env.development`: .*$", + "error: Failed to read environment file `.env.development`: [ERR]", + )); + + uv_snapshot!(filters, context.run().arg("--env-file").arg(".env.development").arg("test.py"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No environment file found at: `.env.development` + "###); + + Ok(()) +} diff --git a/crates/uv/tests/it/venv.rs b/crates/uv/tests/it/venv.rs index 1bc4ece4e86a..767ca58f5d96 100644 --- a/crates/uv/tests/it/venv.rs +++ b/crates/uv/tests/it/venv.rs @@ -50,6 +50,28 @@ fn create_venv() { context.venv.assert(predicates::path::is_dir()); } +#[test] +fn create_venv_313() { + let context = TestContext::new_with_versions(&["3.13"]); + + uv_snapshot!(context.filters(), context.venv() + .arg(context.venv.as_os_str()) + .arg("--python") + .arg("3.13"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.13.[X] interpreter at: [PYTHON-3.13] + Creating virtual environment at: .venv + Activate with: source .venv/[BIN]/activate + "### + ); + + context.venv.assert(predicates::path::is_dir()); +} + #[test] fn create_venv_project_environment() -> Result<()> { let context = TestContext::new_with_versions(&["3.12"]); diff --git a/docs/configuration/environment.md b/docs/configuration/environment.md index 0806d54807eb..e86ef2c19269 100644 --- a/docs/configuration/environment.md +++ b/docs/configuration/environment.md @@ -85,6 +85,10 @@ uv accepts the following command-line arguments as environment variables: `uv.lock` remains unchanged. - `UV_FROZEN`: Equivalent to the `--frozen` command-line argument. If set, uv will run without updating the `uv.lock` file. +- `UV_ENV_FILE`: Equivalent to the `--env-file` command-line argument in `uv run`. If set, uv will + read environment variables from this `.env` file. +- `UV_NO_ENV_FILE`: Equivalent to the `--no-env-file` command-line argument in `uv run`. If set, uv + will not read environment variables from a `.env` file. In each case, the corresponding command-line argument takes precedence over an environment variable. diff --git a/docs/configuration/files.md b/docs/configuration/files.md index 640321e6beb8..86152bffd278 100644 --- a/docs/configuration/files.md +++ b/docs/configuration/files.md @@ -72,6 +72,25 @@ configuration files (e.g., user-level configuration will be ignored). See the [settings reference](../reference/settings.md) for an enumeration of the available settings. +## `.env` + +By default, `uv run` will load environment variables from a `.env` file in the current working +directory, following the discovery and parsing rules of the +[`dotenvy`](https://github.com/allan2/dotenvy) crate. + +To load a `.env` file from a dedicated location, set the `UV_ENV_FILE` environment variable, or pass +the `--env-file` flag to `uv run`. + +The `--env-file` flag can be provided multiple times, with subsequent files overriding values +defined in previous files. To provide multiple files via the `UV_ENV_FILE` environment variable, +separate the paths with a space (e.g., `UV_ENV_FILE="/path/to/file1 /path/to/file2"`). + +To disable this behavior, set the `UV_NO_ENV_FILE` environment variable to `1`, or pass the +`--no-env-file` flag to `uv run`. + +If the same variable is defined in the environment and in a `.env` file, the value from the +environment will take precedence. + ## Configuring the pip interface A dedicated [`[tool.uv.pip]`](../reference/settings.md#pip) section is provided for configuring diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 143e2d5e5705..d5755c150f44 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -64,6 +64,8 @@ When used outside a project, if a virtual environment can be found in the curren Arguments following the command (or script) are not interpreted as arguments to uv. All options to uv must be provided before the command, e.g., `uv run --verbose foo`. A `--` can be used to separate the command from uv options for clarity, e.g., `uv run --python 3.12 -- python`. +Respects `.env` files in the current directory unless `--no-env-file` is provided. +

Usage

``` @@ -131,6 +133,13 @@ uv run [OPTIONS] [COMMAND]

See --project to only change the project root directory.

+
--env-file env-file

Load environment variables from a .env file.

+ +

Can be provided multiple times, with subsequent files overriding values defined in previous files.

+ +

Defaults to reading .env in the current working directory.

+ +

May also be set with the UV_ENV_FILE environment variable.

--exclude-newer exclude-newer

Limit candidate packages to those that were uploaded prior to the given date.

Accepts both RFC 3339 timestamps (e.g., 2006-12-02T02:07:43Z) and local dates in the same format (e.g., 2006-12-02) in your system’s configured time zone.

@@ -282,6 +291,9 @@ uv run [OPTIONS] [COMMAND]
--no-editable

Install any editable dependencies, including the project and any workspace members, as non-editable

+
--no-env-file

Avoid reading environment variables from a .env file

+ +

May also be set with the UV_NO_ENV_FILE environment variable.

--no-index

Ignore the registry index (e.g., PyPI), instead relying on direct URL dependencies and those provided via --find-links

--no-progress

Hide all progress outputs.