Skip to content

Commit 6efc433

Browse files
committed
Discover and respect .python-version files in parent directories (#6370)
Uses #6369 for test coverage. Updates version file discovery to search up into parent directories. Also refactors Python request determination to avoid duplicating the user request / version file / workspace lookup logic in every command (this supersedes the work started in #6372). There is a bit of remaining work here, mostly around documentation. There are some edge-cases where we don't use the refactored request utility, like `uv build` — I'm not sure how I'm going to handle that yet as it needs a separate root directory.
1 parent ade1dc9 commit 6efc433

File tree

23 files changed

+800
-245
lines changed

23 files changed

+800
-245
lines changed

crates/uv-python/src/lib.rs

+1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ pub use crate::prefix::Prefix;
1717
pub use crate::python_version::PythonVersion;
1818
pub use crate::target::Target;
1919
pub use crate::version_files::{
20+
DiscoveryOptions as VersionFileDiscoveryOptions, FilePreference as VersionFilePreference,
2021
PythonVersionFile, PYTHON_VERSIONS_FILENAME, PYTHON_VERSION_FILENAME,
2122
};
2223
pub use crate::virtualenv::{Error as VirtualEnvError, PyVenvConfiguration, VirtualEnvironment};

crates/uv-python/src/version_files.rs

+82-25
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use std::path::{Path, PathBuf};
44
use fs_err as fs;
55
use itertools::Itertools;
66
use tracing::debug;
7+
use uv_fs::Simplified;
78

89
use crate::PythonRequest;
910

@@ -22,38 +23,91 @@ pub struct PythonVersionFile {
2223
versions: Vec<PythonRequest>,
2324
}
2425

26+
/// Whether to prefer the `.python-version` or `.python-versions` file.
27+
#[derive(Debug, Clone, Copy, Default)]
28+
pub enum FilePreference {
29+
#[default]
30+
Version,
31+
Versions,
32+
}
33+
34+
#[derive(Debug, Default, Clone)]
35+
pub struct DiscoveryOptions<'a> {
36+
/// The path to stop discovery at.
37+
stop_discovery_at: Option<&'a Path>,
38+
/// When `no_config` is set, Python version files will be ignored.
39+
///
40+
/// Discovery will still run in order to display a log about the ignored file.
41+
no_config: bool,
42+
preference: FilePreference,
43+
}
44+
45+
impl<'a> DiscoveryOptions<'a> {
46+
#[must_use]
47+
pub fn with_no_config(self, no_config: bool) -> Self {
48+
Self { no_config, ..self }
49+
}
50+
51+
#[must_use]
52+
pub fn with_preference(self, preference: FilePreference) -> Self {
53+
Self { preference, ..self }
54+
}
55+
56+
#[must_use]
57+
pub fn with_stop_discovery_at(self, stop_discovery_at: Option<&'a Path>) -> Self {
58+
Self {
59+
stop_discovery_at,
60+
..self
61+
}
62+
}
63+
}
64+
2565
impl PythonVersionFile {
26-
/// Find a Python version file in the given directory.
66+
/// Find a Python version file in the given directory or any of its parents.
2767
pub async fn discover(
2868
working_directory: impl AsRef<Path>,
29-
// TODO(zanieb): Create a `DiscoverySettings` struct for these options
30-
no_config: bool,
31-
prefer_versions: bool,
69+
options: &DiscoveryOptions<'_>,
3270
) -> Result<Option<Self>, std::io::Error> {
33-
let versions_path = working_directory.as_ref().join(PYTHON_VERSIONS_FILENAME);
34-
let version_path = working_directory.as_ref().join(PYTHON_VERSION_FILENAME);
35-
36-
if no_config {
37-
if version_path.exists() {
38-
debug!("Ignoring `.python-version` file due to `--no-config`");
39-
} else if versions_path.exists() {
40-
debug!("Ignoring `.python-versions` file due to `--no-config`");
41-
};
71+
let Some(path) = Self::find_nearest(working_directory, options) else {
72+
return Ok(None);
73+
};
74+
75+
if options.no_config {
76+
debug!(
77+
"Ignoring Python version file at `{}` due to `--no-config`",
78+
path.user_display()
79+
);
4280
return Ok(None);
4381
}
4482

45-
let paths = if prefer_versions {
46-
[versions_path, version_path]
47-
} else {
48-
[version_path, versions_path]
83+
// Uses `try_from_path` instead of `from_path` to avoid TOCTOU failures.
84+
Self::try_from_path(path).await
85+
}
86+
87+
fn find_nearest(path: impl AsRef<Path>, options: &DiscoveryOptions<'_>) -> Option<PathBuf> {
88+
path.as_ref()
89+
.ancestors()
90+
.take_while(|path| {
91+
// Only walk up the given directory, if any.
92+
options
93+
.stop_discovery_at
94+
.and_then(Path::parent)
95+
.map(|stop_discovery_at| stop_discovery_at != *path)
96+
.unwrap_or(true)
97+
})
98+
.find_map(|path| Self::find_in_directory(path, options))
99+
}
100+
101+
fn find_in_directory(path: &Path, options: &DiscoveryOptions<'_>) -> Option<PathBuf> {
102+
let version_path = path.join(PYTHON_VERSION_FILENAME);
103+
let versions_path = path.join(PYTHON_VERSIONS_FILENAME);
104+
105+
let paths = match options.preference {
106+
FilePreference::Versions => [versions_path, version_path],
107+
FilePreference::Version => [version_path, versions_path],
49108
};
50-
for path in paths {
51-
if let Some(result) = Self::try_from_path(path).await? {
52-
return Ok(Some(result));
53-
};
54-
}
55109

56-
Ok(None)
110+
paths.into_iter().find(|path| path.is_file())
57111
}
58112

59113
/// Try to read a Python version file at the given path.
@@ -62,7 +116,10 @@ impl PythonVersionFile {
62116
pub async fn try_from_path(path: PathBuf) -> Result<Option<Self>, std::io::Error> {
63117
match fs::tokio::read_to_string(&path).await {
64118
Ok(content) => {
65-
debug!("Reading requests from `{}`", path.display());
119+
debug!(
120+
"Reading Python requests from version file at `{}`",
121+
path.display()
122+
);
66123
let versions = content
67124
.lines()
68125
.filter(|line| {
@@ -104,7 +161,7 @@ impl PythonVersionFile {
104161
}
105162
}
106163

107-
/// Return the first version declared in the file, if any.
164+
/// Return the first request declared in the file, if any.
108165
pub fn version(&self) -> Option<&PythonRequest> {
109166
self.versions.first()
110167
}

crates/uv-scripts/src/lib.rs

+13-4
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,19 @@ impl Pep723Item {
4444
Self::Remote(metadata) => metadata,
4545
}
4646
}
47+
48+
/// Return the path of the PEP 723 item, if any.
49+
pub fn path(&self) -> Option<&Path> {
50+
match self {
51+
Self::Script(script) => Some(&script.path),
52+
Self::Stdin(_) => None,
53+
Self::Remote(_) => None,
54+
}
55+
}
4756
}
4857

4958
/// A PEP 723 script, including its [`Pep723Metadata`].
50-
#[derive(Debug)]
59+
#[derive(Debug, Clone)]
5160
pub struct Pep723Script {
5261
/// The path to the Python script.
5362
pub path: PathBuf,
@@ -188,7 +197,7 @@ impl Pep723Script {
188197
/// PEP 723 metadata as parsed from a `script` comment block.
189198
///
190199
/// See: <https://peps.python.org/pep-0723/>
191-
#[derive(Debug, Deserialize)]
200+
#[derive(Debug, Deserialize, Clone)]
192201
#[serde(rename_all = "kebab-case")]
193202
pub struct Pep723Metadata {
194203
pub dependencies: Option<Vec<uv_pep508::Requirement<VerbatimParsedUrl>>>,
@@ -248,13 +257,13 @@ impl FromStr for Pep723Metadata {
248257
}
249258
}
250259

251-
#[derive(Deserialize, Debug)]
260+
#[derive(Deserialize, Debug, Clone)]
252261
#[serde(rename_all = "kebab-case")]
253262
pub struct Tool {
254263
pub uv: Option<ToolUv>,
255264
}
256265

257-
#[derive(Debug, Deserialize)]
266+
#[derive(Debug, Deserialize, Clone)]
258267
#[serde(deny_unknown_fields)]
259268
pub struct ToolUv {
260269
#[serde(flatten)]

crates/uv-workspace/src/workspace.rs

+4
Original file line numberDiff line numberDiff line change
@@ -925,6 +925,7 @@ impl ProjectWorkspace {
925925
// Only walk up the given directory, if any.
926926
options
927927
.stop_discovery_at
928+
.and_then(Path::parent)
928929
.map(|stop_discovery_at| stop_discovery_at != *path)
929930
.unwrap_or(true)
930931
})
@@ -1127,6 +1128,7 @@ async fn find_workspace(
11271128
// Only walk up the given directory, if any.
11281129
options
11291130
.stop_discovery_at
1131+
.and_then(Path::parent)
11301132
.map(|stop_discovery_at| stop_discovery_at != *path)
11311133
.unwrap_or(true)
11321134
})
@@ -1219,6 +1221,7 @@ pub fn check_nested_workspaces(inner_workspace_root: &Path, options: &DiscoveryO
12191221
// Only walk up the given directory, if any.
12201222
options
12211223
.stop_discovery_at
1224+
.and_then(Path::parent)
12221225
.map(|stop_discovery_at| stop_discovery_at != *path)
12231226
.unwrap_or(true)
12241227
})
@@ -1385,6 +1388,7 @@ impl VirtualProject {
13851388
// Only walk up the given directory, if any.
13861389
options
13871390
.stop_discovery_at
1391+
.and_then(Path::parent)
13881392
.map(|stop_discovery_at| stop_discovery_at != *path)
13891393
.unwrap_or(true)
13901394
})

crates/uv/src/commands/build_frontend.rs

+8-4
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ use uv_fs::Simplified;
2323
use uv_normalize::PackageName;
2424
use uv_python::{
2525
EnvironmentPreference, PythonDownloads, PythonEnvironment, PythonInstallation,
26-
PythonPreference, PythonRequest, PythonVariant, PythonVersionFile, VersionRequest,
26+
PythonPreference, PythonRequest, PythonVariant, PythonVersionFile, VersionFileDiscoveryOptions,
27+
VersionRequest,
2728
};
2829
use uv_requirements::RequirementsSource;
2930
use uv_resolver::{ExcludeNewer, FlatIndex, RequiresPython};
@@ -391,9 +392,12 @@ async fn build_package(
391392

392393
// (2) Request from `.python-version`
393394
if interpreter_request.is_none() {
394-
interpreter_request = PythonVersionFile::discover(source.directory(), no_config, false)
395-
.await?
396-
.and_then(PythonVersionFile::into_version);
395+
interpreter_request = PythonVersionFile::discover(
396+
source.directory(),
397+
&VersionFileDiscoveryOptions::default().with_no_config(no_config),
398+
)
399+
.await?
400+
.and_then(PythonVersionFile::into_version);
397401
}
398402

399403
// (3) `Requires-Python` in `pyproject.toml`

crates/uv/src/commands/project/add.rs

+30-26
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,11 @@ use uv_pep508::{ExtraName, Requirement, UnnamedRequirement, VersionOrUrl};
2727
use uv_pypi_types::{redact_credentials, ParsedUrl, RequirementSource, VerbatimParsedUrl};
2828
use uv_python::{
2929
EnvironmentPreference, Interpreter, PythonDownloads, PythonEnvironment, PythonInstallation,
30-
PythonPreference, PythonRequest, PythonVariant, PythonVersionFile, VersionRequest,
30+
PythonPreference, PythonRequest,
3131
};
3232
use uv_requirements::{NamedRequirementsResolver, RequirementsSource, RequirementsSpecification};
3333
use uv_resolver::{FlatIndex, InstallTarget};
34-
use uv_scripts::Pep723Script;
34+
use uv_scripts::{Pep723Item, Pep723Script};
3535
use uv_types::{BuildIsolation, HashStrategy};
3636
use uv_warnings::warn_user_once;
3737
use uv_workspace::pyproject::{DependencyType, Source, SourceError};
@@ -44,7 +44,9 @@ use crate::commands::pip::loggers::{
4444
use crate::commands::pip::operations::Modifications;
4545
use crate::commands::pip::resolution_environment;
4646
use crate::commands::project::lock::LockMode;
47-
use crate::commands::project::{script_python_requirement, ProjectError};
47+
use crate::commands::project::{
48+
init_script_python_requirement, validate_script_requires_python, ProjectError, ScriptPython,
49+
};
4850
use crate::commands::reporters::{PythonDownloadReporter, ResolverReporter};
4951
use crate::commands::{diagnostics, pip, project, ExitStatus, SharedState};
5052
use crate::printer::Printer;
@@ -76,6 +78,7 @@ pub(crate) async fn add(
7678
concurrency: Concurrency,
7779
native_tls: bool,
7880
allow_insecure_host: &[TrustedHost],
81+
no_config: bool,
7982
cache: &Cache,
8083
printer: Printer,
8184
) -> Result<ExitStatus> {
@@ -134,12 +137,13 @@ pub(crate) async fn add(
134137
let script = if let Some(script) = Pep723Script::read(&script).await? {
135138
script
136139
} else {
137-
let requires_python = script_python_requirement(
140+
let requires_python = init_script_python_requirement(
138141
python.as_deref(),
139142
project_dir,
140143
false,
141144
python_preference,
142145
python_downloads,
146+
no_config,
143147
&client_builder,
144148
cache,
145149
&reporter,
@@ -148,28 +152,17 @@ pub(crate) async fn add(
148152
Pep723Script::init(&script, requires_python.specifiers()).await?
149153
};
150154

151-
let python_request = if let Some(request) = python.as_deref() {
152-
// (1) Explicit request from user
153-
Some(PythonRequest::parse(request))
154-
} else if let Some(request) = PythonVersionFile::discover(project_dir, false, false)
155-
.await?
156-
.and_then(PythonVersionFile::into_version)
157-
{
158-
// (2) Request from `.python-version`
159-
Some(request)
160-
} else {
161-
// (3) `Requires-Python` in `pyproject.toml`
162-
script
163-
.metadata
164-
.requires_python
165-
.clone()
166-
.map(|requires_python| {
167-
PythonRequest::Version(VersionRequest::Range(
168-
requires_python,
169-
PythonVariant::Default,
170-
))
171-
})
172-
};
155+
let ScriptPython {
156+
source,
157+
python_request,
158+
requires_python,
159+
} = ScriptPython::from_request(
160+
python.as_deref().map(PythonRequest::parse),
161+
None,
162+
&Pep723Item::Script(script.clone()),
163+
no_config,
164+
)
165+
.await?;
173166

174167
let interpreter = PythonInstallation::find_or_download(
175168
python_request.as_ref(),
@@ -183,6 +176,16 @@ pub(crate) async fn add(
183176
.await?
184177
.into_interpreter();
185178

179+
if let Some((requires_python, requires_python_source)) = requires_python {
180+
validate_script_requires_python(
181+
&interpreter,
182+
None,
183+
&requires_python,
184+
&requires_python_source,
185+
&source,
186+
)?;
187+
}
188+
186189
Target::Script(script, Box::new(interpreter))
187190
} else {
188191
// Find the project in the workspace.
@@ -221,6 +224,7 @@ pub(crate) async fn add(
221224
connectivity,
222225
native_tls,
223226
allow_insecure_host,
227+
no_config,
224228
cache,
225229
printer,
226230
)

crates/uv/src/commands/project/export.rs

+3
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ pub(crate) async fn export(
4949
concurrency: Concurrency,
5050
native_tls: bool,
5151
allow_insecure_host: &[TrustedHost],
52+
no_config: bool,
5253
quiet: bool,
5354
cache: &Cache,
5455
printer: Printer,
@@ -99,12 +100,14 @@ pub(crate) async fn export(
99100
// Find an interpreter for the project
100101
interpreter = ProjectInterpreter::discover(
101102
project.workspace(),
103+
project_dir,
102104
python.as_deref().map(PythonRequest::parse),
103105
python_preference,
104106
python_downloads,
105107
connectivity,
106108
native_tls,
107109
allow_insecure_host,
110+
no_config,
108111
cache,
109112
printer,
110113
)

0 commit comments

Comments
 (0)