diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 26d9f3ff501a..b8722cba3ffb 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2633,9 +2633,12 @@ pub struct RunArgs { /// 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, value_parser = parse_file_path, env = EnvVars::UV_ENV_FILE)] - pub env_file: Option, + #[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)] diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index d98171c70aba..23b6aa48ecf2 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,7 +79,7 @@ pub(crate) async fn run( native_tls: bool, cache: &Cache, printer: Printer, - env_file: Option, + env_file: Vec, no_env_file: bool, ) -> anyhow::Result { // These cases seem quite complex because (in theory) they should change the "current package". @@ -109,52 +109,58 @@ pub(crate) async fn run( // Read from the `.env` file, if necessary. if !no_env_file { - let env_file_path = env_file.as_deref().unwrap_or_else(|| Path::new(".env")); - match dotenvy::from_path(env_file_path) { - Err(dotenvy::Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => { - if env_file.is_none() { - debug!( - "No environment file found at: `{}`", - env_file_path.simplified_display() + 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(), ); - } else { - bail!( - "No environment file found at: `{}`", - env_file_path.simplified_display() + } + Err(err) => { + warn_user!( + "Failed to parse environment file `{}`: {err}", + env_file_path.simplified_display(), ); } - } - Err(dotenvy::Error::Io(err)) => { - if env_file.is_none() { + Ok(()) => { debug!( - "Failed to read environment file `{}`: {err}", - env_file_path.simplified_display() - ); - } else { - bail!( - "Failed to read environment file `{}`: {err}", + "Read environment file at: `{}`", 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() - ); - } } } diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index a022f039ae3c..3954844e48ed 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -244,7 +244,7 @@ pub(crate) struct RunSettings { pub(crate) python: Option, pub(crate) refresh: Refresh, pub(crate) settings: ResolverInstallerSettings, - pub(crate) env_file: Option, + pub(crate) env_file: Vec, pub(crate) no_env_file: bool, } diff --git a/crates/uv/tests/it/run.rs b/crates/uv/tests/it/run.rs index 9f8742aecaf7..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 ----- @@ -2396,8 +2394,7 @@ fn run_stdin_with_pep723() -> Result<()> { fn run_with_env() -> Result<()> { let context = TestContext::new("3.12"); - let test_script = context.temp_dir.child("test.py"); - test_script.write_str(indoc! { " + context.temp_dir.child("test.py").write_str(indoc! { " import os print(os.environ.get('THE_EMPIRE_VARIABLE')) print(os.environ.get('REBEL_1')) @@ -2406,8 +2403,7 @@ fn run_with_env() -> Result<()> { " })?; - let env_file = context.temp_dir.child(".env"); - env_file.write_str(indoc! { " + context.temp_dir.child(".env").write_str(indoc! { " THE_EMPIRE_VARIABLE=palpatine REBEL_1=leia_organa REBEL_2=obi_wan_kenobi @@ -2415,10 +2411,7 @@ fn run_with_env() -> Result<()> { " })?; - let mut command = context.run(); - let command_with_args = command.arg("test.py"); - - uv_snapshot!(context.filters(), command_with_args,@r###" + uv_snapshot!(context.filters(), context.run().arg("test.py"), @r###" success: true exit_code: 0 ----- stdout ----- @@ -2434,11 +2427,10 @@ fn run_with_env() -> Result<()> { } #[test] -fn run_with_parent_env() -> Result<()> { +fn run_with_env_file() -> Result<()> { let context = TestContext::new("3.12"); - let test_script = context.temp_dir.child("test").child("test.py"); - test_script.write_str(indoc! { " + context.temp_dir.child("test.py").write_str(indoc! { " import os print(os.environ.get('THE_EMPIRE_VARIABLE')) print(os.environ.get('REBEL_1')) @@ -2447,8 +2439,7 @@ fn run_with_parent_env() -> Result<()> { " })?; - let env_file = context.temp_dir.child(".env"); - env_file.write_str(indoc! { " + context.temp_dir.child(".file").write_str(indoc! { " THE_EMPIRE_VARIABLE=palpatine REBEL_1=leia_organa REBEL_2=obi_wan_kenobi @@ -2456,19 +2447,14 @@ fn run_with_parent_env() -> Result<()> { " })?; - let mut command = context.run(); - let command_with_args = command - .arg("test.py") - .current_dir(context.temp_dir.child("test")); - - uv_snapshot!(context.filters(), command_with_args,@r###" + uv_snapshot!(context.filters(), context.run().arg("--env-file").arg(".file").arg("test.py"), @r###" success: true exit_code: 0 ----- stdout ----- - None - None - None - None + palpatine + leia_organa + obi_wan_kenobi + C3PO ----- stderr ----- "###); @@ -2477,100 +2463,143 @@ fn run_with_parent_env() -> Result<()> { } #[test] -fn run_with_env_omitted() -> Result<()> { +fn run_with_multiple_env_files() -> Result<()> { let context = TestContext::new("3.12"); - let test_script = context.temp_dir.child("test.py"); - test_script.write_str(indoc! { " + 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')) " })?; - let env_file = context.temp_dir.child(".env"); - env_file.write_str(indoc! { " + context.temp_dir.child(".env1").write_str(indoc! { " THE_EMPIRE_VARIABLE=palpatine + REBEL_1=leia_organa " })?; - let mut command = context.run(); - let command_with_args = command.arg("--no-env-file").arg("test.py"); + context.temp_dir.child(".env2").write_str(indoc! { " + THE_EMPIRE_VARIABLE=palpatine + REBEL_1=obi_wan_kenobi + REBEL_2=C3PO + " + })?; - uv_snapshot!(context.filters(), command_with_args,@r###" + 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 ----- - None + 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_malformed_env() -> Result<()> { +fn run_with_env_omitted() -> Result<()> { let context = TestContext::new("3.12"); - let test_script = context.temp_dir.child("test.py"); - test_script.write_str(indoc! { " + context.temp_dir.child("test.py").write_str(indoc! { " import os print(os.environ.get('THE_EMPIRE_VARIABLE')) " })?; - let env_file = context.temp_dir.child(".env"); - env_file.write_str(indoc! { " - THE_^EMPIRE_VARIABLE=darth_vader + context.temp_dir.child(".env").write_str(indoc! { " + THE_EMPIRE_VARIABLE=palpatine " })?; - let mut command = context.run(); - let command_with_args = command.arg("test.py"); - - uv_snapshot!(context.filters(), command_with_args,@r###" + uv_snapshot!(context.filters(), context.run().arg("--no-env-file").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_specific_env_file() -> Result<()> { +fn run_with_parent_env() -> Result<()> { let context = TestContext::new("3.12"); - let test_script = context.temp_dir.child("test.py"); - test_script.write_str(indoc! { " + 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 " })?; - let env_file = context.temp_dir.child(".env.development"); - env_file.write_str(indoc! { " - THE_EMPIRE_VARIABLE=sidious + 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')) " })?; - let mut command = context.run(); - let command_with_args = command - .arg("--env-file") - .arg(".env.development") - .arg("test.py"); + context.temp_dir.child(".env").write_str(indoc! { " + THE_^EMPIRE_VARIABLE=darth_vader + " + })?; - uv_snapshot!(context.filters(), command_with_args,@r###" + uv_snapshot!(context.filters(), context.run().arg("test.py"), @r###" success: true exit_code: 0 ----- stdout ----- - sidious + None ----- stderr ----- + warning: Failed to parse environment file `.env` at position 4: THE_^EMPIRE_VARIABLE=darth_vader "###); Ok(()) @@ -2580,26 +2609,19 @@ fn run_with_specific_env_file() -> Result<()> { fn run_with_not_existing_env_file() -> Result<()> { let context = TestContext::new("3.12"); - let test_script = context.temp_dir.child("test.py"); - test_script.write_str(indoc! { " + context.temp_dir.child("test.py").write_str(indoc! { " import os print(os.environ.get('THE_EMPIRE_VARIABLE')) " })?; - let mut command = context.run(); - let command_with_args = command - .arg("--env-file") - .arg(".env.development") - .arg("test.py"); - 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, command_with_args,@r###" + uv_snapshot!(filters, context.run().arg("--env-file").arg(".env.development").arg("test.py"), @r###" success: false exit_code: 2 ----- stdout ----- diff --git a/docs/configuration/files.md b/docs/configuration/files.md index de78f91436f7..86152bffd278 100644 --- a/docs/configuration/files.md +++ b/docs/configuration/files.md @@ -78,11 +78,18 @@ By default, `uv run` will load environment variables from a `.env` file in the c directory, following the discovery and parsing rules of the [`dotenvy`](https://github.com/allan2/dotenvy) crate. -If the same variable is defined in the environment and in the file, the value from the environment -will take precedence. +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`. -To disable this behavior, set `UV_NO_ENV_FILE=1` in the environment, or pass the `--no-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 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 873676073157..6ead947353c0 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -135,6 +135,8 @@ uv run [OPTIONS] [COMMAND]
--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.