mirror of
https://github.com/astral-sh/uv.git
synced 2025-07-07 21:35:00 +00:00
Add a puffin add
command (#117)
This needs far better error handling and user-facing feedback, but it does the basic operation (and includes discovery of the `pyproject.toml` file, etc.).
This commit is contained in:
parent
339553e228
commit
4c87a1d42c
11 changed files with 292 additions and 0 deletions
15
Cargo.lock
generated
15
Cargo.lock
generated
|
@ -1832,6 +1832,7 @@ dependencies = [
|
||||||
"puffin-interpreter",
|
"puffin-interpreter",
|
||||||
"puffin-package",
|
"puffin-package",
|
||||||
"puffin-resolver",
|
"puffin-resolver",
|
||||||
|
"puffin-workspace",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
@ -1942,6 +1943,20 @@ dependencies = [
|
||||||
"wheel-filename",
|
"wheel-filename",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "puffin-workspace"
|
||||||
|
version = "0.0.1"
|
||||||
|
dependencies = [
|
||||||
|
"fs-err",
|
||||||
|
"pep440_rs 0.3.12",
|
||||||
|
"pep508_rs",
|
||||||
|
"puffin-package",
|
||||||
|
"pyproject-toml",
|
||||||
|
"serde",
|
||||||
|
"thiserror",
|
||||||
|
"toml_edit 0.20.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyo3"
|
name = "pyo3"
|
||||||
version = "0.19.2"
|
version = "0.19.2"
|
||||||
|
|
|
@ -58,6 +58,7 @@ thiserror = { version = "1.0.49" }
|
||||||
tokio = { version = "1.16.1", features = ["rt-multi-thread"] }
|
tokio = { version = "1.16.1", features = ["rt-multi-thread"] }
|
||||||
tokio-util = { version = "0.7.9", features = ["compat"] }
|
tokio-util = { version = "0.7.9", features = ["compat"] }
|
||||||
toml = { version = "0.8.2" }
|
toml = { version = "0.8.2" }
|
||||||
|
toml_edit = { version = "0.20.2" }
|
||||||
tracing = { version = "0.1.37" }
|
tracing = { version = "0.1.37" }
|
||||||
tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }
|
||||||
tracing-tree = { version = "0.2.5" }
|
tracing-tree = { version = "0.2.5" }
|
||||||
|
|
|
@ -20,6 +20,7 @@ puffin-installer = { path = "../puffin-installer" }
|
||||||
puffin-interpreter = { path = "../puffin-interpreter" }
|
puffin-interpreter = { path = "../puffin-interpreter" }
|
||||||
puffin-package = { path = "../puffin-package" }
|
puffin-package = { path = "../puffin-package" }
|
||||||
puffin-resolver = { path = "../puffin-resolver" }
|
puffin-resolver = { path = "../puffin-resolver" }
|
||||||
|
puffin-workspace = { path = "../puffin-workspace" }
|
||||||
|
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
bitflags = { workspace = true }
|
bitflags = { workspace = true }
|
||||||
|
|
29
crates/puffin-cli/src/commands/add.rs
Normal file
29
crates/puffin-cli/src/commands/add.rs
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use crate::commands::ExitStatus;
|
||||||
|
use crate::printer::Printer;
|
||||||
|
|
||||||
|
/// Add a dependency to the workspace.
|
||||||
|
pub(crate) fn add(name: &str, _printer: Printer) -> Result<ExitStatus> {
|
||||||
|
// Locate the workspace.
|
||||||
|
let Some(workspace_root) = puffin_workspace::find_pyproject_toml(std::env::current_dir()?)
|
||||||
|
else {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Could not find a `pyproject.toml` file in the current directory or any of its parents"
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
info!("Found workspace at: {}", workspace_root.display());
|
||||||
|
|
||||||
|
// Parse the manifest.
|
||||||
|
let mut manifest = puffin_workspace::Workspace::try_from(workspace_root.as_path())?;
|
||||||
|
|
||||||
|
// Add the dependency.
|
||||||
|
manifest.add_dependency(name)?;
|
||||||
|
|
||||||
|
// Write the manifest back to disk.
|
||||||
|
manifest.save(&workspace_root)?;
|
||||||
|
|
||||||
|
Ok(ExitStatus::Success)
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
pub(crate) use add::add;
|
||||||
pub(crate) use clean::clean;
|
pub(crate) use clean::clean;
|
||||||
pub(crate) use compile::compile;
|
pub(crate) use compile::compile;
|
||||||
pub(crate) use freeze::freeze;
|
pub(crate) use freeze::freeze;
|
||||||
|
@ -8,6 +9,7 @@ pub(crate) use sync::{sync, SyncFlags};
|
||||||
pub(crate) use uninstall::uninstall;
|
pub(crate) use uninstall::uninstall;
|
||||||
pub(crate) use venv::venv;
|
pub(crate) use venv::venv;
|
||||||
|
|
||||||
|
mod add;
|
||||||
mod clean;
|
mod clean;
|
||||||
mod compile;
|
mod compile;
|
||||||
mod freeze;
|
mod freeze;
|
||||||
|
|
|
@ -45,6 +45,8 @@ enum Commands {
|
||||||
Uninstall(UninstallArgs),
|
Uninstall(UninstallArgs),
|
||||||
/// Create a virtual environment.
|
/// Create a virtual environment.
|
||||||
Venv(VenvArgs),
|
Venv(VenvArgs),
|
||||||
|
/// Add a dependency to the workspace.
|
||||||
|
Add(AddArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
|
@ -83,6 +85,12 @@ struct VenvArgs {
|
||||||
name: PathBuf,
|
name: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
struct AddArgs {
|
||||||
|
/// The name of the package to add.
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> ExitCode {
|
async fn main() -> ExitCode {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
@ -153,6 +161,7 @@ async fn main() -> ExitCode {
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
Commands::Venv(args) => commands::venv(&args.name, args.python.as_deref(), printer).await,
|
Commands::Venv(args) => commands::venv(&args.name, args.python.as_deref(), printer).await,
|
||||||
|
Commands::Add(args) => commands::add(&args.name, printer),
|
||||||
};
|
};
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
|
|
21
crates/puffin-workspace/Cargo.toml
Normal file
21
crates/puffin-workspace/Cargo.toml
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
[package]
|
||||||
|
name = "puffin-workspace"
|
||||||
|
version = "0.0.1"
|
||||||
|
edition = { workspace = true }
|
||||||
|
rust-version = { workspace = true }
|
||||||
|
homepage = { workspace = true }
|
||||||
|
documentation = { workspace = true }
|
||||||
|
repository = { workspace = true }
|
||||||
|
authors = { workspace = true }
|
||||||
|
license = { workspace = true }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
pep440_rs = { path = "../pep440-rs" }
|
||||||
|
pep508_rs = { path = "../pep508-rs" }
|
||||||
|
puffin-package = { path = "../puffin-package" }
|
||||||
|
|
||||||
|
fs-err = { workspace = true }
|
||||||
|
pyproject-toml = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
toml_edit = { workspace = true, features = ["serde"] }
|
17
crates/puffin-workspace/src/error.rs
Normal file
17
crates/puffin-workspace/src/error.rs
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
use std::io;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum WorkspaceError {
|
||||||
|
#[error(transparent)]
|
||||||
|
IO(#[from] io::Error),
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
InvalidToml(#[from] toml_edit::TomlError),
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
InvalidPyproject(#[from] toml_edit::de::Error),
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
InvalidRequirement(#[from] pep508_rs::Pep508Error),
|
||||||
|
}
|
19
crates/puffin-workspace/src/lib.rs
Normal file
19
crates/puffin-workspace/src/lib.rs
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
pub use error::WorkspaceError;
|
||||||
|
pub use workspace::Workspace;
|
||||||
|
|
||||||
|
mod error;
|
||||||
|
mod toml;
|
||||||
|
mod workspace;
|
||||||
|
|
||||||
|
/// Find the closest `pyproject.toml` file to the given path.
|
||||||
|
pub fn find_pyproject_toml(path: impl AsRef<Path>) -> Option<PathBuf> {
|
||||||
|
for directory in path.as_ref().ancestors() {
|
||||||
|
let pyproject_toml = directory.join("pyproject.toml");
|
||||||
|
if pyproject_toml.is_file() {
|
||||||
|
return Some(pyproject_toml);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
41
crates/puffin-workspace/src/toml.rs
Normal file
41
crates/puffin-workspace/src/toml.rs
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
/// Reformat a TOML array to use multiline format.
|
||||||
|
pub(crate) fn format_multiline_array(dependencies: &mut toml_edit::Array) {
|
||||||
|
for item in dependencies.iter_mut() {
|
||||||
|
let decor = item.decor_mut();
|
||||||
|
let mut prefix = String::new();
|
||||||
|
for comment in find_comments(decor.prefix()).chain(find_comments(decor.suffix())) {
|
||||||
|
prefix.push_str("\n ");
|
||||||
|
prefix.push_str(comment);
|
||||||
|
}
|
||||||
|
prefix.push_str("\n ");
|
||||||
|
decor.set_prefix(prefix);
|
||||||
|
decor.set_suffix("");
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies.set_trailing(&{
|
||||||
|
let mut comments = find_comments(Some(dependencies.trailing())).peekable();
|
||||||
|
let mut value = String::new();
|
||||||
|
if comments.peek().is_some() {
|
||||||
|
for comment in comments {
|
||||||
|
value.push_str("\n ");
|
||||||
|
value.push_str(comment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value.push('\n');
|
||||||
|
value
|
||||||
|
});
|
||||||
|
|
||||||
|
dependencies.set_trailing_comma(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return an iterator over the comments in a raw string.
|
||||||
|
fn find_comments(raw_string: Option<&toml_edit::RawString>) -> impl Iterator<Item = &str> {
|
||||||
|
raw_string
|
||||||
|
.and_then(toml_edit::RawString::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let line = line.trim();
|
||||||
|
line.starts_with('#').then_some(line)
|
||||||
|
})
|
||||||
|
}
|
137
crates/puffin-workspace/src/workspace.rs
Normal file
137
crates/puffin-workspace/src/workspace.rs
Normal file
|
@ -0,0 +1,137 @@
|
||||||
|
use std::io;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use fs_err as fs;
|
||||||
|
use pyproject_toml::{BuildSystem, Project};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use toml_edit::Document;
|
||||||
|
|
||||||
|
use pep508_rs::Requirement;
|
||||||
|
use puffin_package::package_name::PackageName;
|
||||||
|
|
||||||
|
use crate::toml::format_multiline_array;
|
||||||
|
use crate::WorkspaceError;
|
||||||
|
|
||||||
|
/// Unlike [`pyproject_toml::PyProjectToml`], in our case `build_system` is also optional
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
struct PyProjectToml {
|
||||||
|
/// Build-related data
|
||||||
|
build_system: Option<BuildSystem>,
|
||||||
|
|
||||||
|
/// Project metadata
|
||||||
|
project: Option<Project>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Workspace {
|
||||||
|
/// The parsed `pyproject.toml`.
|
||||||
|
#[allow(unused)]
|
||||||
|
pyproject_toml: PyProjectToml,
|
||||||
|
|
||||||
|
/// The raw document.
|
||||||
|
document: Document,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Workspace {
|
||||||
|
/// Add a dependency to the workspace.
|
||||||
|
pub fn add_dependency(&mut self, dependency: &str) -> Result<(), WorkspaceError> {
|
||||||
|
let requirement = Requirement::from_str(dependency)?;
|
||||||
|
|
||||||
|
let Some(project) = self
|
||||||
|
.document
|
||||||
|
.get_mut("project")
|
||||||
|
.map(|project| project.as_table_mut().unwrap())
|
||||||
|
else {
|
||||||
|
// No `project` table.
|
||||||
|
let mut dependencies = toml_edit::Array::new();
|
||||||
|
dependencies.push(dependency);
|
||||||
|
format_multiline_array(&mut dependencies);
|
||||||
|
|
||||||
|
let mut project = toml_edit::Table::new();
|
||||||
|
project.insert(
|
||||||
|
"dependencies",
|
||||||
|
toml_edit::Item::Value(toml_edit::Value::Array(dependencies)),
|
||||||
|
);
|
||||||
|
|
||||||
|
self.document
|
||||||
|
.insert("project", toml_edit::Item::Table(project));
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(dependencies) = project
|
||||||
|
.get_mut("dependencies")
|
||||||
|
.map(|dependencies| dependencies.as_array_mut().unwrap())
|
||||||
|
else {
|
||||||
|
// No `dependencies` array.
|
||||||
|
let mut dependencies = toml_edit::Array::new();
|
||||||
|
dependencies.push(dependency);
|
||||||
|
format_multiline_array(&mut dependencies);
|
||||||
|
|
||||||
|
project.insert(
|
||||||
|
"dependencies",
|
||||||
|
toml_edit::Item::Value(toml_edit::Value::Array(dependencies)),
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO(charlie): Awkward `drop` pattern required to work around destructors, apparently.
|
||||||
|
let mut iter = dependencies.iter();
|
||||||
|
let index = iter.position(|item| {
|
||||||
|
let Some(item) = item.as_str() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(existing) = Requirement::from_str(item) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
PackageName::normalize(&requirement.name) == PackageName::normalize(existing.name)
|
||||||
|
});
|
||||||
|
drop(iter);
|
||||||
|
|
||||||
|
if let Some(index) = index {
|
||||||
|
dependencies.replace(index, dependency);
|
||||||
|
} else {
|
||||||
|
dependencies.push(dependency);
|
||||||
|
}
|
||||||
|
|
||||||
|
format_multiline_array(dependencies);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save the workspace to disk.
|
||||||
|
pub fn save(&self, path: impl AsRef<Path>) -> Result<(), WorkspaceError> {
|
||||||
|
let file = fs::File::create(path.as_ref())?;
|
||||||
|
self.write(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the workspace to a writer.
|
||||||
|
fn write(&self, mut writer: impl io::Write) -> Result<(), WorkspaceError> {
|
||||||
|
writer.write_all(self.document.to_string().as_bytes())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<&Path> for Workspace {
|
||||||
|
type Error = WorkspaceError;
|
||||||
|
|
||||||
|
fn try_from(path: &Path) -> Result<Self, Self::Error> {
|
||||||
|
// Read the `pyproject.toml` from disk.
|
||||||
|
let contents = fs::read_to_string(path)?;
|
||||||
|
|
||||||
|
// Parse the `pyproject.toml` file.
|
||||||
|
let pyproject_toml = toml_edit::de::from_str::<PyProjectToml>(&contents)?;
|
||||||
|
|
||||||
|
// Parse the raw document.
|
||||||
|
let document = contents.parse::<Document>()?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
pyproject_toml,
|
||||||
|
document,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue