From 0f8186d9adde63be0b8ff4af28b5adb2f287374b Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 23 Jul 2024 12:02:40 -0400 Subject: [PATCH] Add `requires-python` to `uv init` (#5322) ## Summary Prefers, in order: - The major-minor version of an interpreter discovered via `--python`. - The `requires-python` from the workspace. - The major-minor version of the default interpreter. If the `--python` request is a version or a version range, we use that without fetching an interpreter. Closes https://github.com/astral-sh/uv/issues/5299. --- crates/uv-cli/src/lib.rs | 14 ++ .../src/metadata/requires_dist.rs | 17 +- crates/uv-resolver/src/requires_python.rs | 15 ++ crates/uv-workspace/src/lib.rs | 4 +- crates/uv-workspace/src/workspace.rs | 125 ++++++++---- crates/uv/src/commands/project/add.rs | 6 +- crates/uv/src/commands/project/init.rs | 132 +++++++++++-- crates/uv/src/commands/project/lock.rs | 5 +- crates/uv/src/commands/project/remove.rs | 6 +- crates/uv/src/commands/project/run.rs | 8 +- crates/uv/src/commands/project/sync.rs | 5 +- crates/uv/src/commands/project/tree.rs | 5 +- crates/uv/src/commands/python/pin.rs | 21 +- crates/uv/src/lib.rs | 19 +- crates/uv/src/settings.rs | 3 + crates/uv/tests/init.rs | 179 +++++++++++++++++- 16 files changed, 461 insertions(+), 103 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 74b969511..137de506e 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-distribution/src/metadata/requires_dist.rs b/crates/uv-distribution/src/metadata/requires_dist.rs index 34d75acb6..d30d61449 100644 --- a/crates/uv-distribution/src/metadata/requires_dist.rs +++ b/crates/uv-distribution/src/metadata/requires_dist.rs @@ -5,7 +5,7 @@ use once_cell::sync::Lazy; use uv_configuration::PreviewMode; use uv_normalize::{ExtraName, GroupName, PackageName}; -use uv_workspace::ProjectWorkspace; +use uv_workspace::{DiscoveryOptions, ProjectWorkspace}; use crate::metadata::lowering::lower_requirement; use crate::metadata::MetadataError; @@ -52,8 +52,12 @@ impl RequiresDist { ) -> Result { // TODO(konsti): Limit discovery for Git checkouts to Git root. // TODO(konsti): Cache workspace discovery. - let Some(project_workspace) = - ProjectWorkspace::from_maybe_project_root(install_path, lock_path, None).await? + let Some(project_workspace) = ProjectWorkspace::from_maybe_project_root( + install_path, + lock_path, + &DiscoveryOptions::default(), + ) + .await? else { return Ok(Self::from_metadata23(metadata)); }; @@ -155,7 +159,7 @@ mod test { use uv_configuration::PreviewMode; use uv_workspace::pyproject::PyProjectToml; - use uv_workspace::ProjectWorkspace; + use uv_workspace::{DiscoveryOptions, ProjectWorkspace}; use crate::RequiresDist; @@ -170,7 +174,10 @@ mod test { .as_ref() .context("metadata field project not found")?, &pyproject_toml, - Some(path), + &DiscoveryOptions { + stop_discovery_at: Some(path), + ..DiscoveryOptions::default() + }, ) .await?; let requires_dist = pypi_types::RequiresDist::parse_pyproject_toml(contents)?; diff --git a/crates/uv-resolver/src/requires_python.rs b/crates/uv-resolver/src/requires_python.rs index 34edaf596..41ae5ef80 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-workspace/src/lib.rs b/crates/uv-workspace/src/lib.rs index b778a8e1d..db7979cfb 100644 --- a/crates/uv-workspace/src/lib.rs +++ b/crates/uv-workspace/src/lib.rs @@ -1,4 +1,6 @@ -pub use workspace::{ProjectWorkspace, VirtualProject, Workspace, WorkspaceError, WorkspaceMember}; +pub use workspace::{ + DiscoveryOptions, ProjectWorkspace, VirtualProject, Workspace, WorkspaceError, WorkspaceMember, +}; pub mod pyproject; pub mod pyproject_mut; diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index e153d9846..8f060dc5d 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -42,6 +42,14 @@ pub enum WorkspaceError { Normalize(#[source] std::io::Error), } +#[derive(Debug, Default, Clone)] +pub struct DiscoveryOptions<'a> { + /// The path to stop discovery at. + pub stop_discovery_at: Option<&'a Path>, + /// The set of member paths to ignore. + pub ignore: FxHashSet<&'a Path>, +} + /// A workspace, consisting of a root directory and members. See [`ProjectWorkspace`]. #[derive(Debug, Clone)] #[cfg_attr(test, derive(serde::Serialize))] @@ -80,7 +88,7 @@ impl Workspace { /// * If there is no explicit workspace: We have a single project workspace, we're done. pub async fn discover( path: &Path, - stop_discovery_at: Option<&Path>, + options: &DiscoveryOptions<'_>, ) -> Result { let path = absolutize_path(path) .map_err(WorkspaceError::Normalize)? @@ -133,8 +141,7 @@ impl Workspace { } else if pyproject_toml.project.is_none() { // Without a project, it can't be an implicit root return Err(WorkspaceError::MissingProject(project_path)); - } else if let Some(workspace) = find_workspace(&project_path, stop_discovery_at).await? - { + } else if let Some(workspace) = find_workspace(&project_path, options).await? { // We have found an explicit root above. workspace } else { @@ -168,7 +175,7 @@ impl Workspace { workspace_definition, workspace_pyproject_toml, current_project, - stop_discovery_at, + options, ) .await } @@ -345,11 +352,19 @@ impl Workspace { } } - /// Returns `true` if the path is a workspace member. - pub fn includes(&self, project_path: &Path) -> bool { - self.packages - .values() - .any(|member| project_path == member.root()) + /// Returns `true` if the path is included by the workspace. + pub fn includes(&self, project_path: &Path) -> Result { + if let Some(workspace) = self + .pyproject_toml + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.workspace.as_ref()) + { + is_included_in_workspace(project_path, &self.install_path, workspace) + } else { + Ok(false) + } } /// Collect the workspace member projects from the `members` and `excludes` entries. @@ -359,7 +374,7 @@ impl Workspace { workspace_definition: ToolUvWorkspace, workspace_pyproject_toml: PyProjectToml, current_project: Option, - stop_discovery_at: Option<&Path>, + options: &DiscoveryOptions<'_>, ) -> Result { let mut workspace_members = BTreeMap::new(); // Avoid reading a `pyproject.toml` more than once. @@ -421,6 +436,13 @@ impl Workspace { if !seen.insert(member_root.clone()) { continue; } + if options.ignore.contains(member_root.as_path()) { + debug!( + "Ignoring workspace member: `{}`", + member_root.simplified_display() + ); + continue; + } let member_root = absolutize_path(&member_root) .map_err(WorkspaceError::Normalize)? .to_path_buf(); @@ -474,7 +496,7 @@ impl Workspace { .and_then(|uv| uv.sources) .unwrap_or_default(); - check_nested_workspaces(&workspace_root, stop_discovery_at); + check_nested_workspaces(&workspace_root, options); Ok(Workspace { install_path: workspace_root, @@ -612,13 +634,14 @@ impl ProjectWorkspace { /// only directories between the current path and `stop_discovery_at` are considered. pub async fn discover( path: &Path, - stop_discovery_at: Option<&Path>, + options: &DiscoveryOptions<'_>, ) -> Result { let project_root = path .ancestors() .take_while(|path| { // Only walk up the given directory, if any. - stop_discovery_at + options + .stop_discovery_at .map(|stop_discovery_at| stop_discovery_at != *path) .unwrap_or(true) }) @@ -630,13 +653,13 @@ impl ProjectWorkspace { project_root.simplified_display() ); - Self::from_project_root(project_root, stop_discovery_at).await + Self::from_project_root(project_root, options).await } /// Discover the workspace starting from the directory containing the `pyproject.toml`. async fn from_project_root( project_root: &Path, - stop_discovery_at: Option<&Path>, + options: &DiscoveryOptions<'_>, ) -> Result { // Read the current `pyproject.toml`. let pyproject_path = project_root.join("pyproject.toml"); @@ -655,7 +678,7 @@ impl ProjectWorkspace { Path::new(""), &project, &pyproject_toml, - stop_discovery_at, + options, ) .await } @@ -665,7 +688,7 @@ impl ProjectWorkspace { pub async fn from_maybe_project_root( install_path: &Path, lock_path: &Path, - stop_discovery_at: Option<&Path>, + options: &DiscoveryOptions<'_>, ) -> Result, WorkspaceError> { // Read the `pyproject.toml`. let pyproject_path = install_path.join("pyproject.toml"); @@ -683,14 +706,7 @@ impl ProjectWorkspace { }; Ok(Some( - Self::from_project( - install_path, - lock_path, - &project, - &pyproject_toml, - stop_discovery_at, - ) - .await?, + Self::from_project(install_path, lock_path, &project, &pyproject_toml, options).await?, )) } @@ -721,7 +737,7 @@ impl ProjectWorkspace { lock_path: &Path, project: &Project, project_pyproject_toml: &PyProjectToml, - stop_discovery_at: Option<&Path>, + options: &DiscoveryOptions<'_>, ) -> Result { let project_path = absolutize_path(install_path) .map_err(WorkspaceError::Normalize)? @@ -756,7 +772,7 @@ impl ProjectWorkspace { if workspace.is_none() { // The project isn't an explicit workspace root, check if we're a regular workspace // member by looking for an explicit workspace root above. - workspace = find_workspace(&project_path, stop_discovery_at).await?; + workspace = find_workspace(&project_path, options).await?; } let current_project = WorkspaceMember { @@ -819,7 +835,7 @@ impl ProjectWorkspace { workspace_definition, workspace_pyproject_toml, Some(current_project), - stop_discovery_at, + options, ) .await?; @@ -834,14 +850,15 @@ impl ProjectWorkspace { /// Find the workspace root above the current project, if any. async fn find_workspace( project_root: &Path, - stop_discovery_at: Option<&Path>, + options: &DiscoveryOptions<'_>, ) -> Result, WorkspaceError> { // Skip 1 to ignore the current project itself. for workspace_root in project_root .ancestors() .take_while(|path| { // Only walk up the given directory, if any. - stop_discovery_at + options + .stop_discovery_at .map(|stop_discovery_at| stop_discovery_at != *path) .unwrap_or(true) }) @@ -919,12 +936,13 @@ async fn find_workspace( } /// Warn when the valid workspace is included in another workspace. -fn check_nested_workspaces(inner_workspace_root: &Path, stop_discovery_at: Option<&Path>) { +fn check_nested_workspaces(inner_workspace_root: &Path, options: &DiscoveryOptions) { for outer_workspace_root in inner_workspace_root .ancestors() .take_while(|path| { // Only walk up the given directory, if any. - stop_discovery_at + options + .stop_discovery_at .map(|stop_discovery_at| stop_discovery_at != *path) .unwrap_or(true) }) @@ -1013,6 +1031,31 @@ fn is_excluded_from_workspace( Ok(false) } +/// Check if we're in the `tool.uv.workspace.members` of a workspace. +fn is_included_in_workspace( + project_path: &Path, + workspace_root: &Path, + workspace: &ToolUvWorkspace, +) -> Result { + for member_glob in workspace.members.iter().flatten() { + let absolute_glob = workspace_root + .simplified() + .join(member_glob.as_str()) + .to_string_lossy() + .to_string(); + for member_root in glob(&absolute_glob) + .map_err(|err| WorkspaceError::Pattern(absolute_glob.to_string(), err))? + { + let member_root = + member_root.map_err(|err| WorkspaceError::Glob(absolute_glob.to_string(), err))?; + if member_root == project_path { + return Ok(true); + } + } + } + Ok(false) +} + /// A project that can be synced. /// /// The project could be a package within a workspace, a real workspace root, or even a virtual @@ -1035,7 +1078,7 @@ impl VirtualProject { /// discovering the main workspace. pub async fn discover( path: &Path, - stop_discovery_at: Option<&Path>, + options: &DiscoveryOptions<'_>, ) -> Result { assert!( path.is_absolute(), @@ -1045,7 +1088,8 @@ impl VirtualProject { .ancestors() .take_while(|path| { // Only walk up the given directory, if any. - stop_discovery_at + options + .stop_discovery_at .map(|stop_discovery_at| stop_discovery_at != *path) .unwrap_or(true) }) @@ -1070,7 +1114,7 @@ impl VirtualProject { Path::new(""), project, &pyproject_toml, - stop_discovery_at, + options, ) .await?; Ok(Self::Project(project)) @@ -1091,7 +1135,7 @@ impl VirtualProject { workspace.clone(), pyproject_toml, None, - stop_discovery_at, + options, ) .await?; @@ -1135,7 +1179,7 @@ mod tests { use insta::assert_json_snapshot; - use crate::workspace::ProjectWorkspace; + use crate::workspace::{DiscoveryOptions, ProjectWorkspace}; async fn workspace_test(folder: &str) -> (ProjectWorkspace, String) { let root_dir = env::current_dir() @@ -1146,9 +1190,10 @@ mod tests { .unwrap() .join("scripts") .join("workspaces"); - let project = ProjectWorkspace::discover(&root_dir.join(folder), None) - .await - .unwrap(); + let project = + ProjectWorkspace::discover(&root_dir.join(folder), &DiscoveryOptions::default()) + .await + .unwrap(); let root_escaped = regex::escape(root_dir.to_string_lossy().as_ref()); (project, root_escaped) } diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 8a79645b4..a05e665d8 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -14,7 +14,7 @@ use uv_types::{BuildIsolation, HashStrategy}; use uv_warnings::warn_user_once; use uv_workspace::pyproject::{DependencyType, Source, SourceError}; use uv_workspace::pyproject_mut::PyProjectTomlMut; -use uv_workspace::{ProjectWorkspace, VirtualProject, Workspace}; +use uv_workspace::{DiscoveryOptions, ProjectWorkspace, VirtualProject, Workspace}; use crate::commands::pip::operations::Modifications; use crate::commands::pip::resolution_environment; @@ -54,12 +54,12 @@ pub(crate) async fn add( // Find the project in the workspace. let project = if let Some(package) = package { - Workspace::discover(&std::env::current_dir()?, None) + Workspace::discover(&std::env::current_dir()?, &DiscoveryOptions::default()) .await? .with_current_project(package.clone()) .with_context(|| format!("Package `{package}` not found in workspace"))? } else { - ProjectWorkspace::discover(&std::env::current_dir()?, None).await? + ProjectWorkspace::discover(&std::env::current_dir()?, &DiscoveryOptions::default()).await? }; // Discover or create the virtual environment. diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index a811b6cc2..03c7ffbb1 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::{Context, 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 uv_workspace::{DiscoveryOptions, 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,36 +77,111 @@ pub(crate) async fn init( } }; - // Create the `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)?; - // Discover the current workspace, if it exists. let workspace = if isolated { None } else { // Attempt to find a workspace root. let parent = path.parent().expect("Project path has no parent"); - match Workspace::discover(parent, None).await { + match Workspace::discover( + parent, + &DiscoveryOptions { + ignore: std::iter::once(path.as_ref()).collect(), + ..DiscoveryOptions::default() + }, + ) + .await + { Ok(workspace) => Some(workspace), Err(WorkspaceError::MissingPyprojectToml) => None, Err(err) => return Err(err.into()), } }; + // 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([ + u64::from(major), + u64::from(minor), + ])) + } + PythonRequest::Version(VersionRequest::MajorMinorPatch(major, minor, patch)) => { + RequiresPython::greater_than_equal_version(&Version::new([ + u64::from(major), + u64::from(minor), + u64::from(patch), + ])) + } + 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()) + }; + + // Create 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"); @@ -123,7 +213,7 @@ pub(crate) async fn init( name.cyan(), workspace.install_path().simplified_display().cyan() )?; - } else if workspace.includes(&path) { + } else if workspace.includes(&path)? { // If the member is already included in the workspace, skip the `members` addition. writeln!( printer.stderr(), diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index d7e2b5e81..7a7d21ed5 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -24,7 +24,7 @@ use uv_resolver::{ }; use uv_types::{BuildIsolation, EmptyInstalledPackages, HashStrategy}; use uv_warnings::{warn_user, warn_user_once}; -use uv_workspace::Workspace; +use uv_workspace::{DiscoveryOptions, Workspace}; use crate::commands::project::{find_requires_python, FoundInterpreter, ProjectError, SharedState}; use crate::commands::{pip, ExitStatus}; @@ -51,7 +51,8 @@ pub(crate) async fn lock( } // Find the project requirements. - let workspace = Workspace::discover(&std::env::current_dir()?, None).await?; + let workspace = + Workspace::discover(&std::env::current_dir()?, &DiscoveryOptions::default()).await?; // Find an interpreter for the project let interpreter = FoundInterpreter::discover( diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs index 85c958717..b771f4ad0 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -8,7 +8,7 @@ use uv_python::{PythonFetch, PythonPreference, PythonRequest}; use uv_warnings::{warn_user, warn_user_once}; use uv_workspace::pyproject::DependencyType; use uv_workspace::pyproject_mut::PyProjectTomlMut; -use uv_workspace::{ProjectWorkspace, VirtualProject, Workspace}; +use uv_workspace::{DiscoveryOptions, ProjectWorkspace, VirtualProject, Workspace}; use crate::commands::pip::operations::Modifications; use crate::commands::{project, ExitStatus, SharedState}; @@ -39,12 +39,12 @@ pub(crate) async fn remove( // Find the project in the workspace. let project = if let Some(package) = package { - Workspace::discover(&std::env::current_dir()?, None) + Workspace::discover(&std::env::current_dir()?, &DiscoveryOptions::default()) .await? .with_current_project(package.clone()) .with_context(|| format!("Package `{package}` not found in workspace"))? } else { - ProjectWorkspace::discover(&std::env::current_dir()?, None).await? + ProjectWorkspace::discover(&std::env::current_dir()?, &DiscoveryOptions::default()).await? }; let mut pyproject = PyProjectTomlMut::from_toml(project.current_project().pyproject_toml())?; diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 8af2b6ecf..3d75e588f 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -23,7 +23,7 @@ use uv_python::{ }; use uv_requirements::{RequirementsSource, RequirementsSpecification}; use uv_warnings::warn_user_once; -use uv_workspace::{VirtualProject, Workspace, WorkspaceError}; +use uv_workspace::{DiscoveryOptions, VirtualProject, Workspace, WorkspaceError}; use crate::commands::pip::operations::Modifications; use crate::commands::project::environment::CachedEnvironment; @@ -144,13 +144,15 @@ pub(crate) async fn run( // We need a workspace, but we don't need to have a current package, we can be e.g. in // the root of a virtual workspace and then switch into the selected package. Some(VirtualProject::Project( - Workspace::discover(&std::env::current_dir()?, None) + Workspace::discover(&std::env::current_dir()?, &DiscoveryOptions::default()) .await? .with_current_project(package.clone()) .with_context(|| format!("Package `{package}` not found in workspace"))?, )) } else { - match VirtualProject::discover(&std::env::current_dir()?, None).await { + match VirtualProject::discover(&std::env::current_dir()?, &DiscoveryOptions::default()) + .await + { Ok(project) => Some(project), Err(WorkspaceError::MissingPyprojectToml) => None, Err(WorkspaceError::NonWorkspace(_)) => None, diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 0da237732..ddd40a78e 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -12,7 +12,7 @@ use uv_python::{PythonEnvironment, PythonFetch, PythonPreference, PythonRequest} use uv_resolver::{FlatIndex, Lock}; use uv_types::{BuildIsolation, HashStrategy}; use uv_warnings::warn_user_once; -use uv_workspace::VirtualProject; +use uv_workspace::{DiscoveryOptions, VirtualProject}; use crate::commands::pip::operations::Modifications; use crate::commands::project::lock::do_safe_lock; @@ -45,7 +45,8 @@ pub(crate) async fn sync( } // Identify the project - let project = VirtualProject::discover(&std::env::current_dir()?, None).await?; + let project = + VirtualProject::discover(&std::env::current_dir()?, &DiscoveryOptions::default()).await?; // Discover or create the virtual environment. let venv = project::get_or_init_environment( diff --git a/crates/uv/src/commands/project/tree.rs b/crates/uv/src/commands/project/tree.rs index 081f56518..905017bc2 100644 --- a/crates/uv/src/commands/project/tree.rs +++ b/crates/uv/src/commands/project/tree.rs @@ -10,7 +10,7 @@ use uv_client::Connectivity; use uv_configuration::{Concurrency, PreviewMode}; use uv_python::{PythonFetch, PythonPreference, PythonRequest}; use uv_warnings::warn_user_once; -use uv_workspace::Workspace; +use uv_workspace::{DiscoveryOptions, Workspace}; use crate::commands::pip::tree::DisplayDependencyGraph; use crate::commands::project::FoundInterpreter; @@ -47,7 +47,8 @@ pub(crate) async fn tree( } // Find the project requirements. - let workspace = Workspace::discover(&std::env::current_dir()?, None).await?; + let workspace = + Workspace::discover(&std::env::current_dir()?, &DiscoveryOptions::default()).await?; // Find an interpreter for the project let interpreter = FoundInterpreter::discover( diff --git a/crates/uv/src/commands/python/pin.rs b/crates/uv/src/commands/python/pin.rs index 8206f437d..9b28e4913 100644 --- a/crates/uv/src/commands/python/pin.rs +++ b/crates/uv/src/commands/python/pin.rs @@ -14,7 +14,7 @@ use uv_python::{ PYTHON_VERSION_FILENAME, }; use uv_warnings::warn_user_once; -use uv_workspace::VirtualProject; +use uv_workspace::{DiscoveryOptions, VirtualProject}; use crate::commands::{project::find_requires_python, ExitStatus}; use crate::printer::Printer; @@ -33,14 +33,17 @@ pub(crate) async fn pin( warn_user_once!("`uv python pin` is experimental and may change without warning"); } - let virtual_project = match VirtualProject::discover(&std::env::current_dir()?, None).await { - Ok(virtual_project) if !isolated => Some(virtual_project), - Ok(_) => None, - Err(err) => { - debug!("Failed to discover virtual project: {err}"); - None - } - }; + let virtual_project = + match VirtualProject::discover(&std::env::current_dir()?, &DiscoveryOptions::default()) + .await + { + Ok(virtual_project) if !isolated => Some(virtual_project), + Ok(_) => None, + Err(err) => { + debug!("Failed to discover virtual project: {err}"); + None + } + }; let Some(request) = request else { // Display the current pinned Python version diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index f8a662dad..a9d9d1691 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1,4 +1,3 @@ -use std::env; use std::ffi::OsString; use std::fmt::Write; use std::io::stdout; @@ -24,7 +23,7 @@ use uv_cli::{SelfCommand, SelfNamespace}; use uv_configuration::Concurrency; use uv_requirements::RequirementsSource; use uv_settings::{Combine, FilesystemOptions}; -use uv_workspace::Workspace; +use uv_workspace::{DiscoveryOptions, Workspace}; use crate::commands::{ExitStatus, ToolRunCommand}; use crate::printer::Printer; @@ -74,12 +73,14 @@ async fn run(cli: Cli) -> Result { Some(FilesystemOptions::from_file(config_file)?) } else if cli.global_args.isolated { None - } else if let Ok(project) = Workspace::discover(&env::current_dir()?, None).await { + } else if let Ok(project) = + Workspace::discover(&std::env::current_dir()?, &DiscoveryOptions::default()).await + { let project = FilesystemOptions::from_directory(project.install_path())?; let user = FilesystemOptions::user()?; project.combine(user) } else { - let project = FilesystemOptions::find(env::current_dir()?)?; + let project = FilesystemOptions::find(std::env::current_dir()?)?; let user = FilesystemOptions::user()?; project.combine(user) }; @@ -130,7 +131,7 @@ async fn run(cli: Cli) -> Result { .break_words(false) .word_separator(textwrap::WordSeparator::AsciiSpace) .word_splitter(textwrap::WordSplitter::NoHyphenation) - .wrap_lines(env::var("UV_NO_WRAP").map(|_| false).unwrap_or(true)) + .wrap_lines(std::env::var("UV_NO_WRAP").map(|_| false).unwrap_or(true)) .build(), ) }))?; @@ -834,8 +835,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 @@ -1100,7 +1107,7 @@ where // We support increasing the stack size to avoid stack overflows in debug mode on Windows. In // addition, we box types and futures in various places. This includes the `Box::pin(run())` // here, which prevents the large (non-send) main future alone from overflowing the stack. - let result = if let Ok(stack_size) = env::var("UV_STACK_SIZE") { + let result = if let Ok(stack_size) = std::env::var("UV_STACK_SIZE") { let stack_size = stack_size.parse().expect("Invalid stack size"); let tokio_main = move || { let runtime = tokio::runtime::Builder::new_current_thread() diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 786e633b4..2ee565f6c 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 3cb16c27e..146439c7c 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] "###); @@ -206,6 +207,7 @@ fn init_dot_args() -> Result<()> { version = "0.1.0" description = "Add your description here" readme = "README.md" + requires-python = ">=3.12" dependencies = [] [tool.uv] @@ -234,7 +236,6 @@ fn init_dot_args() -> 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] "###); @@ -285,6 +286,7 @@ fn init_workspace() -> Result<()> { version = "0.1.0" description = "Add your description here" readme = "README.md" + requires-python = ">=3.12" dependencies = [] [tool.uv] @@ -352,7 +354,6 @@ fn init_workspace_relative_sub_package() -> Result<()> { })?; let child = context.temp_dir.join("foo"); - fs_err::create_dir(&child)?; uv_snapshot!(context.filters(), context.init().current_dir(&context.temp_dir).arg("foo"), @r###" success: true @@ -380,6 +381,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] @@ -447,7 +449,6 @@ fn init_workspace_outside() -> Result<()> { })?; let child = context.temp_dir.join("foo"); - fs_err::create_dir(&child)?; // Run `uv init ` outside the workspace. uv_snapshot!(context.filters(), context.init().current_dir(&context.home_dir).arg(&child), @r###" @@ -476,6 +477,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] @@ -556,6 +558,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] @@ -690,6 +693,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] @@ -804,8 +808,11 @@ fn init_matches_members() -> Result<()> { ", })?; + // Create the parent directory (`packages`) and the child directory (`foo`), to ensure that + // the empty child directory does _not_ trigger a workspace discovery error despite being a + // valid member. let packages = context.temp_dir.join("packages"); - fs_err::create_dir_all(packages)?; + fs_err::create_dir_all(packages.join("foo"))?; uv_snapshot!(context.filters(), context.init().current_dir(context.temp_dir.join("packages")).arg("foo"), @r###" success: true @@ -876,3 +883,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(()) +}