Pin `.python-version` in `uv init` (#6869)

## Summary

I'm not convinced that the behavior is correct as-implemented. When the
user passes a `--python >=3.8` or we discover a `requires-python` from
the workspace, we're currently writing that request out to
`.python-version`. I would probably rather that we write the resolved
patch version?

Closes https://github.com/astral-sh/uv/issues/6821.
This commit is contained in:
Charlie Marsh 2024-09-03 19:43:50 -04:00 committed by GitHub
parent 3e8d53c8eb
commit 50d7b9c38a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 350 additions and 113 deletions

View File

@ -2184,6 +2184,14 @@ pub struct InitArgs {
#[arg(long)]
pub no_readme: bool,
/// Do not create a `.python-version` file for the project.
///
/// By default, uv will create a `.python-version` file containing the minor version of
/// the discovered Python interpreter, which will cause subsequent uv commands to use that
/// version.
#[arg(long)]
pub no_pin_python: bool,
/// Avoid discovering a workspace and create a standalone project.
///
/// By default, uv searches for workspaces in the current directory or any

View File

@ -94,7 +94,7 @@ impl PythonVersionFile {
/// Create a new representation of a version file at the given path.
///
/// The file will not any versions; see [`PythonVersionFile::with_versions`].
/// The file will not any include versions; see [`PythonVersionFile::with_versions`].
/// The file will not be created; see [`PythonVersionFile::write`].
pub fn new(path: PathBuf) -> Self {
Self {

View File

@ -11,7 +11,7 @@ use uv_client::{BaseClientBuilder, Connectivity};
use uv_fs::{Simplified, CWD};
use uv_python::{
EnvironmentPreference, PythonDownloads, PythonInstallation, PythonPreference, PythonRequest,
VersionRequest,
PythonVersionFile, VersionRequest,
};
use uv_resolver::RequiresPython;
use uv_workspace::pyproject_mut::{DependencyTarget, PyProjectTomlMut};
@ -30,6 +30,7 @@ pub(crate) async fn init(
package: bool,
project_kind: InitProjectKind,
no_readme: bool,
no_pin_python: bool,
python: Option<String>,
no_workspace: bool,
python_preference: PythonPreference,
@ -73,6 +74,7 @@ pub(crate) async fn init(
package,
project_kind,
no_readme,
no_pin_python,
python,
no_workspace,
python_preference,
@ -121,6 +123,7 @@ async fn init_project(
package: bool,
project_kind: InitProjectKind,
no_readme: bool,
no_pin_python: bool,
python: Option<String>,
no_workspace: bool,
python_preference: PythonPreference,
@ -173,33 +176,77 @@ async fn init_project(
}
};
// Add a `requires-python` field to the `pyproject.toml`.
let requires_python = if let Some(request) = python.as_deref() {
let reporter = PythonDownloadReporter::single(printer);
let client_builder = BaseClientBuilder::new()
.connectivity(connectivity)
.native_tls(native_tls);
// Add a `requires-python` field to the `pyproject.toml` and return the corresponding interpreter.
let (requires_python, python_request) = if let Some(request) = python.as_deref() {
// (1) Explicit request from user
match PythonRequest::parse(request) {
PythonRequest::Version(VersionRequest::MajorMinor(major, minor)) => {
RequiresPython::greater_than_equal_version(&Version::new([
let requires_python = RequiresPython::greater_than_equal_version(&Version::new([
u64::from(major),
u64::from(minor),
]))
]));
let python_request = if no_pin_python {
None
} else {
Some(PythonRequest::Version(VersionRequest::MajorMinor(
major, minor,
)))
};
(requires_python, python_request)
}
PythonRequest::Version(VersionRequest::MajorMinorPatch(major, minor, patch)) => {
RequiresPython::greater_than_equal_version(&Version::new([
let requires_python = RequiresPython::greater_than_equal_version(&Version::new([
u64::from(major),
u64::from(minor),
u64::from(patch),
]))
]));
let python_request = if no_pin_python {
None
} else {
Some(PythonRequest::Version(VersionRequest::MajorMinorPatch(
major, minor, patch,
)))
};
(requires_python, python_request)
}
PythonRequest::Version(VersionRequest::Range(specifiers)) => {
RequiresPython::from_specifiers(&specifiers)?
ref python_request @ PythonRequest::Version(VersionRequest::Range(ref specifiers)) => {
let requires_python = RequiresPython::from_specifiers(specifiers)?;
let python_request = if no_pin_python {
None
} else {
let interpreter = PythonInstallation::find_or_download(
Some(python_request),
EnvironmentPreference::Any,
python_preference,
python_downloads,
&client_builder,
cache,
Some(&reporter),
)
.await?
.into_interpreter();
Some(PythonRequest::Version(VersionRequest::MajorMinor(
interpreter.python_major(),
interpreter.python_minor(),
)))
};
(requires_python, python_request)
}
request => {
let reporter = PythonDownloadReporter::single(printer);
let client_builder = BaseClientBuilder::new()
.connectivity(connectivity)
.native_tls(native_tls);
python_request => {
let interpreter = PythonInstallation::find_or_download(
Some(&request),
Some(&python_request),
EnvironmentPreference::Any,
python_preference,
python_downloads,
@ -209,7 +256,20 @@ async fn init_project(
)
.await?
.into_interpreter();
RequiresPython::greater_than_equal_version(&interpreter.python_minor_version())
let requires_python =
RequiresPython::greater_than_equal_version(&interpreter.python_minor_version());
let python_request = if no_pin_python {
None
} else {
Some(PythonRequest::Version(VersionRequest::MajorMinor(
interpreter.python_major(),
interpreter.python_minor(),
)))
};
(requires_python, python_request)
}
}
} else if let Some(requires_python) = workspace
@ -217,16 +277,36 @@ async fn init_project(
.and_then(|workspace| find_requires_python(workspace).ok().flatten())
{
// (2) `Requires-Python` from the workspace
requires_python
let python_request =
PythonRequest::Version(VersionRequest::Range(requires_python.specifiers().clone()));
// Pin to the minor version.
let python_request = if no_pin_python {
None
} else {
let interpreter = PythonInstallation::find_or_download(
Some(&python_request),
EnvironmentPreference::Any,
python_preference,
python_downloads,
&client_builder,
cache,
Some(&reporter),
)
.await?
.into_interpreter();
Some(PythonRequest::Version(VersionRequest::MajorMinor(
interpreter.python_major(),
interpreter.python_minor(),
)))
};
(requires_python, python_request)
} else {
// (3) Default to the system Python
let request = PythonRequest::Any;
let reporter = PythonDownloadReporter::single(printer);
let client_builder = BaseClientBuilder::new()
.connectivity(connectivity)
.native_tls(native_tls);
let interpreter = PythonInstallation::find_or_download(
Some(&request),
None,
EnvironmentPreference::Any,
python_preference,
python_downloads,
@ -236,10 +316,33 @@ async fn init_project(
)
.await?
.into_interpreter();
RequiresPython::greater_than_equal_version(&interpreter.python_minor_version())
let requires_python =
RequiresPython::greater_than_equal_version(&interpreter.python_minor_version());
// Pin to the minor version.
let python_request = if no_pin_python {
None
} else {
Some(PythonRequest::Version(VersionRequest::MajorMinor(
interpreter.python_major(),
interpreter.python_minor(),
)))
};
(requires_python, python_request)
};
project_kind.init(name, path, &requires_python, no_readme, package)?;
project_kind
.init(
name,
path,
&requires_python,
python_request.as_ref(),
no_readme,
package,
)
.await?;
if let Some(workspace) = workspace {
if workspace.excludes(path)? {
@ -284,7 +387,7 @@ async fn init_project(
Ok(())
}
#[derive(Debug, Clone, Default)]
#[derive(Debug, Copy, Clone, Default)]
pub(crate) enum InitProjectKind {
#[default]
Application,
@ -293,55 +396,145 @@ pub(crate) enum InitProjectKind {
impl InitProjectKind {
/// Initialize this project kind at the target path.
fn init(
&self,
async fn init(
self,
name: &PackageName,
path: &Path,
requires_python: &RequiresPython,
python_request: Option<&PythonRequest>,
no_readme: bool,
package: bool,
) -> Result<()> {
match self {
InitProjectKind::Application => {
init_application(name, path, requires_python, no_readme, package)
self.init_application(
name,
path,
requires_python,
python_request,
no_readme,
package,
)
.await
}
InitProjectKind::Library => {
init_library(name, path, requires_python, no_readme, package)
self.init_library(
name,
path,
requires_python,
python_request,
no_readme,
package,
)
.await
}
}
}
/// Whether or not this project kind is packaged by default.
pub(crate) fn packaged_by_default(&self) -> bool {
/// Whether this project kind is packaged by default.
pub(crate) fn packaged_by_default(self) -> bool {
matches!(self, InitProjectKind::Library)
}
}
fn init_application(
name: &PackageName,
path: &Path,
requires_python: &RequiresPython,
no_readme: bool,
package: bool,
) -> Result<()> {
// Create the `pyproject.toml`
let mut pyproject = pyproject_project(name, requires_python, no_readme);
async fn init_application(
self,
name: &PackageName,
path: &Path,
requires_python: &RequiresPython,
python_request: Option<&PythonRequest>,
no_readme: bool,
package: bool,
) -> Result<()> {
// Create the `pyproject.toml`
let mut pyproject = pyproject_project(name, requires_python, no_readme);
// Include additional project configuration for packaged applications
if package {
// Since it'll be packaged, we can add a `[project.scripts]` entry
pyproject.push('\n');
pyproject.push_str(&pyproject_project_scripts(name, "hello", "hello"));
// Include additional project configuration for packaged applications
if package {
// Since it'll be packaged, we can add a `[project.scripts]` entry
pyproject.push('\n');
pyproject.push_str(&pyproject_project_scripts(name, "hello", "hello"));
// Add a build system
pyproject.push('\n');
pyproject.push_str(pyproject_build_system());
// Add a build system
pyproject.push('\n');
pyproject.push_str(pyproject_build_system());
}
fs_err::create_dir_all(path)?;
// Create the source structure.
if package {
// Create `src/{name}/__init__.py`, if it doesn't exist already.
let src_dir = path.join("src").join(&*name.as_dist_info_name());
let init_py = src_dir.join("__init__.py");
if !init_py.try_exists()? {
fs_err::create_dir_all(&src_dir)?;
fs_err::write(
init_py,
indoc::formatdoc! {r#"
def hello():
print("Hello from {name}!")
"#},
)?;
}
} else {
// Create `hello.py` if it doesn't exist
// TODO(zanieb): Only create `hello.py` if there are no other Python files?
let hello_py = path.join("hello.py");
if !hello_py.try_exists()? {
fs_err::write(
path.join("hello.py"),
indoc::formatdoc! {r#"
def main():
print("Hello from {name}!")
if __name__ == "__main__":
main()
"#},
)?;
}
}
fs_err::write(path.join("pyproject.toml"), pyproject)?;
// Write .python-version if it doesn't exist.
if let Some(python_request) = python_request {
if PythonVersionFile::discover(path, false, false)
.await?
.is_none()
{
PythonVersionFile::new(path.join(".python-version"))
.with_versions(vec![python_request.clone()])
.write()
.await?;
}
}
Ok(())
}
fs_err::create_dir_all(path)?;
async fn init_library(
self,
name: &PackageName,
path: &Path,
requires_python: &RequiresPython,
python_request: Option<&PythonRequest>,
no_readme: bool,
package: bool,
) -> Result<()> {
if !package {
return Err(anyhow!("Library projects must be packaged"));
}
// Create the `pyproject.toml`
let mut pyproject = pyproject_project(name, requires_python, no_readme);
// Always include a build system if the project is packaged.
pyproject.push('\n');
pyproject.push_str(pyproject_build_system());
fs_err::create_dir_all(path)?;
fs_err::write(path.join("pyproject.toml"), pyproject)?;
// Create the source structure.
if package {
// Create `src/{name}/__init__.py`, if it doesn't exist already.
let src_dir = path.join("src").join(&*name.as_dist_info_name());
let init_py = src_dir.join("__init__.py");
@ -350,70 +543,27 @@ fn init_application(
fs_err::write(
init_py,
indoc::formatdoc! {r#"
def hello():
print("Hello from {name}!")
def hello() -> str:
return "Hello from {name}!"
"#},
)?;
}
} else {
// Create `hello.py` if it doesn't exist
// TODO(zanieb): Only create `hello.py` if there are no other Python files?
let hello_py = path.join("hello.py");
if !hello_py.try_exists()? {
fs_err::write(
path.join("hello.py"),
indoc::formatdoc! {r#"
def main():
print("Hello from {name}!")
if __name__ == "__main__":
main()
"#},
)?;
// Write .python-version if it doesn't exist.
if let Some(python_request) = python_request {
if PythonVersionFile::discover(path, false, false)
.await?
.is_none()
{
PythonVersionFile::new(path.join(".python-version"))
.with_versions(vec![python_request.clone()])
.write()
.await?;
}
}
Ok(())
}
fs_err::write(path.join("pyproject.toml"), pyproject)?;
Ok(())
}
fn init_library(
name: &PackageName,
path: &Path,
requires_python: &RequiresPython,
no_readme: bool,
package: bool,
) -> Result<()> {
if !package {
return Err(anyhow!("Library projects must be packaged"));
}
// Create the `pyproject.toml`
let mut pyproject = pyproject_project(name, requires_python, no_readme);
// Always include a build system if the project is packaged.
pyproject.push('\n');
pyproject.push_str(pyproject_build_system());
fs_err::create_dir_all(path)?;
fs_err::write(path.join("pyproject.toml"), pyproject)?;
// Create `src/{name}/__init__.py`, if it doesn't exist already.
let src_dir = path.join("src").join(&*name.as_dist_info_name());
let init_py = src_dir.join("__init__.py");
if !init_py.try_exists()? {
fs_err::create_dir_all(&src_dir)?;
fs_err::write(
init_py,
indoc::formatdoc! {r#"
def hello() -> str:
return "Hello from {name}!"
"#},
)?;
}
Ok(())
}
/// Generate the `[project]` section of a `pyproject.toml`.

View File

@ -1028,6 +1028,7 @@ async fn run_project(
args.package,
args.kind,
args.no_readme,
args.no_pin_python,
args.python,
args.no_workspace,
globals.python_preference,

View File

@ -157,6 +157,7 @@ pub(crate) struct InitSettings {
pub(crate) package: bool,
pub(crate) kind: InitProjectKind,
pub(crate) no_readme: bool,
pub(crate) no_pin_python: bool,
pub(crate) no_workspace: bool,
pub(crate) python: Option<String>,
}
@ -174,6 +175,7 @@ impl InitSettings {
app,
lib,
no_readme,
no_pin_python,
no_workspace,
python,
} = args;
@ -193,6 +195,7 @@ impl InitSettings {
package,
kind,
no_readme,
no_pin_python,
no_workspace,
python,
}

View File

@ -53,6 +53,16 @@ fn init() -> Result<()> {
Resolved 1 package in [TIME]
"###);
let python_version =
fs_err::read_to_string(context.temp_dir.join("foo").join(".python-version"))?;
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
python_version, @"3.12"
);
});
Ok(())
}
@ -455,6 +465,40 @@ fn init_no_readme() -> Result<()> {
Ok(())
}
#[test]
fn init_no_pin_python() -> Result<()> {
let context = TestContext::new("3.12");
uv_snapshot!(context.filters(), context.init().arg("foo").arg("--no-pin-python"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Initialized project `foo` at `[TEMP_DIR]/foo`
"###);
let pyproject = fs_err::read_to_string(context.temp_dir.join("foo/pyproject.toml"))?;
let _ = fs_err::read_to_string(context.temp_dir.join("foo/.python-version")).unwrap_err();
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
pyproject, @r###"
[project]
name = "foo"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = []
"###
);
});
Ok(())
}
#[test]
fn init_library_current_dir() -> Result<()> {
let context = TestContext::new("3.12");
@ -1618,6 +1662,15 @@ fn init_requires_python_workspace() -> Result<()> {
);
});
let python_version = fs_err::read_to_string(child.join(".python-version"))?;
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
python_version, @"3.12"
);
});
Ok(())
}
@ -1667,6 +1720,15 @@ fn init_requires_python_version() -> Result<()> {
);
});
let python_version = fs_err::read_to_string(child.join(".python-version"))?;
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
python_version, @"3.8"
);
});
Ok(())
}
@ -1674,7 +1736,7 @@ fn init_requires_python_version() -> Result<()> {
/// specifiers verbatim.
#[test]
fn init_requires_python_specifiers() -> Result<()> {
let context = TestContext::new("3.12");
let context = TestContext::new_with_versions(&["3.8", "3.12"]);
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! {
@ -1717,6 +1779,15 @@ fn init_requires_python_specifiers() -> Result<()> {
);
});
let python_version = fs_err::read_to_string(child.join(".python-version"))?;
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
python_version, @"3.8"
);
});
Ok(())
}

View File

@ -453,6 +453,10 @@ uv init [OPTIONS] [PATH]
<p>This is the default behavior when using <code>--app</code>.</p>
</dd><dt><code>--no-pin-python</code></dt><dd><p>Do not create a <code>.python-version</code> file for the project.</p>
<p>By default, uv will create a <code>.python-version</code> file containing the minor version of the discovered Python interpreter, which will cause subsequent uv commands to use that version.</p>
</dd><dt><code>--no-progress</code></dt><dd><p>Hide all progress outputs.</p>
<p>For example, spinners or progress bars.</p>