Implement uv init (#4791)

## Summary

Implements the `uv init` command, which initializes a project
(`pyproject.toml`, `README.md`, `src/__init__.py`) in the current
directory, or in the given path. `uv init` also does workspace
discovery.

Resolves https://github.com/astral-sh/uv/issues/1360.

---------

Co-authored-by: Zanie Blue <contact@zanie.dev>
This commit is contained in:
Ibraheem Ahmed 2024-07-19 11:11:48 -04:00 committed by GitHub
parent 2169902bd9
commit 12dd450a8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 533 additions and 2 deletions

1
Cargo.lock generated
View file

@ -4760,6 +4760,7 @@ dependencies = [
"nanoid",
"once_cell",
"path-absolutize",
"path-slash",
"pep440_rs",
"pep508_rs",
"platform-tags",

View file

@ -358,6 +358,9 @@ pub enum PipCommand {
#[derive(Subcommand)]
pub enum ProjectCommand {
/// Initialize a project.
#[clap(hide = true)]
Init(InitArgs),
/// Run a command in the project environment.
#[clap(hide = true)]
#[command(
@ -1781,6 +1784,21 @@ impl ExternalCommand {
}
}
#[derive(Args)]
#[allow(clippy::struct_excessive_bools)]
pub struct InitArgs {
/// The path of the project.
pub path: Option<String>,
/// The name of the project, defaults to the name of the directory.
#[arg(long)]
pub name: Option<PackageName>,
/// Do not create a readme file.
#[arg(long)]
pub no_readme: bool,
}
#[derive(Args)]
#[allow(clippy::struct_excessive_bools)]
pub struct RunArgs {

View file

@ -40,6 +40,7 @@ glob = { workspace = true }
nanoid = { workspace = true }
once_cell = { workspace = true }
path-absolutize = { workspace = true }
path-slash = { workspace = true }
reqwest = { workspace = true }
reqwest-middleware = { workspace = true }
rmp-serde = { workspace = true }

View file

@ -1,6 +1,8 @@
use std::path::Path;
use std::str::FromStr;
use std::{fmt, mem};
use path_slash::PathExt;
use thiserror::Error;
use toml_edit::{Array, DocumentMut, Item, RawString, Table, TomlError, Value};
@ -26,6 +28,8 @@ pub enum Error {
MalformedDependencies,
#[error("Sources in `pyproject.toml` are malformed")]
MalformedSources,
#[error("Workspace in `pyproject.toml` is malformed")]
MalformedWorkspace,
#[error("Cannot perform ambiguous update; found multiple entries with matching package names")]
Ambiguous,
}
@ -38,6 +42,35 @@ impl PyProjectTomlMut {
})
}
/// Adds a project to the workspace.
pub fn add_workspace(&mut self, path: impl AsRef<Path>) -> Result<(), Error> {
// Get or create `tool.uv.workspace.members`.
let members = self
.doc
.entry("tool")
.or_insert(implicit())
.as_table_mut()
.ok_or(Error::MalformedWorkspace)?
.entry("uv")
.or_insert(implicit())
.as_table_mut()
.ok_or(Error::MalformedWorkspace)?
.entry("workspace")
.or_insert(Item::Table(Table::new()))
.as_table_mut()
.ok_or(Error::MalformedWorkspace)?
.entry("members")
.or_insert(Item::Value(Value::Array(Array::new())))
.as_array_mut()
.ok_or(Error::MalformedWorkspace)?;
// Add the path to the workspace.
// Use cross-platform slashes so the toml string type does not change
members.push(path.as_ref().to_slash_lossy().to_string());
Ok(())
}
/// Adds a dependency to `project.dependencies`.
pub fn add_dependency(
&mut self,

View file

@ -53,6 +53,7 @@ fs-err = { workspace = true, features = ["tokio"] }
futures = { workspace = true }
indexmap = { workspace = true }
indicatif = { workspace = true }
indoc = { workspace = true }
itertools = { workspace = true }
miette = { workspace = true, features = ["fancy"] }
owo-colors = { workspace = true }

View file

@ -19,6 +19,7 @@ pub(crate) use pip::sync::pip_sync;
pub(crate) use pip::tree::pip_tree;
pub(crate) use pip::uninstall::pip_uninstall;
pub(crate) use project::add::add;
pub(crate) use project::init::init;
pub(crate) use project::lock::lock;
pub(crate) use project::remove::remove;
pub(crate) use project::run::run;

View file

@ -0,0 +1,155 @@
use std::fmt::Write;
use std::path::PathBuf;
use anyhow::Result;
use owo_colors::OwoColorize;
use pep508_rs::PackageName;
use uv_configuration::PreviewMode;
use uv_distribution::pyproject_mut::PyProjectTomlMut;
use uv_distribution::{ProjectWorkspace, WorkspaceError};
use uv_fs::Simplified;
use uv_warnings::warn_user_once;
use crate::commands::ExitStatus;
use crate::printer::Printer;
/// Add one or more packages to the project requirements.
#[allow(clippy::single_match_else)]
pub(crate) async fn init(
explicit_path: Option<String>,
name: Option<PackageName>,
no_readme: bool,
preview: PreviewMode,
printer: Printer,
) -> Result<ExitStatus> {
if preview.is_disabled() {
warn_user_once!("`uv init` is experimental and may change without warning");
}
// Discover the current workspace, if it exists.
let current_dir = std::env::current_dir()?.canonicalize()?;
let workspace = match ProjectWorkspace::discover(&current_dir, None).await {
Ok(project) => Some(project),
Err(WorkspaceError::MissingPyprojectToml) => None,
Err(err) => return Err(err.into()),
};
// Default to the current directory if a path was not provided.
let path = match explicit_path {
None => current_dir.clone(),
Some(ref path) => PathBuf::from(path),
};
// Default to the directory name if a name was not provided.
let name = match name {
Some(name) => name,
None => {
let name = path
.file_name()
.and_then(|path| path.to_str())
.expect("Invalid package name");
PackageName::new(name.to_string())?
}
};
// Make sure a project does not already exist in the given directory.
if path.join("pyproject.toml").exists() {
let path = path
.simple_canonicalize()
.unwrap_or_else(|_| path.simplified().to_path_buf());
anyhow::bail!(
"Project is already initialized in {}",
path.display().cyan()
);
}
// Create the directory for the project.
let src_dir = path.join("src").join(name.as_ref());
fs_err::create_dir_all(&src_dir)?;
// 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::write(path.join("pyproject.toml"), pyproject)?;
// Create `src/{name}/__init__.py` if it does not already exist.
let init_py = src_dir.join("__init__.py");
if !init_py.try_exists()? {
fs_err::write(
init_py,
indoc::formatdoc! {r#"
def hello() -> str:
return "Hello from {name}!"
"#},
)?;
}
// 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 {
// Add the package to the workspace.
let mut pyproject =
PyProjectTomlMut::from_toml(workspace.current_project().pyproject_toml())?;
pyproject.add_workspace(path.strip_prefix(workspace.project_root())?)?;
// Save the modified `pyproject.toml`.
fs_err::write(
workspace.current_project().root().join("pyproject.toml"),
pyproject.to_string(),
)?;
writeln!(
printer.stderr(),
"Adding {} as member of workspace {}",
name.cyan(),
workspace
.workspace()
.install_path()
.simplified_display()
.cyan()
)?;
}
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 project {} in {}",
name.cyan(),
path.display().cyan()
)?;
}
}
Ok(ExitStatus::Success)
}

