Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down
40 changes: 40 additions & 0 deletions crates/uv/src/commands/tool/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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?;
Expand Down
2 changes: 2 additions & 0 deletions crates/uv/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions crates/uv/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -485,6 +487,8 @@ impl ToolRunSettings {
constraints,
overrides,
isolated,
env_file,
no_env_file,
show_resolution,
installer,
build,
Expand Down Expand Up @@ -556,6 +560,8 @@ impl ToolRunSettings {
settings,
options,
install_mirrors,
env_file,
no_env_file,
}
}
}
Expand Down
94 changes: 94 additions & 0 deletions crates/uv/tests/it/tool_run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
8 changes: 8 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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>
Expand Down