diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 656195bc4..fbc6d430e 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -4093,6 +4093,17 @@ pub struct ToolRunArgs { #[arg(long)] pub isolated: bool, + /// Load environment variables from a `.env` file. + /// + /// Can be provided multiple times, with subsequent files overriding values defined in previous + /// files. + #[arg(long, value_delimiter = ' ', 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, + #[command(flatten)] pub installer: ResolverInstallerArgs, diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs index 460c7ff6e..41163c4b9 100644 --- a/crates/uv/src/commands/tool/run.rs +++ b/crates/uv/src/commands/tool/run.rs @@ -96,6 +96,8 @@ pub(crate) async fn run( concurrency: Concurrency, cache: Cache, printer: Printer, + env_file: Vec, + no_env_file: bool, preview: PreviewMode, ) -> anyhow::Result { /// Whether or not a path looks like a Python script based on the file extension. @@ -104,6 +106,44 @@ pub(crate) async fn run( .is_some_and(|ext| ext.eq_ignore_ascii_case("py") || ext.eq_ignore_ascii_case("pyw")) } + // 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() + ); + } + } + } + } + let Some(command) = command else { // When a command isn't provided, we'll show a brief help including available tools show_help(invocation_source, &cache, printer).await?; diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 2e9b3908c..97aca26c9 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1122,6 +1122,8 @@ async fn run(mut cli: Cli) -> Result { globals.concurrency, cache, printer, + args.env_file, + args.no_env_file, globals.preview, )) .await diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 1af4f06ef..111b54798 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -466,6 +466,8 @@ pub(crate) struct ToolRunSettings { pub(crate) refresh: Refresh, pub(crate) options: ResolverInstallerOptions, pub(crate) settings: ResolverInstallerSettings, + pub(crate) env_file: Vec, + pub(crate) no_env_file: bool, } impl ToolRunSettings { @@ -485,6 +487,8 @@ impl ToolRunSettings { constraints, overrides, isolated, + env_file, + no_env_file, show_resolution, installer, build, @@ -556,6 +560,8 @@ impl ToolRunSettings { settings, options, install_mirrors, + env_file, + no_env_file, } } } diff --git a/crates/uv/tests/it/tool_run.rs b/crates/uv/tests/it/tool_run.rs index b4fb2dc8a..2206d2444 100644 --- a/crates/uv/tests/it/tool_run.rs +++ b/crates/uv/tests/it/tool_run.rs @@ -2011,6 +2011,100 @@ fn tool_run_python_from() { "###); } +#[test] +fn run_with_env_file() -> anyhow::Result<()> { + let context = TestContext::new("3.12").with_filtered_counts(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + // Create a project with a custom script. + let foo_dir = context.temp_dir.child("foo"); + let foo_pyproject_toml = foo_dir.child("pyproject.toml"); + + foo_pyproject_toml.write_str(indoc! { r#" + [project] + name = "foo" + version = "1.0.0" + requires-python = ">=3.8" + dependencies = [] + + [project.scripts] + script = "foo.main:run" + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "# + })?; + + // Create the `foo` module. + let foo_project_src = foo_dir.child("src"); + let foo_module = foo_project_src.child("foo"); + let foo_main_py = foo_module.child("main.py"); + foo_main_py.write_str(indoc! { r#" + def run(): + 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')) + + __name__ == "__main__" and run() + "# + })?; + + uv_snapshot!(context.filters(), context.tool_run() + .arg("--from") + .arg("./foo") + .arg("script") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r" + success: true + exit_code: 0 + ----- stdout ----- + None + None + None + None + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + foo==1.0.0 (from file://[TEMP_DIR]/foo) + "); + + 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.tool_run() + .arg("--env-file").arg(".file") + .arg("--from") + .arg("./foo") + .arg("script") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r" + success: true + exit_code: 0 + ----- stdout ----- + palpatine + leia_organa + obi_wan_kenobi + C3PO + + ----- stderr ----- + Resolved [N] packages in [TIME] + "); + + Ok(()) +} + #[test] fn tool_run_from_at() { let context = TestContext::new("3.12") diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 8c88feacc..7a7e410e3 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -3149,6 +3149,11 @@ uv tool 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.

@@ -3293,6 +3298,9 @@ uv tool run [OPTIONS] [COMMAND]

Normally, configuration files are discovered in the current directory, parent directories, or user configuration directories.

May also be set with the UV_NO_CONFIG environment variable.

+
--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-managed-python

Disable use of uv-managed Python versions.