View file

@ -35,6 +35,7 @@ use crate::settings::{InstallerSettingsRef, ResolverInstallerSettings, ResolverS
pub(crate) mod add;
pub(crate) mod environment;
pub(crate) mod init;
pub(crate) mod lock;
pub(crate) mod remove;
pub(crate) mod run;

View file

@ -821,6 +821,20 @@ async fn run_project(
}
match *project_command {
ProjectCommand::Init(args) => {
// Resolve the settings from the command-line arguments and workspace configuration.
let args = settings::InitSettings::resolve(args, filesystem);
show_settings!(args);
commands::init(
args.path,
args.name,
args.no_readme,
globals.preview,
printer,
)
.await
}
ProjectCommand::Run(args) => {
// Resolve the settings from the command-line arguments and workspace configuration.
let args = settings::RunSettings::resolve(args, filesystem);

View file

@ -11,8 +11,8 @@ use pypi_types::Requirement;
use uv_cache::{CacheArgs, Refresh};
use uv_cli::options::{flag, resolver_installer_options, resolver_options};
use uv_cli::{
AddArgs, ColorChoice, Commands, ExternalCommand, GlobalArgs, ListFormat, LockArgs, Maybe,
PipCheckArgs, PipCompileArgs, PipFreezeArgs, PipInstallArgs, PipListArgs, PipShowArgs,
AddArgs, ColorChoice, Commands, ExternalCommand, GlobalArgs, InitArgs, ListFormat, LockArgs,
Maybe, PipCheckArgs, PipCompileArgs, PipFreezeArgs, PipInstallArgs, PipListArgs, PipShowArgs,
PipSyncArgs, PipTreeArgs, PipUninstallArgs, PythonFindArgs, PythonInstallArgs, PythonListArgs,
PythonPinArgs, PythonUninstallArgs, RemoveArgs, RunArgs, SyncArgs, ToolDirArgs,
ToolInstallArgs, ToolListArgs, ToolRunArgs, ToolUninstallArgs, TreeArgs, VenvArgs,
@ -147,6 +147,33 @@ impl CacheSettings {
}
}
/// The resolved settings to use for a `init` invocation.
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone)]
pub(crate) struct InitSettings {
pub(crate) path: Option<String>,
pub(crate) name: Option<PackageName>,
pub(crate) no_readme: bool,
}
impl InitSettings {
/// Resolve the [`InitSettings`] from the CLI and filesystem configuration.
#[allow(clippy::needless_pass_by_value)]
pub(crate) fn resolve(args: InitArgs, _filesystem: Option<FilesystemOptions>) -> Self {
let InitArgs {
path,
name,
no_readme,
} = args;
Self {
path,
name,
no_readme,
}
}
}
/// The resolved settings to use for a `run` invocation.
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone)]

