Skip to content

Commit 0c31b66

Browse files
committed
Discover and respect .python-version files in parent directories
1 parent d73b253 commit 0c31b66

File tree

19 files changed

+658
-230
lines changed

19 files changed

+658
-230
lines changed

crates/uv-python/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ pub use crate::prefix::Prefix;
1414
pub use crate::python_version::PythonVersion;
1515
pub use crate::target::Target;
1616
pub use crate::version_files::{
17+
DiscoveryOptions as VersionFileDiscoveryOptions, FilePreference as VersionFilePreference,
1718
PythonVersionFile, PYTHON_VERSIONS_FILENAME, PYTHON_VERSION_FILENAME,
1819
};
1920
pub use crate::virtualenv::{Error as VirtualEnvError, PyVenvConfiguration, VirtualEnvironment};

crates/uv-python/src/version_files.rs

Lines changed: 79 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,88 @@ 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+
no_config: bool,
39+
preference: FilePreference,
40+
}
41+
42+
impl<'a> DiscoveryOptions<'a> {
43+
#[must_use]
44+
pub fn with_no_config(self, no_config: bool) -> Self {
45+
Self { no_config, ..self }
46+
}
47+
48+
#[must_use]
49+
pub fn with_preference(self, preference: FilePreference) -> Self {
50+
Self { preference, ..self }
51+
}
52+
53+
#[must_use]
54+
pub fn with_stop_discovery_at(self, stop_discovery_at: Option<&'a Path>) -> Self {
55+
Self {
56+
stop_discovery_at,
57+
..self
58+
}
59+
}
60+
}
61+
2562
impl PythonVersionFile {
26-
/// Find a Python version file in the given directory.
63+
/// Find a Python version file in the given directory or any of its parents.
2764
pub async fn discover(
2865
working_directory: impl AsRef<Path>,
29-
// TODO(zanieb): Create a `DiscoverySettings` struct for these options
30-
no_config: bool,
31-
prefer_versions: bool,
66+
options: &DiscoveryOptions<'_>,
3267
) -> 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-
};
68+
let Some(path) = Self::find_nearest(working_directory, options) else {
69+
return Ok(None);
70+
};
71+
72+
if options.no_config {
73+
debug!(
74+
"Ignoring Python version file at `{}` due to `--no-config`",
75+
path.user_display()
76+
);
4277
return Ok(None);
4378
}
4479

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

56-
Ok(None)
107+
paths.into_iter().find(|path| path.is_file())
57108
}
58109

