Support .env files in uv tool run (#12386)

## Summary

Closes https://github.com/astral-sh/uv/issues/12371.
This commit is contained in:
Charlie Marsh 2025-03-22 08:36:42 -07:00 committed by GitHub
parent 42a87da857
commit 2b3d6fd7b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 161 additions and 0 deletions

View file

@ -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<PathBuf>,
/// 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,

View file

@ -96,6 +96,8 @@ pub(crate) async fn run(
concurrency: Concurrency,
cache: Cache,
printer: Printer,
env_file: Vec<PathBuf>,
no_env_file: bool,
preview: PreviewMode,
) -> anyhow::Result<ExitStatus> {
/// 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?;

View file

@ -1122,6 +1122,8 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
globals.concurrency,
cache,
printer,
args.env_file,
args.no_env_file,
globals.preview,
))
.await

View file

@ -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<PathBuf>,
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,
}
}
}

View file

@ -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")

View file

@ -3149,6 +3149,11 @@ uv tool run [OPTIONS] [COMMAND]
<p>See <code>--project</code> to only change the project root directory.</p>
</dd><dt id="uv-tool-run--env-file"><a href="#uv-tool-run--env-file"><code>--env-file</code></a> <i>env-file</i></dt><dd><p>Load environment variables from a <code>.env</code> file.</p>
<p>Can be provided multiple times, with subsequent files overriding values defined in previous files.</p>
<p>May also be set with the <code>UV_ENV_FILE</code> environment variable.</p>
</dd><dt id="uv-tool-run--exclude-newer"><a href="#uv-tool-run--exclude-newer"><code>--exclude-newer</code></a> <i>exclude-newer</i></dt><dd><p>Limit candidate packages to those that were uploaded prior to the given date.</p>
<p>Accepts both RFC 3339 timestamps (e.g., <code>2006-12-02T02:07:43Z</code>) and local dates in the same format (e.g., <code>2006-12-02</code>) in your system&#8217;s configured time zone.</p>
@ -3293,6 +3298,9 @@ uv tool run [OPTIONS] [COMMAND]
<p>Normally, configuration files are discovered in the current directory, parent directories, or user configuration directories.</p>
<p>May also be set with the <code>UV_NO_CONFIG</code> environment variable.</p>
</dd><dt id="uv-tool-run--no-env-file"><a href="#uv-tool-run--no-env-file"><code>--no-env-file</code></a></dt><dd><p>Avoid reading environment variables from a <code>.env</code> file</p>
<p>May also be set with the <code>UV_NO_ENV_FILE</code> environment variable.</p>
</dd><dt id="uv-tool-run--no-index"><a href="#uv-tool-run--no-index"><code>--no-index</code></a></dt><dd><p>Ignore the registry index (e.g., PyPI), instead relying on direct URL dependencies and those provided via <code>--find-links</code></p>
</dd><dt id="uv-tool-run--no-managed-python"><a href="#uv-tool-run--no-managed-python"><code>--no-managed-python</code></a></dt><dd><p>Disable use of uv-managed Python versions.</p>