View file

@ -406,6 +406,14 @@ impl TestContext {
command
}
/// Create a `uv init` command with options shared across scenarios.
pub fn init(&self) -> Command {
let mut command = Command::new(get_bin());
command.arg("init");
self.add_shared_args(&mut command);
command
}
/// Create a `uv sync` command with options shared across scenarios.
pub fn sync(&self) -> Command {
let mut command = Command::new(get_bin());

271
crates/uv/tests/init.rs Normal file
View file

@ -0,0 +1,271 @@
#![cfg(all(feature = "python", feature = "pypi"))]
use anyhow::Result;
use assert_fs::prelude::*;
use indoc::indoc;
use insta::assert_snapshot;
use common::{uv_snapshot, TestContext};
mod common;
#[test]
fn init() -> Result<()> {
let context = TestContext::new("3.12");
uv_snapshot!(context.filters(), context.init().arg("foo"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: `uv init` is experimental and may change without warning
Initialized project foo in [TEMP_DIR]/foo
"###);
let pyproject = fs_err::read_to_string(context.temp_dir.join("foo/pyproject.toml"))?;
let init_py = fs_err::read_to_string(context.temp_dir.join("foo/src/foo/__init__.py"))?;
let _ = fs_err::read_to_string(context.temp_dir.join("foo/README.md")).unwrap();
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"
dependencies = []
[tool.uv]
dev-dependencies = []
"###
);
});
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
init_py, @r###"
def hello() -> str:
return "Hello from foo!"
"###
);
});
// Run `uv lock` in the new project.
uv_snapshot!(context.filters(), context.lock().current_dir(context.temp_dir.join("foo")), @r###"
success: true
exit_code: 0
----- stdout -----
----- 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]
"###);
Ok(())
}
#[test]
fn init_no_readme() -> Result<()> {
let context = TestContext::new("3.12");
uv_snapshot!(context.filters(), context.init().arg("foo").arg("--no-readme"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: `uv init` is experimental and may change without warning
Initialized project foo in [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/README.md")).unwrap_err();
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
pyproject, @r###"
[project]
name = "foo"
version = "0.1.0"
description = "Add your description here"
dependencies = []
[tool.uv]
dev-dependencies = []
"###
);
});
Ok(())
}
#[test]
fn current_dir() -> Result<()> {
let context = TestContext::new("3.12");
let dir = context.temp_dir.join("foo");
fs_err::create_dir(&dir)?;
uv_snapshot!(context.filters(), context.init().current_dir(&dir), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: `uv init` is experimental and may change without warning
Initialized project foo
"###);
let pyproject = fs_err::read_to_string(dir.join("pyproject.toml"))?;
let init_py = fs_err::read_to_string(dir.join("src/foo/__init__.py"))?;
let _ = fs_err::read_to_string(dir.join("README.md")).unwrap();
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"
dependencies = []
[tool.uv]
dev-dependencies = []
"###
);
});
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
init_py, @r###"
def hello() -> str:
return "Hello from foo!"
"###
);
});
// Run `uv lock` in the new project.
uv_snapshot!(context.filters(), context.lock().current_dir(&dir), @r###"
success: true
exit_code: 0
----- stdout -----
----- 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]
"###);
Ok(())
}
#[test]
fn init_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.12"
dependencies = ["anyio==3.7.0"]
"#,
})?;
let child = context.temp_dir.join("foo");
fs_err::create_dir(&child)?;
uv_snapshot!(context.filters(), context.init().current_dir(&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
"###);
let pyproject = fs_err::read_to_string(child.join("pyproject.toml"))?;
let init_py = fs_err::read_to_string(child.join("src/foo/__init__.py"))?;
let _ = fs_err::read_to_string(child.join("README.md")).unwrap();
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"
dependencies = []
[tool.uv]
dev-dependencies = []
"###
);
});
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
init_py, @r###"
def hello() -> str:
return "Hello from foo!"
"###
);
});
let workspace = fs_err::read_to_string(context.temp_dir.join("pyproject.toml"))?;
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
workspace, @r###"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["anyio==3.7.0"]
[tool.uv.workspace]
members = ["foo"]
"###
);
});
// Run `uv lock` in the workspace.
uv_snapshot!(context.filters(), context.lock(), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: `uv lock` is experimental and may change without warning
Resolved 5 packages in [TIME]
"###);
Ok(())
}