Skip to content

Commit 8f9d3e8

Browse files
committed
Discover and respect .python-version files in parent directories
1 parent f346f17 commit 8f9d3e8

File tree

19 files changed

+680
-233
lines changed

19 files changed

+680
-233
lines changed

crates/uv-python/src/lib.rs

Lines changed: 1 addition & 0 deletions
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

Lines changed: 82 additions & 25 deletions
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

Lines changed: 13 additions & 4 deletions
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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -884,6 +884,7 @@ impl ProjectWorkspace {
884884
// Only walk up the given directory, if any.
885885
options
886886
.stop_discovery_at
887+
.and_then(Path::parent)
887888
.map(|stop_discovery_at| stop_discovery_at != *path)
888889
.unwrap_or(true)
889890
})
@@ -1086,6 +1087,7 @@ async fn find_workspace(
10861087
// Only walk up the given directory, if any.
10871088
options
10881089
.stop_discovery_at
1090+
.and_then(Path::parent)
10891091
.map(|stop_discovery_at| stop_discovery_at != *path)
10901092
.unwrap_or(true)
10911093
})
@@ -1178,6 +1180,7 @@ pub fn check_nested_workspaces(inner_workspace_root: &Path, options: &DiscoveryO
11781180
// Only walk up the given directory, if any.
11791181
options
11801182
.stop_discovery_at
1183+
.and_then(Path::parent)
11811184
.map(|stop_discovery_at| stop_discovery_at != *path)
11821185
.unwrap_or(true)
11831186
})
@@ -1344,6 +1347,7 @@ impl VirtualProject {
13441347
// Only walk up the given directory, if any.
13451348
options
13461349
.stop_discovery_at
1350+
.and_then(Path::parent)
13471351
.map(|stop_discovery_at| stop_discovery_at != *path)
13481352
.unwrap_or(true)
13491353
})

crates/uv/src/commands/build_frontend.rs

Lines changed: 8 additions & 4 deletions
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

Lines changed: 26 additions & 26 deletions
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;
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;
@@ -134,7 +136,7 @@ pub(crate) async fn add(
134136
let script = if let Some(script) = Pep723Script::read(&script).await? {
135137
script
136138
} else {
137-
let requires_python = script_python_requirement(
139+
let requires_python = init_script_python_requirement(
138140
python.as_deref(),
139141
project_dir,
140142
false,
@@ -148,28 +150,16 @@ pub(crate) async fn add(
148150
Pep723Script::init(&script, requires_python.specifiers()).await?
149151
};
150152

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-
};
153+
let ScriptPython {
154+
source,
155+
python_request,
156+
requires_python,
157+
} = ScriptPython::from_request(
158+
python.as_deref().map(PythonRequest::parse),
159+
None,
160+
&Pep723Item::Script(script.clone()),
161+
)
162+
.await?;
173163

174164
let interpreter = PythonInstallation::find_or_download(
175165
python_request.as_ref(),
@@ -183,6 +173,16 @@ pub(crate) async fn add(
183173
.await?
184174
.into_interpreter();
185175

176+
if let Some((requires_python, requires_python_source)) = requires_python {
177+
validate_script_requires_python(
178+
&interpreter,
179+
None,
180+
&requires_python,
181+
&requires_python_source,
182+
&source,
183+
)?;
184+
}
185+
186186
Target::Script(script, Box::new(interpreter))
187187
} else {
188188
// Find the project in the workspace.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ pub(crate) async fn export(
8989
// Find an interpreter for the project
9090
interpreter = ProjectInterpreter::discover(
9191
project.workspace(),
92+
project_dir,
9293
python.as_deref().map(PythonRequest::parse),
9394
python_preference,
9495
python_downloads,

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,15 @@ use uv_pep440::Version;
1818
use uv_pep508::PackageName;
1919
use uv_python::{
2020
EnvironmentPreference, PythonDownloads, PythonInstallation, PythonPreference, PythonRequest,
21-
PythonVariant, PythonVersionFile, VersionRequest,
21+
PythonVariant, PythonVersionFile, VersionFileDiscoveryOptions, VersionRequest,
2222
};
2323
use uv_resolver::RequiresPython;
2424
use uv_scripts::{Pep723Script, ScriptTag};
2525
use uv_warnings::warn_user_once;
2626
use uv_workspace::pyproject_mut::{DependencyTarget, PyProjectTomlMut};
2727
use uv_workspace::{DiscoveryOptions, MemberDiscovery, Workspace, WorkspaceError};
2828

29-
use crate::commands::project::{find_requires_python, script_python_requirement};
29+
use crate::commands::project::{find_requires_python, init_script_python_requirement};
3030
use crate::commands::reporters::PythonDownloadReporter;
3131
use crate::commands::ExitStatus;
3232
use crate::printer::Printer;
@@ -226,7 +226,7 @@ async fn init_script(
226226
}
227227
};
228228

229-
let requires_python = script_python_requirement(
229+
let requires_python = init_script_python_requirement(
230230
python.as_deref(),
231231
&CWD,
232232
no_pin_python,
@@ -690,7 +690,7 @@ impl InitProjectKind {
690690

691691
// Write .python-version if it doesn't exist.
692692
if let Some(python_request) = python_request {
693-
if PythonVersionFile::discover(path, false, false)
693+
if PythonVersionFile::discover(path, &VersionFileDiscoveryOptions::default())
694694
.await?
695695
.is_none()
696696
{
@@ -744,7 +744,7 @@ impl InitProjectKind {
744744

745745
// Write .python-version if it doesn't exist.
746746
if let Some(python_request) = python_request {
747-
if PythonVersionFile::discover(path, false, false)
747+
if PythonVersionFile::discover(path, &VersionFileDiscoveryOptions::default())
748748
.await?
749749
.is_none()
750750
{

0 commit comments

Comments
 (0)