diff --git a/Cargo.lock b/Cargo.lock index 19107f3f81c5..0e88f5fe9d78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -950,6 +950,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" @@ -4178,6 +4184,7 @@ dependencies = [ "clap", "console", "ctrlc", + "dotenvy", "etcetera", "filetime", "flate2", diff --git a/Cargo.toml b/Cargo.toml index b5be4b26df97..6713e56236d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -93,6 +93,7 @@ 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-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 714a36a91e90..195536f7f30a 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -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 = "" @@ -2656,6 +2659,16 @@ pub struct RunArgs { #[arg(long)] pub no_editable: bool, + /// Load environment variables from a `.env` file. + /// + /// 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, + + /// 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-static/src/env_vars.rs b/crates/uv-static/src/env_vars.rs index 4719f504729b..6f26cd4fe28e 100644 --- a/crates/uv-static/src/env_vars.rs +++ b/crates/uv-static/src/env_vars.rs @@ -519,4 +519,10 @@ impl EnvVars { /// Used to set test credentials for keyring tests. #[attr_hidden] 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/Cargo.toml b/crates/uv/Cargo.toml index 4572de35f6f3..aa5d36d4feef 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/run.rs b/crates/uv/src/commands/project/run.rs index a7465cbd31e5..1889ad549320 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -81,6 +81,8 @@ pub(crate) async fn run( native_tls: bool, cache: &Cache, printer: Printer, + env_file: Option, + 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. @@ -107,6 +109,57 @@ 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_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() + ); + } else { + bail!( + "No environment file found at: `{}`", + env_file_path.simplified_display() + ); + } + } + Err(dotenvy::Error::Io(err)) => { + if env_file.is_none() { + 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); diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index b171de7b1f26..f038413b687b 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; @@ -1309,6 +1310,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 3dedd2408eb6..ceda35a23cd9 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -242,6 +242,8 @@ pub(crate) struct RunSettings { pub(crate) python: Option, pub(crate) refresh: Refresh, pub(crate) settings: ResolverInstallerSettings, + pub(crate) env_file: Option, + pub(crate) no_env_file: bool, } impl RunSettings { @@ -277,6 +279,8 @@ impl RunSettings { no_project, python, show_resolution, + env_file, + no_env_file, } = args; Self { @@ -308,6 +312,8 @@ impl RunSettings { resolver_installer_options(installer, build), filesystem, ), + env_file, + no_env_file, } } } diff --git a/crates/uv/tests/it/run.rs b/crates/uv/tests/it/run.rs index e7bef85f219d..97abdc6b888d 100644 --- a/crates/uv/tests/it/run.rs +++ b/crates/uv/tests/it/run.rs @@ -2831,3 +2831,222 @@ fn run_stdin_with_pep723() -> Result<()> { Ok(()) } + +#[test] +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! { " + 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')) + " + })?; + + let env_file = context.temp_dir.child(".env"); + env_file.write_str(indoc! { " + THE_EMPIRE_VARIABLE=palpatine + REBEL_1=leia_organa + REBEL_2=obi_wan_kenobi + REBEL_3=C3PO + " + })?; + + let mut command = context.run(); + let command_with_args = command.arg("test.py"); + + uv_snapshot!(context.filters(), command_with_args,@r###" + success: true + exit_code: 0 + ----- stdout ----- + palpatine + leia_organa + obi_wan_kenobi + C3PO + + ----- stderr ----- + "###); + + Ok(()) +} + +#[test] +fn run_with_parent_env() -> Result<()> { + let context = TestContext::new("3.12"); + + let test_script = context.temp_dir.child("test").child("test.py"); + test_script.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')) + " + })?; + + let env_file = context.temp_dir.child(".env"); + env_file.write_str(indoc! { " + THE_EMPIRE_VARIABLE=palpatine + REBEL_1=leia_organa + REBEL_2=obi_wan_kenobi + REBEL_3=C3PO + " + })?; + + 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###" + success: true + exit_code: 0 + ----- stdout ----- + None + None + None + None + + ----- stderr ----- + "###); + + Ok(()) +} + +#[test] +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! { " + 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=palpatine + " + })?; + + let mut command = context.run(); + let command_with_args = command.arg("--no-env-file").arg("test.py"); + + uv_snapshot!(context.filters(), command_with_args,@r###" + success: true + exit_code: 0 + ----- stdout ----- + None + + ----- stderr ----- + "###); + + Ok(()) +} + +#[test] +fn run_with_malformed_env() -> Result<()> { + let context = TestContext::new("3.12"); + + let test_script = context.temp_dir.child("test.py"); + test_script.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 + " + })?; + + let mut command = context.run(); + let command_with_args = command.arg("test.py"); + + uv_snapshot!(context.filters(), command_with_args,@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<()> { + let context = TestContext::new("3.12"); + + let test_script = context.temp_dir.child("test.py"); + test_script.write_str(indoc! { " + import os + print(os.environ.get('THE_EMPIRE_VARIABLE')) + " + })?; + + let env_file = context.temp_dir.child(".env.development"); + env_file.write_str(indoc! { " + THE_EMPIRE_VARIABLE=sidious + " + })?; + + let mut command = context.run(); + let command_with_args = command + .arg("--env-file") + .arg(".env.development") + .arg("test.py"); + + uv_snapshot!(context.filters(), command_with_args,@r###" + success: true + exit_code: 0 + ----- stdout ----- + sidious + + ----- stderr ----- + "###); + + Ok(()) +} + +#[test] +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! { " + 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###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No environment file found at: `.env.development` + "###); + + Ok(()) +} diff --git a/docs/configuration/environment.md b/docs/configuration/environment.md index 8956d56c33de..a1cecf3504a5 100644 --- a/docs/configuration/environment.md +++ b/docs/configuration/environment.md @@ -174,3 +174,5 @@ uv respects the following environment variables: For example, `RUST_LOG=trace` will enable trace-level logging. See the [tracing documentation](https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#example-syntax) for more. +- [`UV_ENV_FILE`](#UV_ENV_FILE): Used to overwrite path for loading `.env` files when executing `uv run` commands. +- [`UV_NO_ENV_FILE`](#UV_NO_ENV_FILE): Used to ignore `.env` files when executing `uv run` commands. diff --git a/docs/configuration/files.md b/docs/configuration/files.md index 640321e6beb8..de78f91436f7 100644 --- a/docs/configuration/files.md +++ b/docs/configuration/files.md @@ -72,6 +72,18 @@ 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. + +If the same variable is defined in the environment and in the file, the value from the environment +will take precedence. + +To disable this behavior, set `UV_NO_ENV_FILE=1` in the environment, or pass the `--no-env-file` +flag to `uv run`. + ## 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 7ae6e6ab4268..5870c9fbc76a 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

``` @@ -139,6 +141,11 @@ uv run [OPTIONS] [COMMAND]

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

+
--env-file env-file

Load environment variables from a .env file.

+ +

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.

@@ -296,6 +303,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-group no-group

Exclude dependencies from the specified dependency group.

May be provided multiple times.