Add uv init --virtual (#5396)

## Summary

Add `uv init --virtual` to create an explicit virtual workspace.

Relates to #5338
This commit is contained in:
Jo 2024-07-25 02:52:33 +08:00 committed by GitHub
parent ac614ee70f
commit 7bcafec778
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 196 additions and 54 deletions

View file

@ -1794,7 +1794,11 @@ pub struct InitArgs {
#[arg(long)] #[arg(long)]
pub name: Option<PackageName>, pub name: Option<PackageName>,
/// Do not create a readme file. /// Create a virtual workspace instead of a project.
#[arg(long)]
pub r#virtual: bool,
/// Do not create a `README.md` file.
#[arg(long)] #[arg(long)]
pub no_readme: bool, pub no_readme: bool,

View file

@ -1,5 +1,6 @@
pub use workspace::{ pub use workspace::{
DiscoveryOptions, ProjectWorkspace, VirtualProject, Workspace, WorkspaceError, WorkspaceMember, check_nested_workspaces, DiscoveryOptions, ProjectWorkspace, VirtualProject, Workspace,
WorkspaceError, WorkspaceMember,
}; };
pub mod pyproject; pub mod pyproject;

View file

@ -160,6 +160,8 @@ impl Workspace {
workspace_root.simplified_display() workspace_root.simplified_display()
); );
check_nested_workspaces(&workspace_root, options);
// Unlike in `ProjectWorkspace` discovery, we might be in a virtual workspace root without // Unlike in `ProjectWorkspace` discovery, we might be in a virtual workspace root without
// being in any specific project. // being in any specific project.
let current_project = pyproject_toml let current_project = pyproject_toml
@ -170,6 +172,7 @@ impl Workspace {
project, project,
pyproject_toml, pyproject_toml,
}); });
Self::collect_members( Self::collect_members(
workspace_root.clone(), workspace_root.clone(),
// This method supports only absolute paths. // This method supports only absolute paths.
@ -526,8 +529,6 @@ impl Workspace {
.and_then(|uv| uv.sources) .and_then(|uv| uv.sources)
.unwrap_or_default(); .unwrap_or_default();
check_nested_workspaces(&workspace_root, options);
Ok(Workspace { Ok(Workspace {
install_path: workspace_root, install_path: workspace_root,
lock_path, lock_path,
@ -966,7 +967,7 @@ async fn find_workspace(
} }
/// Warn when the valid workspace is included in another workspace. /// Warn when the valid workspace is included in another workspace.
fn check_nested_workspaces(inner_workspace_root: &Path, options: &DiscoveryOptions) { pub fn check_nested_workspaces(inner_workspace_root: &Path, options: &DiscoveryOptions) {
for outer_workspace_root in inner_workspace_root for outer_workspace_root in inner_workspace_root
.ancestors() .ancestors()
.take_while(|path| { .take_while(|path| {
@ -1025,8 +1026,9 @@ fn check_nested_workspaces(inner_workspace_root: &Path, options: &DiscoveryOptio
}; };
if !is_excluded { if !is_excluded {
warn_user!( warn_user!(
"Nested workspaces are not supported, but outer workspace includes existing workspace: `{}`", "Nested workspaces are not supported, but outer workspace (`{}`) includes `{}`",
pyproject_toml_path.user_display().cyan(), outer_workspace_root.simplified_display().cyan(),
inner_workspace_root.simplified_display().cyan()
); );
} }
} }
@ -1159,6 +1161,8 @@ impl VirtualProject {
.map_err(WorkspaceError::Normalize)? .map_err(WorkspaceError::Normalize)?
.to_path_buf(); .to_path_buf();
check_nested_workspaces(&project_path, options);
let workspace = Workspace::collect_members( let workspace = Workspace::collect_members(
project_path, project_path,
PathBuf::new(), PathBuf::new(),

View file

@ -1,5 +1,5 @@
use std::fmt::Write; use std::fmt::Write;
use std::path::PathBuf; use std::path::Path;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use owo_colors::OwoColorize; use owo_colors::OwoColorize;
@ -16,7 +16,7 @@ use uv_python::{
use uv_resolver::RequiresPython; use uv_resolver::RequiresPython;
use uv_warnings::warn_user_once; use uv_warnings::warn_user_once;
use uv_workspace::pyproject_mut::PyProjectTomlMut; use uv_workspace::pyproject_mut::PyProjectTomlMut;
use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceError}; use uv_workspace::{check_nested_workspaces, DiscoveryOptions, Workspace, WorkspaceError};
use crate::commands::project::find_requires_python; use crate::commands::project::find_requires_python;
use crate::commands::reporters::PythonDownloadReporter; use crate::commands::reporters::PythonDownloadReporter;
@ -24,10 +24,11 @@ use crate::commands::ExitStatus;
use crate::printer::Printer; use crate::printer::Printer;
/// Add one or more packages to the project requirements. /// Add one or more packages to the project requirements.
#[allow(clippy::single_match_else)] #[allow(clippy::single_match_else, clippy::fn_params_excessive_bools)]
pub(crate) async fn init( pub(crate) async fn init(
explicit_path: Option<String>, explicit_path: Option<String>,
name: Option<PackageName>, name: Option<PackageName>,
r#virtual: bool,
no_readme: bool, no_readme: bool,
python: Option<String>, python: Option<String>,
isolated: bool, isolated: bool,
@ -46,7 +47,7 @@ pub(crate) async fn init(
// Default to the current directory if a path was not provided. // Default to the current directory if a path was not provided.
let path = match explicit_path { let path = match explicit_path {
None => std::env::current_dir()?.canonicalize()?, None => std::env::current_dir()?.canonicalize()?,
Some(ref path) => PathBuf::from(path), Some(ref path) => absolutize_path(Path::new(path))?.to_path_buf(),
}; };
// Make sure a project does not already exist in the given directory. // Make sure a project does not already exist in the given directory.
@ -61,9 +62,6 @@ pub(crate) async fn init(
); );
} }
// Canonicalize the path to the project.
let path = absolutize_path(&path)?;
// Default to the directory name if a name was not provided. // Default to the directory name if a name was not provided.
let name = match name { let name = match name {
Some(name) => name, Some(name) => name,
@ -77,6 +75,96 @@ pub(crate) async fn init(
} }
}; };
if r#virtual {
init_virtual_workspace(&path, isolated)?;
} else {
init_project(
&path,
&name,
no_readme,
python,
isolated,
python_preference,
python_fetch,
connectivity,
native_tls,
cache,
printer,
)
.await?;
}
// Create the `README.md` if it does not already exist.
if !no_readme {
let readme = path.join("README.md");
if !readme.exists() {
fs_err::write(readme, String::new())?;
}
}
let project = if r#virtual { "workspace" } else { "project" };
match explicit_path {
// Initialized a project in the current directory.
None => {
writeln!(
printer.stderr(),
"Initialized {} `{}`",
project,
name.cyan()
)?;
}
// Initialized a project in the given directory.
Some(path) => {
let path = path
.simple_canonicalize()
.unwrap_or_else(|_| path.simplified().to_path_buf());
writeln!(
printer.stderr(),
"Initialized {} `{}` at `{}`",
project,
name.cyan(),
path.display().cyan()
)?;
}
}
Ok(ExitStatus::Success)
}
/// Initialize a virtual workspace at the given path.
fn init_virtual_workspace(path: &Path, isolated: bool) -> Result<()> {
// Ensure that we aren't creating a nested workspace.
if !isolated {
check_nested_workspaces(path, &DiscoveryOptions::default());
}
// Create the `pyproject.toml`.
let pyproject = indoc::indoc! {r"
[tool.uv.workspace]
members = []
"};
fs_err::create_dir_all(path)?;
fs_err::write(path.join("pyproject.toml"), pyproject)?;
Ok(())
}
/// Initialize a project (and, implicitly, a workspace root) at the given path.
async fn init_project(
path: &Path,
name: &PackageName,
no_readme: bool,
python: Option<String>,
isolated: bool,
python_preference: PythonPreference,
python_fetch: PythonFetch,
connectivity: Connectivity,
native_tls: bool,
cache: &Cache,
printer: Printer,
) -> Result<()> {
// Discover the current workspace, if it exists. // Discover the current workspace, if it exists.
let workspace = if isolated { let workspace = if isolated {
None None
@ -86,7 +174,7 @@ pub(crate) async fn init(
match Workspace::discover( match Workspace::discover(
parent, parent,
&DiscoveryOptions { &DiscoveryOptions {
ignore: std::iter::once(path.as_ref()).collect(), ignore: std::iter::once(path).collect(),
..DiscoveryOptions::default() ..DiscoveryOptions::default()
}, },
) )
@ -177,7 +265,8 @@ pub(crate) async fn init(
readme = if no_readme { "" } else { "\nreadme = \"README.md\"" }, readme = if no_readme { "" } else { "\nreadme = \"README.md\"" },
requires_python = requires_python.specifiers(), requires_python = requires_python.specifiers(),
}; };
fs_err::create_dir_all(&path)?;
fs_err::create_dir_all(path)?;
fs_err::write(path.join("pyproject.toml"), pyproject)?; fs_err::write(path.join("pyproject.toml"), pyproject)?;
// Create `src/{name}/__init__.py` if it does not already exist. // Create `src/{name}/__init__.py` if it does not already exist.
@ -194,16 +283,8 @@ pub(crate) async fn init(
)?; )?;
} }
// Create the `README.md` if it does not already exist.
if !no_readme {
let readme = path.join("README.md");
if !readme.exists() {
fs_err::write(readme, String::new())?;
}
}
if let Some(workspace) = workspace { if let Some(workspace) = workspace {
if workspace.excludes(&path)? { if workspace.excludes(path)? {
// If the member is excluded by the workspace, ignore it. // If the member is excluded by the workspace, ignore it.
writeln!( writeln!(
printer.stderr(), printer.stderr(),
@ -211,7 +292,7 @@ pub(crate) async fn init(
name.cyan(), name.cyan(),
workspace.install_path().simplified_display().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. // If the member is already included in the workspace, skip the `members` addition.
writeln!( writeln!(
printer.stderr(), printer.stderr(),
@ -239,26 +320,5 @@ pub(crate) async fn init(
} }
} }
match explicit_path { Ok(())
// Initialized a project in the current directory.
None => {
writeln!(printer.stderr(), "Initialized project `{}`", name.cyan())?;
}
// Initialized a project in the given directory.
Some(path) => {
let path = path
.simple_canonicalize()
.unwrap_or_else(|_| path.simplified().to_path_buf());
writeln!(
printer.stderr(),
"Initialized project `{}` at `{}`",
name.cyan(),
path.display().cyan()
)?;
}
}
Ok(ExitStatus::Success)
} }

View file

@ -857,6 +857,7 @@ async fn run_project(
commands::init( commands::init(
args.path, args.path,
args.name, args.name,
args.r#virtual,
args.no_readme, args.no_readme,
args.python, args.python,
globals.isolated, globals.isolated,

View file

@ -153,6 +153,7 @@ impl CacheSettings {
pub(crate) struct InitSettings { pub(crate) struct InitSettings {
pub(crate) path: Option<String>, pub(crate) path: Option<String>,
pub(crate) name: Option<PackageName>, pub(crate) name: Option<PackageName>,
pub(crate) r#virtual: bool,
pub(crate) no_readme: bool, pub(crate) no_readme: bool,
pub(crate) python: Option<String>, pub(crate) python: Option<String>,
} }
@ -164,6 +165,7 @@ impl InitSettings {
let InitArgs { let InitArgs {
path, path,
name, name,
r#virtual,
no_readme, no_readme,
python, python,
} = args; } = args;
@ -171,6 +173,7 @@ impl InitSettings {
Self { Self {
path, path,
name, name,
r#virtual,
no_readme, no_readme,
python, python,
} }

View file

@ -603,7 +603,7 @@ fn init_workspace_isolated() -> Result<()> {
} }
#[test] #[test]
fn init_nested_workspace() -> Result<()> { fn init_project_inside_project() -> Result<()> {
let context = TestContext::new("3.12"); let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml"); let pyproject_toml = context.temp_dir.child("pyproject.toml");
@ -733,6 +733,64 @@ fn init_explicit_workspace() -> Result<()> {
fn init_virtual_workspace() -> Result<()> { fn init_virtual_workspace() -> Result<()> {
let context = TestContext::new("3.12"); let context = TestContext::new("3.12");
let child = context.temp_dir.child("foo");
child.create_dir_all()?;
let pyproject_toml = child.join("pyproject.toml");
uv_snapshot!(context.filters(), context.init().current_dir(&child).arg("--virtual"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: `uv init` is experimental and may change without warning
Initialized workspace `foo`
"###);
let pyproject = fs_err::read_to_string(&pyproject_toml)?;
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
pyproject, @r###"
[tool.uv.workspace]
members = []
"###
);
});
uv_snapshot!(context.filters(), context.init().current_dir(&child).arg("bar"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: `uv init` is experimental and may change without warning
Adding `bar` as member of workspace `[TEMP_DIR]/foo`
Initialized project `bar` at `[TEMP_DIR]/foo/bar`
"###);
let pyproject = fs_err::read_to_string(pyproject_toml)?;
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
pyproject, @r###"
[tool.uv.workspace]
members = ["bar"]
"###
);
});
Ok(())
}
/// Run `uv init --virtual` from within a workspace.
#[test]
fn init_nested_virtual_workspace() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml"); let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! { pyproject_toml.write_str(indoc! {
r" r"
@ -741,18 +799,29 @@ fn init_virtual_workspace() -> Result<()> {
", ",
})?; })?;
let child = context.temp_dir.join("foo"); uv_snapshot!(context.filters(), context.init().current_dir(&context.temp_dir).arg("--virtual").arg("foo"), @r###"
uv_snapshot!(context.filters(), context.init().current_dir(&context.temp_dir).arg(&child), @r###"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
warning: `uv init` is experimental and may change without warning warning: `uv init` is experimental and may change without warning
Adding `foo` as member of workspace `[TEMP_DIR]/` warning: Nested workspaces are not supported, but outer workspace (`[TEMP_DIR]/`) includes `[TEMP_DIR]/foo`
Initialized project `foo` at `[TEMP_DIR]/foo` Initialized workspace `foo` at `[TEMP_DIR]/foo`
"###); "###);
let pyproject = fs_err::read_to_string(context.temp_dir.join("foo").join("pyproject.toml"))?;
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
pyproject, @r###"
[tool.uv.workspace]
members = []
"###
);
});
let workspace = fs_err::read_to_string(context.temp_dir.join("pyproject.toml"))?; let workspace = fs_err::read_to_string(context.temp_dir.join("pyproject.toml"))?;
insta::with_settings!({ insta::with_settings!({
filters => context.filters(), filters => context.filters(),
@ -760,7 +829,7 @@ fn init_virtual_workspace() -> Result<()> {
assert_snapshot!( assert_snapshot!(
workspace, @r###" workspace, @r###"
[tool.uv.workspace] [tool.uv.workspace]
members = ["foo"] members = []
"### "###
); );
}); });