Allow escaping spaces in --env-file handling (#15815)
Some checks are pending
CI / typos (push) Waiting to run
CI / Determine changes (push) Waiting to run
CI / lint (push) Waiting to run
CI / cargo clippy | ubuntu (push) Blocked by required conditions
CI / cargo clippy | windows (push) Blocked by required conditions
CI / cargo dev generate-all (push) Blocked by required conditions
CI / cargo shear (push) Waiting to run
CI / ecosystem test | pydantic/pydantic-core (push) Blocked by required conditions
CI / cargo test | ubuntu (push) Blocked by required conditions
CI / cargo test | macos (push) Blocked by required conditions
CI / cargo test | windows (push) Blocked by required conditions
CI / check windows trampoline | aarch64 (push) Blocked by required conditions
CI / check windows trampoline | i686 (push) Blocked by required conditions
CI / check windows trampoline | x86_64 (push) Blocked by required conditions
CI / test windows trampoline | aarch64 (push) Blocked by required conditions
CI / test windows trampoline | i686 (push) Blocked by required conditions
CI / test windows trampoline | x86_64 (push) Blocked by required conditions
CI / mkdocs (push) Waiting to run
CI / build binary | linux libc (push) Blocked by required conditions
CI / build binary | linux aarch64 (push) Blocked by required conditions
CI / build binary | linux musl (push) Blocked by required conditions
CI / build binary | freebsd (push) Blocked by required conditions
CI / build binary | macos aarch64 (push) Blocked by required conditions
CI / build binary | macos x86_64 (push) Blocked by required conditions
CI / build binary | windows x86_64 (push) Blocked by required conditions
CI / build binary | windows aarch64 (push) Blocked by required conditions
CI / build binary | msrv (push) Blocked by required conditions
CI / ecosystem test | prefecthq/prefect (push) Blocked by required conditions
CI / ecosystem test | pallets/flask (push) Blocked by required conditions
CI / smoke test | linux (push) Blocked by required conditions
CI / integration test | activate nushell venv (push) Blocked by required conditions
CI / integration test | conda on ubuntu (push) Blocked by required conditions
CI / integration test | free-threaded on windows (push) Blocked by required conditions
CI / integration test | aarch64 windows implicit (push) Blocked by required conditions
CI / integration test | aarch64 windows explicit (push) Blocked by required conditions
CI / integration test | pypy on ubuntu (push) Blocked by required conditions
CI / integration test | pyodide on ubuntu (push) Blocked by required conditions
CI / integration test | github actions (push) Blocked by required conditions
CI / integration test | registries (push) Blocked by required conditions
CI / integration test | uv publish (push) Blocked by required conditions
CI / integration test | uv_build (push) Blocked by required conditions
CI / check cache | ubuntu (push) Blocked by required conditions
CI / smoke test | linux aarch64 (push) Blocked by required conditions
CI / check system | alpine (push) Blocked by required conditions
CI / smoke test | macos (push) Blocked by required conditions
CI / smoke test | windows x86_64 (push) Blocked by required conditions
CI / smoke test | windows aarch64 (push) Blocked by required conditions
CI / integration test | deadsnakes python3.9 on ubuntu (push) Blocked by required conditions
CI / integration test | pypy on windows (push) Blocked by required conditions
CI / integration test | graalpy on ubuntu (push) Blocked by required conditions
CI / integration test | graalpy on windows (push) Blocked by required conditions
CI / integration test | free-threaded python on github actions (push) Blocked by required conditions
CI / integration test | pyenv on wsl x86-64 (push) Blocked by required conditions
CI / integration test | determine publish changes (push) Blocked by required conditions
CI / check cache | macos aarch64 (push) Blocked by required conditions
CI / check system | python on debian (push) Blocked by required conditions
CI / check system | python on fedora (push) Blocked by required conditions
CI / check system | python on ubuntu (push) Blocked by required conditions
CI / check system | python on rocky linux 8 (push) Blocked by required conditions
CI / check system | python on rocky linux 9 (push) Blocked by required conditions
CI / check system | graalpy on ubuntu (push) Blocked by required conditions
CI / check system | pypy on ubuntu (push) Blocked by required conditions
CI / check system | pyston (push) Blocked by required conditions
CI / check system | python on macos aarch64 (push) Blocked by required conditions
CI / check system | homebrew python on macos aarch64 (push) Blocked by required conditions
CI / check system | x86-64 python on macos aarch64 (push) Blocked by required conditions
CI / check system | python on macos x86-64 (push) Blocked by required conditions
CI / check system | python3.10 on windows x86-64 (push) Blocked by required conditions
CI / check system | python3.10 on windows x86 (push) Blocked by required conditions
CI / check system | python3.13 on windows x86-64 (push) Blocked by required conditions
CI / check system | x86-64 python3.13 on windows aarch64 (push) Blocked by required conditions
CI / check system | aarch64 python3.13 on windows aarch64 (push) Blocked by required conditions
CI / check system | python3.12 via chocolatey (push) Blocked by required conditions
CI / check system | python3.9 via pyenv (push) Blocked by required conditions
CI / check system | python3.13 (push) Blocked by required conditions
CI / check system | conda3.8 on macos aarch64 (push) Blocked by required conditions
CI / check system | conda3.8 on linux x86-64 (push) Blocked by required conditions
CI / check system | conda3.11 on windows x86-64 (push) Blocked by required conditions
CI / check system | windows registry (push) Blocked by required conditions
CI / check system | conda3.11 on macos aarch64 (push) Blocked by required conditions
CI / check system | conda3.11 on linux x86-64 (push) Blocked by required conditions
CI / check system | conda3.8 on windows x86-64 (push) Blocked by required conditions
CI / check system | amazonlinux (push) Blocked by required conditions
CI / check system | embedded python3.10 on windows x86-64 (push) Blocked by required conditions
CI / benchmarks | walltime aarch64 linux (push) Blocked by required conditions
CI / benchmarks | instrumented (push) Blocked by required conditions
zizmor / Run zizmor (push) Waiting to run

## Summary

We allow space-delimiting for `--env-file`, but Clap doesn't support any
form of escaping, so as-is, there's no way to provide a `.env` file in a
directory that contains a space. We now do the splitting ourselves and
respect escapes.

Closes https://github.com/astral-sh/uv/issues/15806.
This commit is contained in:
Charlie Marsh 2025-09-12 18:11:51 -04:00 committed by GitHub
parent 6876716fd2
commit f59d00b479
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 175 additions and 44 deletions

View file

@ -3192,8 +3192,8 @@ pub struct RunArgs {
///
/// 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>,
#[arg(long, env = EnvVars::UV_ENV_FILE)]
pub env_file: Vec<String>,
/// Avoid reading environment variables from a `.env` file.
#[arg(long, value_parser = clap::builder::BoolishValueParser::new(), env = EnvVars::UV_NO_ENV_FILE)]

View file

@ -0,0 +1,135 @@
use std::path::PathBuf;
/// A collection of `.env` file paths.
#[derive(Default, Debug, Clone, PartialEq, Eq)]
pub struct EnvFile(Vec<PathBuf>);
impl EnvFile {
/// Parse the env file paths from command-line arguments.
pub fn from_args(env_file: Vec<String>, no_env_file: bool) -> Self {
if no_env_file {
return Self::default();
}
if env_file.is_empty() {
return Self::default();
}
let mut paths = Vec::new();
// Split on spaces, but respect backslashes.
for env_file in env_file {
let mut current = String::new();
let mut escape = false;
for c in env_file.chars() {
if escape {
current.push(c);
escape = false;
} else if c == '\\' {
escape = true;
} else if c.is_whitespace() {
if !current.is_empty() {
paths.push(PathBuf::from(current));
current = String::new();
}
} else {
current.push(c);
}
}
if !current.is_empty() {
paths.push(PathBuf::from(current));
}
}
Self(paths)
}
/// Iterate over the paths in the env file.
pub fn iter(&self) -> impl DoubleEndedIterator<Item = &PathBuf> {
self.0.iter()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_from_args_default() {
let env_file = EnvFile::from_args(vec![], false);
assert_eq!(env_file, EnvFile::default());
}
#[test]
fn test_from_args_no_env_file() {
let env_file = EnvFile::from_args(vec!["path1 path2".to_string()], true);
assert_eq!(env_file, EnvFile::default());
}
#[test]
fn test_from_args_empty_string() {
let env_file = EnvFile::from_args(vec![String::new()], false);
assert_eq!(env_file, EnvFile::default());
}
#[test]
fn test_from_args_whitespace_only() {
let env_file = EnvFile::from_args(vec![" ".to_string()], false);
assert_eq!(env_file, EnvFile::default());
}
#[test]
fn test_from_args_single_path() {
let env_file = EnvFile::from_args(vec!["path1".to_string()], false);
assert_eq!(env_file.0, vec![PathBuf::from("path1")]);
}
#[test]
fn test_from_args_multiple_paths() {
let env_file = EnvFile::from_args(vec!["path1 path2 path3".to_string()], false);
assert_eq!(
env_file.0,
vec![
PathBuf::from("path1"),
PathBuf::from("path2"),
PathBuf::from("path3")
]
);
}
#[test]
fn test_from_args_escaped_spaces() {
let env_file = EnvFile::from_args(vec![r"path\ with\ spaces".to_string()], false);
assert_eq!(env_file.0, vec![PathBuf::from("path with spaces")]);
}
#[test]
fn test_from_args_mixed_escaped_and_normal() {
let env_file =
EnvFile::from_args(vec![r"path1 path\ with\ spaces path2".to_string()], false);
assert_eq!(
env_file.0,
vec![
PathBuf::from("path1"),
PathBuf::from("path with spaces"),
PathBuf::from("path2")
]
);
}
#[test]
fn test_from_args_escaped_backslash() {
let env_file = EnvFile::from_args(vec![r"path\\with\\backslashes".to_string()], false);
assert_eq!(env_file.0, vec![PathBuf::from(r"path\with\backslashes")]);
}
#[test]
fn test_iter() {
let env_file = EnvFile(vec![PathBuf::from("path1"), PathBuf::from("path2")]);
let paths: Vec<_> = env_file.iter().collect();
assert_eq!(
paths,
vec![&PathBuf::from("path1"), &PathBuf::from("path2")]
);
}
}

View file

@ -5,6 +5,7 @@ pub use constraints::*;
pub use dependency_groups::*;
pub use dry_run::*;
pub use editable::*;
pub use env_file::*;
pub use export_format::*;
pub use extras::*;
pub use hash::*;
@ -28,6 +29,7 @@ mod constraints;
mod dependency_groups;
mod dry_run;
mod editable;
mod env_file;
mod export_format;
mod extras;
mod hash;

View file

@ -18,7 +18,7 @@ use uv_cache::Cache;
use uv_cli::ExternalCommand;
use uv_client::BaseClientBuilder;
use uv_configuration::{
Concurrency, Constraints, DependencyGroups, DryRun, EditableMode, ExtrasSpecification,
Concurrency, Constraints, DependencyGroups, DryRun, EditableMode, EnvFile, ExtrasSpecification,
InstallOptions, TargetTriple,
};
use uv_distribution::LoweredExtraBuildDependencies;
@ -106,8 +106,7 @@ pub(crate) async fn run(
concurrency: Concurrency,
cache: &Cache,
printer: Printer,
env_file: Vec<PathBuf>,
no_env_file: bool,
env_file: EnvFile,
preview: Preview,
max_recursion_depth: u32,
) -> anyhow::Result<ExitStatus> {
@ -164,39 +163,37 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
let workspace_cache = WorkspaceCache::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()
);
}
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()
);
}
}
}

