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..52e437c37c41 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2656,6 +2656,17 @@ 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. + #[arg(long, env = EnvVars::UV_ENV_FILE)] + pub env_file: Vec, + + /// Avoid reading environment variables from a `.env` file. + #[arg(long, 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..124e9d416a40 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"; + + /// `.env` files from which to load environment variables when executing `uv run` commands. + pub const UV_ENV_FILE: &'static str = "UV_ENV_FILE"; + + /// 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..e308610463ed 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: 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. @@ -107,6 +109,44 @@ pub(crate) async fn run( // Initialize any shared state. let state = SharedState::default(); + // Read from the `.env` file, if necessary. + if !no_env_file { + for env_file_path in env_file.iter().rev().map(PathBuf::as_path) { + match dotenvy::from_path(env_file_path) { + Err(dotenvy::Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => { + bail!( + "No environment file found at: `{}`", + env_file_path.simplified_display() + ); + } + Err(dotenvy::Error::Io(err)) => { + 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..67220130837a 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: Vec, + 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..36056fec2ef3 100644 --- a/crates/uv/tests/it/run.rs +++ b/crates/uv/tests/it/run.rs @@ -2813,9 +2813,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 ----- @@ -2831,3 +2829,218 @@ 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 ----- + None + None + None + None + + ----- stderr ----- + "###); + + uv_snapshot!(context.filters(), context.run().arg("--env-file").arg(".env").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("--env-file").arg(".env").arg("--no-env-file").arg("test.py"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + 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("--env-file").arg(".env").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/docs/configuration/environment.md b/docs/configuration/environment.md index 8956d56c33de..06002d38780b 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): `.env` files from which to load environment variables when executing `uv run` commands. +- [`UV_NO_ENV_FILE`](#UV_NO_ENV_FILE): Ignore `.env` files when executing `uv run` commands. diff --git a/docs/configuration/files.md b/docs/configuration/files.md index 640321e6beb8..3ad6d3bc72b4 100644 --- a/docs/configuration/files.md +++ b/docs/configuration/files.md @@ -72,6 +72,31 @@ 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` + +`uv run` can load environment variables from dotenv files (e.g., `.env`, `.env.local`, +`.env.development`), powered by 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`. + +For example, to load environment variables from a `.env` file in the current working directory: + +```console +$ uv run --env-file .env -- echo $MY_ENV_VAR +``` + +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 dotenv loading (e.g., to override `UV_ENV_FILE` or the `--env-file` command-line +argument), 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 7ae6e6ab4268..cac93c92030f 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -139,6 +139,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.

+ +

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

+ +

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 +301,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.