59110
/// Try to read a Python version file at the given path.
@@ -62,7 +113,10 @@ impl PythonVersionFile {
62113
pub async fn try_from_path(path: PathBuf) -> Result<Option<Self>, std::io::Error> {
63114
match fs::tokio::read_to_string(&path).await {
64115
Ok(content) => {
65-
debug!("Reading requests from `{}`", path.display());
116+
debug!(
117+
"Reading Python requests from version file at `{}`",
118+
path.display()
119+
);
66120
let versions = content
67121
.lines()
68122
.filter(|line| {
@@ -104,7 +158,7 @@ impl PythonVersionFile {
104158
}
105159
}
106160

107-
/// Return the first version declared in the file, if any.
161+
/// Return the first request declared in the file, if any.
108162
pub fn version(&self) -> Option<&PythonRequest> {
109163
self.versions.first()
110164
}

crates/uv-workspace/src/workspace.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -873,6 +873,7 @@ impl ProjectWorkspace {
873873
// Only walk up the given directory, if any.
874874
options
875875
.stop_discovery_at
876+
.and_then(Path::parent)
876877
.map(|stop_discovery_at| stop_discovery_at != *path)
877878
.unwrap_or(true)
878879
})
@@ -1074,6 +1075,7 @@ async fn find_workspace(
10741075
// Only walk up the given directory, if any.
10751076
options
10761077
.stop_discovery_at
1078+
.and_then(Path::parent)
10771079
.map(|stop_discovery_at| stop_discovery_at != *path)
10781080
.unwrap_or(true)
10791081
})
@@ -1166,6 +1168,7 @@ pub fn check_nested_workspaces(inner_workspace_root: &Path, options: &DiscoveryO
11661168
// Only walk up the given directory, if any.
11671169
options
11681170
.stop_discovery_at
1171+
.and_then(Path::parent)
11691172
.map(|stop_discovery_at| stop_discovery_at != *path)
11701173
.unwrap_or(true)
11711174
})
@@ -1332,6 +1335,7 @@ impl VirtualProject {
13321335
// Only walk up the given directory, if any.
13331336
options
13341337
.stop_discovery_at
1338+
.and_then(Path::parent)
13351339
.map(|stop_discovery_at| stop_discovery_at != *path)
13361340
.unwrap_or(true)
13371341
})

crates/uv/src/commands/build.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ use uv_fs::Simplified;
2222
use uv_normalize::PackageName;
2323
use uv_python::{
2424
EnvironmentPreference, PythonDownloads, PythonEnvironment, PythonInstallation,
25-
PythonPreference, PythonRequest, PythonVariant, PythonVersionFile, VersionRequest,
25+
PythonPreference, PythonRequest, PythonVariant, PythonVersionFile, VersionFileDiscoveryOptions,
26+
VersionRequest,
2627
};
2728
use uv_requirements::RequirementsSource;
2829
use uv_resolver::{ExcludeNewer, FlatIndex, RequiresPython};
@@ -366,9 +367,12 @@ async fn build_package(
366367

367368
// (2) Request from `.python-version`
368369
if interpreter_request.is_none() {
369-
interpreter_request = PythonVersionFile::discover(source.directory(), no_config, false)
370-
.await?
371-
.and_then(PythonVersionFile::into_version);
370+
interpreter_request = PythonVersionFile::discover(
371+
source.directory(),
372+
&VersionFileDiscoveryOptions::default().with_no_config(no_config),
373+
)
374+
.await?
375+
.and_then(PythonVersionFile::into_version);
372376
}
373377

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

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

Lines changed: 21 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ use uv_pep508::{ExtraName, Requirement, UnnamedRequirement, VersionOrUrl};
2525
use uv_pypi_types::{redact_git_credentials, ParsedUrl, RequirementSource, VerbatimParsedUrl};
2626
use uv_python::{
2727
EnvironmentPreference, Interpreter, PythonDownloads, PythonEnvironment, PythonInstallation,
28-
PythonPreference, PythonRequest, PythonVariant, PythonVersionFile, VersionRequest,
28+
PythonPreference, PythonRequest,
2929
};
3030
use uv_requirements::{NamedRequirementsResolver, RequirementsSource, RequirementsSpecification};
3131
use uv_resolver::FlatIndex;
@@ -41,7 +41,9 @@ use crate::commands::pip::loggers::{
4141
};
4242
use crate::commands::pip::operations::Modifications;
4343
use crate::commands::pip::resolution_environment;
44-
use crate::commands::project::{script_python_requirement, ProjectError};
44+
use crate::commands::project::{
45+
init_script_python_requirement, validate_script_requires_python, ProjectError, ScriptPython,
46+
};
4547
use crate::commands::reporters::{PythonDownloadReporter, ResolverReporter};
4648
use crate::commands::{diagnostics, pip, project, ExitStatus, SharedState};
4749
use crate::printer::Printer;
@@ -128,7 +130,7 @@ pub(crate) async fn add(
128130
let script = if let Some(script) = Pep723Script::read(&script).await? {
129131
script
130132
} else {
131-
let requires_python = script_python_requirement(
133+
let requires_python = init_script_python_requirement(
132134
python.as_deref(),
133135
project_dir,
134136
false,
@@ -142,28 +144,12 @@ pub(crate) async fn add(
142144
Pep723Script::init(&script, requires_python.specifiers()).await?
143145
};
144146

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

168154
let interpreter = PythonInstallation::find_or_download(
169155
python_request.as_ref(),
@@ -177,6 +163,16 @@ pub(crate) async fn add(
177163
.await?
178164
.into_interpreter();
179165

166+
if let Some((requires_python, requires_python_source)) = requires_python {
167+
validate_script_requires_python(
168+
&interpreter,
169+
None,
170+
&requires_python,
171+
&requires_python_source,
172+
&source,
173+
)?;
174+
}
175+
180176
Target::Script(script, Box::new(interpreter))
181177
} else {
182178
// 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
@@ -76,6 +76,7 @@ pub(crate) async fn export(
7676
// Find an interpreter for the project
7777
let interpreter = ProjectInterpreter::discover(
7878
project.workspace(),
79+
project_dir,
7980
python.as_deref().map(PythonRequest::parse),
8081
python_preference,
8182
python_downloads,

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,15 @@ use uv_pep440::Version;
1313
use uv_pep508::PackageName;
1414
use uv_python::{
1515
EnvironmentPreference, PythonDownloads, PythonInstallation, PythonPreference, PythonRequest,
16-
PythonVariant, PythonVersionFile, VersionRequest,
16+
PythonVariant, PythonVersionFile, VersionFileDiscoveryOptions, VersionRequest,
1717
};
1818
use uv_resolver::RequiresPython;
1919
use uv_scripts::{Pep723Script, ScriptTag};
2020
use uv_warnings::warn_user_once;
2121
use uv_workspace::pyproject_mut::{DependencyTarget, PyProjectTomlMut};
2222
use uv_workspace::{DiscoveryOptions, MemberDiscovery, Workspace, WorkspaceError};
2323

24-
use crate::commands::project::{find_requires_python, script_python_requirement};
24+
use crate::commands::project::{find_requires_python, init_script_python_requirement};
2525
use crate::commands::reporters::PythonDownloadReporter;
2626
use crate::commands::ExitStatus;
2727
use crate::printer::Printer;
@@ -207,7 +207,7 @@ async fn init_script(
207207
}
208208
};
209209

210-
let requires_python = script_python_requirement(
210+
let requires_python = init_script_python_requirement(
211211
python.as_deref(),
212212
&CWD,
213213
no_pin_python,
@@ -659,7 +659,7 @@ impl InitProjectKind {
659659

660660
// Write .python-version if it doesn't exist.
661661
if let Some(python_request) = python_request {
662-
if PythonVersionFile::discover(path, false, false)
662+
if PythonVersionFile::discover(path, &VersionFileDiscoveryOptions::default())
663663
.await?
664664
.is_none()
665665
{
@@ -724,7 +724,7 @@ impl InitProjectKind {
724724

725725
// Write .python-version if it doesn't exist.
726726
if let Some(python_request) = python_request {
727-
if PythonVersionFile::discover(path, false, false)
727+
if PythonVersionFile::discover(path, &VersionFileDiscoveryOptions::default())
728728
.await?
729729
.is_none()
730730
{

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

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

0 commit comments

Comments
 (0)