View file

@ -1865,7 +1865,6 @@ async fn run_project(
&cache,
printer,
args.env_file,
args.no_env_file,
globals.preview,
args.max_recursion_depth,
))

View file

@ -23,7 +23,7 @@ use uv_cli::{
};
use uv_client::Connectivity;
use uv_configuration::{
BuildIsolation, BuildOptions, Concurrency, DependencyGroups, DryRun, EditableMode,
BuildIsolation, BuildOptions, Concurrency, DependencyGroups, DryRun, EditableMode, EnvFile,
ExportFormat, ExtrasSpecification, HashCheckingMode, IndexStrategy, InstallOptions,
KeyringProviderType, NoBinary, NoBuild, ProjectBuildBackend, Reinstall, RequiredVersion,
SourceStrategy, TargetTriple, TrustedHost, TrustedPublishing, Upgrade, VersionControlSystem,
@ -343,8 +343,7 @@ pub(crate) struct RunSettings {
pub(crate) install_mirrors: PythonInstallMirrors,
pub(crate) refresh: Refresh,
pub(crate) settings: ResolverInstallerSettings,
pub(crate) env_file: Vec<PathBuf>,
pub(crate) no_env_file: bool,
pub(crate) env_file: EnvFile,
pub(crate) max_recursion_depth: u32,
}
@ -461,8 +460,7 @@ impl RunSettings {
resolver_installer_options(installer, build),
filesystem,
),
env_file,
no_env_file,
env_file: EnvFile::from_args(env_file, no_env_file),
install_mirrors,
max_recursion_depth: max_recursion_depth.unwrap_or(Self::DEFAULT_MAX_RECURSION_DEPTH),
}