From 50d7b9c38acf472c6ce4c5cf81a20a1741d0fdec Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 3 Sep 2024 19:43:50 -0400 Subject: [PATCH] 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. --- crates/uv-cli/src/lib.rs | 8 + crates/uv-python/src/version_files.rs | 2 +- crates/uv/src/commands/project/init.rs | 368 +++++++++++++++++-------- crates/uv/src/lib.rs | 1 + crates/uv/src/settings.rs | 3 + crates/uv/tests/init.rs | 73 ++++- docs/reference/cli.md | 4 + 7 files changed, 348 insertions(+), 111 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 885bb2508992..37acc18a4077 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -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 diff --git a/crates/uv-python/src/version_files.rs b/crates/uv-python/src/version_files.rs index c7c7c0f300a7..eb1bdff52fb0 100644 --- a/crates/uv-python/src/version_files.rs +++ b/crates/uv-python/src/version_files.rs @@ -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 { diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index 9f156e26485b..9e51959f5363 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -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, 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, 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")); + + // Add a build system + pyproject.push('\n'); + pyproject.push_str(pyproject_build_system()); + } - // 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")); + 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)?; - // Add a build system + // 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(()) + } + + 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::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?; + } } - } - 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(()) } - - Ok(()) } /// Generate the `[project]` section of a `pyproject.toml`. diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 92d470953d95..c841433e3294 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -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, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 55302c500531..95ddb3514355 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -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, } @@ -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, } diff --git a/crates/uv/tests/init.rs b/crates/uv/tests/init.rs index 8f551d5335de..f6411bf9d817 100644 --- a/crates/uv/tests/init.rs +++ b/crates/uv/tests/init.rs @@ -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(()) } diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 254d497c8185..4ba82d500610 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -453,6 +453,10 @@ uv init [OPTIONS] [PATH]

This is the default behavior when using --app.

+
--no-pin-python

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.

+
--no-progress

Hide all progress outputs.

For example, spinners or progress bars.