diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 74b969511b2b..137de506e006 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -1797,6 +1797,20 @@ pub struct InitArgs { /// Do not create a readme file. #[arg(long)] pub no_readme: bool, + + /// The Python interpreter to use to determine the minimum supported Python version. + /// + /// By default, uv uses the virtual environment in the current working directory or any parent + /// directory, falling back to searching for a Python executable in `PATH`. The `--python` + /// option allows you to specify a different interpreter. + /// + /// Supported formats: + /// - `3.10` looks for an installed Python 3.10 using `py --list-paths` on Windows, or + /// `python3.10` on Linux and macOS. + /// - `python3.10` or `python.exe` looks for a binary with the given name in `PATH`. + /// - `/home/ferris/.local/bin/python3.10` uses the exact Python at the given path. + #[arg(long, short, env = "UV_PYTHON", verbatim_doc_comment)] + pub python: Option, } #[derive(Args)] diff --git a/crates/uv-resolver/src/requires_python.rs b/crates/uv-resolver/src/requires_python.rs index 34edaf596a35..41ae5ef8000c 100644 --- a/crates/uv-resolver/src/requires_python.rs +++ b/crates/uv-resolver/src/requires_python.rs @@ -49,6 +49,21 @@ impl RequiresPython { } } + /// Returns a [`RequiresPython`] from a version specifier. + pub fn from_specifiers(specifiers: &VersionSpecifiers) -> Result { + let bound = RequiresPythonBound( + crate::pubgrub::PubGrubSpecifier::from_release_specifiers(specifiers)? + .iter() + .next() + .map(|(lower, _)| lower.clone()) + .unwrap_or(Bound::Unbounded), + ); + Ok(Self { + specifiers: specifiers.clone(), + bound, + }) + } + /// Returns a [`RequiresPython`] to express the union of the given version specifiers. /// /// For example, given `>=3.8` and `>=3.9`, this would return `>=3.8`. diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index a44625453b3d..89e2db607c11 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -3,14 +3,23 @@ use std::path::PathBuf; use anyhow::Result; use owo_colors::OwoColorize; - +use pep440_rs::Version; use pep508_rs::PackageName; +use uv_cache::Cache; +use uv_client::{BaseClientBuilder, Connectivity}; use uv_configuration::PreviewMode; use uv_fs::{absolutize_path, Simplified}; +use uv_python::{ + EnvironmentPreference, PythonFetch, PythonInstallation, PythonPreference, PythonRequest, + VersionRequest, +}; +use uv_resolver::RequiresPython; use uv_warnings::warn_user_once; use uv_workspace::pyproject_mut::PyProjectTomlMut; use uv_workspace::{Workspace, WorkspaceError}; +use crate::commands::project::find_requires_python; +use crate::commands::reporters::PythonDownloadReporter; use crate::commands::ExitStatus; use crate::printer::Printer; @@ -20,8 +29,14 @@ pub(crate) async fn init( explicit_path: Option, name: Option, no_readme: bool, + python: Option, isolated: bool, preview: PreviewMode, + python_preference: PythonPreference, + python_fetch: PythonFetch, + connectivity: Connectivity, + native_tls: bool, + cache: &Cache, printer: Printer, ) -> Result { if preview.is_disabled() { @@ -62,20 +77,15 @@ pub(crate) async fn init( // Canonicalize the path to the project. let path = absolutize_path(&path)?; - // Create the `pyproject.toml`. + // Create the `pyproject.toml` before workspace discovery. If the directory already exists, + // and is a workspace member via `tools.uv.workspace`, we don't want discovery to fail due to + // the missing `pyproject.toml`. let pyproject = indoc::formatdoc! {r#" [project] name = "{name}" version = "0.1.0" - description = "Add your description here"{readme} - dependencies = [] - - [tool.uv] - dev-dependencies = [] "#, - readme = if no_readme { "" } else { "\nreadme = \"README.md\"" }, }; - fs_err::create_dir_all(&path)?; fs_err::write(path.join("pyproject.toml"), pyproject)?; @@ -92,6 +102,90 @@ pub(crate) async fn init( } }; + // Add a `requires-python` field to the `pyproject.toml`. + let requires_python = 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([ + major as u64, + minor as u64, + ])) + } + PythonRequest::Version(VersionRequest::MajorMinorPatch(major, minor, patch)) => { + RequiresPython::greater_than_equal_version(&Version::new([ + major as u64, + minor as u64, + patch as u64, + ])) + } + PythonRequest::Version(VersionRequest::Range(specifiers)) => { + RequiresPython::from_specifiers(&specifiers)? + } + request => { + let reporter = PythonDownloadReporter::single(printer); + let client_builder = BaseClientBuilder::new() + .connectivity(connectivity) + .native_tls(native_tls); + let interpreter = PythonInstallation::find_or_fetch( + Some(request), + EnvironmentPreference::Any, + python_preference, + python_fetch, + &client_builder, + cache, + Some(&reporter), + ) + .await? + .into_interpreter(); + RequiresPython::greater_than_equal_version(&interpreter.python_minor_version()) + } + } + } else if let Some(requires_python) = workspace + .as_ref() + .and_then(|workspace| find_requires_python(workspace).ok().flatten()) + { + // (2) `Requires-Python` from the workspace + requires_python + } 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_fetch( + Some(request), + EnvironmentPreference::Any, + python_preference, + python_fetch, + &client_builder, + cache, + Some(&reporter), + ) + .await? + .into_interpreter(); + RequiresPython::greater_than_equal_version(&interpreter.python_minor_version()) + }; + + // Add the remaining metadata to the `pyproject.toml`. + let pyproject = indoc::formatdoc! {r#" + [project] + name = "{name}" + version = "0.1.0" + description = "Add your description here"{readme} + requires-python = "{requires_python}" + dependencies = [] + + [tool.uv] + dev-dependencies = [] + "#, + readme = if no_readme { "" } else { "\nreadme = \"README.md\"" }, + requires_python = requires_python.specifiers(), + }; + fs_err::create_dir_all(&path)?; + fs_err::write(path.join("pyproject.toml"), pyproject)?; + // Create `src/{name}/__init__.py` if it does not already exist. let src_dir = path.join("src").join(&*name.as_dist_info_name()); let init_py = src_dir.join("__init__.py"); diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index f8a662dad5b7..8cb49ad288c7 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -834,8 +834,14 @@ async fn run_project( args.path, args.name, args.no_readme, + args.python, globals.isolated, globals.preview, + globals.python_preference, + globals.python_fetch, + globals.connectivity, + globals.native_tls, + &cache, printer, ) .await diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 786e633b4a7d..2ee565f6c80d 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -154,6 +154,7 @@ pub(crate) struct InitSettings { pub(crate) path: Option, pub(crate) name: Option, pub(crate) no_readme: bool, + pub(crate) python: Option, } impl InitSettings { @@ -164,12 +165,14 @@ impl InitSettings { path, name, no_readme, + python, } = args; Self { path, name, no_readme, + python, } } } diff --git a/crates/uv/tests/init.rs b/crates/uv/tests/init.rs index c2654f28bc35..376c23843ba5 100644 --- a/crates/uv/tests/init.rs +++ b/crates/uv/tests/init.rs @@ -37,6 +37,7 @@ fn init() -> Result<()> { version = "0.1.0" description = "Add your description here" readme = "README.md" + requires-python = ">=3.12" dependencies = [] [tool.uv] @@ -65,7 +66,6 @@ fn init() -> Result<()> { ----- stderr ----- warning: `uv lock` is experimental and may change without warning Using Python 3.12.[X] interpreter at: [PYTHON-3.12] - warning: No `requires-python` field found in the workspace. Defaulting to `>=3.12`. Resolved 1 package in [TIME] "###); @@ -98,6 +98,7 @@ fn init_no_readme() -> Result<()> { name = "foo" version = "0.1.0" description = "Add your description here" + requires-python = ">=3.12" dependencies = [] [tool.uv] @@ -140,6 +141,7 @@ fn current_dir() -> Result<()> { version = "0.1.0" description = "Add your description here" readme = "README.md" + requires-python = ">=3.12" dependencies = [] [tool.uv] @@ -168,7 +170,6 @@ fn current_dir() -> Result<()> { ----- stderr ----- warning: `uv lock` is experimental and may change without warning Using Python 3.12.[X] interpreter at: [PYTHON-3.12] - warning: No `requires-python` field found in the workspace. Defaulting to `>=3.12`. Resolved 1 package in [TIME] "###); @@ -219,6 +220,7 @@ fn init_workspace() -> Result<()> { version = "0.1.0" description = "Add your description here" readme = "README.md" + requires-python = ">=3.12" dependencies = [] [tool.uv] @@ -314,6 +316,7 @@ fn init_workspace_relative_sub_package() -> Result<()> { version = "0.1.0" description = "Add your description here" readme = "README.md" + requires-python = ">=3.12" dependencies = [] [tool.uv] @@ -410,6 +413,7 @@ fn init_workspace_outside() -> Result<()> { version = "0.1.0" description = "Add your description here" readme = "README.md" + requires-python = ">=3.12" dependencies = [] [tool.uv] @@ -490,6 +494,7 @@ fn init_invalid_names() -> Result<()> { version = "0.1.0" description = "Add your description here" readme = "README.md" + requires-python = ">=3.12" dependencies = [] [tool.uv] @@ -624,6 +629,7 @@ fn init_nested_workspace() -> Result<()> { version = "0.1.0" description = "Add your description here" readme = "README.md" + requires-python = ">=3.12" dependencies = [] [tool.uv] @@ -810,3 +816,163 @@ fn init_matches_exclude() -> Result<()> { Ok(()) } + +/// Run `uv init`, inheriting the `requires-python` from the workspace. +#[test] +fn init_requires_python_workspace() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! { + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.10" + + [tool.uv.workspace] + members = [] + "#, + })?; + + let child = context.temp_dir.join("foo"); + uv_snapshot!(context.filters(), context.init().current_dir(&context.temp_dir).arg(&child), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv init` is experimental and may change without warning + Adding `foo` as member of workspace `[TEMP_DIR]/` + Initialized project `foo` at `[TEMP_DIR]/foo` + "###); + + let pyproject_toml = fs_err::read_to_string(child.join("pyproject.toml"))?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r###" + [project] + name = "foo" + version = "0.1.0" + description = "Add your description here" + readme = "README.md" + requires-python = ">=3.10" + dependencies = [] + + [tool.uv] + dev-dependencies = [] + "### + ); + }); + + Ok(()) +} + +/// Run `uv init`, inferring the `requires-python` from the `--python` flag. +#[test] +fn init_requires_python_version() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! { + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + + [tool.uv.workspace] + members = [] + "#, + })?; + + let child = context.temp_dir.join("foo"); + uv_snapshot!(context.filters(), context.init().current_dir(&context.temp_dir).arg(&child).arg("--python").arg("3.8"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv init` is experimental and may change without warning + Adding `foo` as member of workspace `[TEMP_DIR]/` + Initialized project `foo` at `[TEMP_DIR]/foo` + "###); + + let pyproject_toml = fs_err::read_to_string(child.join("pyproject.toml"))?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r###" + [project] + name = "foo" + version = "0.1.0" + description = "Add your description here" + readme = "README.md" + requires-python = ">=3.8" + dependencies = [] + + [tool.uv] + dev-dependencies = [] + "### + ); + }); + + Ok(()) +} + +/// Run `uv init`, inferring the `requires-python` from the `--python` flag, and preserving the +/// specifiers verbatim. +#[test] +fn init_requires_python_specifiers() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! { + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + + [tool.uv.workspace] + members = [] + "#, + })?; + + let child = context.temp_dir.join("foo"); + uv_snapshot!(context.filters(), context.init().current_dir(&context.temp_dir).arg(&child).arg("--python").arg("==3.8.*"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv init` is experimental and may change without warning + Adding `foo` as member of workspace `[TEMP_DIR]/` + Initialized project `foo` at `[TEMP_DIR]/foo` + "###); + + let pyproject_toml = fs_err::read_to_string(child.join("pyproject.toml"))?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r###" + [project] + name = "foo" + version = "0.1.0" + description = "Add your description here" + readme = "README.md" + requires-python = "==3.8.*" + dependencies = [] + + [tool.uv] + dev-dependencies = [] + "### + ); + }); + + Ok(()) +}