From 564975a77ae16fe75b9ed67bff230749b685394b Mon Sep 17 00:00:00 2001 From: Chris Pryer Date: Thu, 16 Nov 2023 18:53:11 -0500 Subject: [PATCH] Add `huak-pyproject-toml` --- Cargo.lock | 9 + crates/huak-package-manager/src/ops/add.rs | 2 +- crates/huak-pyproject-toml/.gitignore | 1 + crates/huak-pyproject-toml/Cargo.toml | 17 + crates/huak-pyproject-toml/src/error.rs | 12 + crates/huak-pyproject-toml/src/lib.rs | 658 +++++++++++++++++++++ 6 files changed, 698 insertions(+), 1 deletion(-) create mode 100644 crates/huak-pyproject-toml/.gitignore create mode 100644 crates/huak-pyproject-toml/Cargo.toml create mode 100644 crates/huak-pyproject-toml/src/error.rs create mode 100644 crates/huak-pyproject-toml/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index caf727c0..690fe03e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -601,6 +601,15 @@ dependencies = [ "toml_edit 0.21.0", ] +[[package]] +name = "huak-pyproject-toml" +version = "0.0.0" +dependencies = [ + "tempfile", + "thiserror", + "toml_edit 0.21.0", +] + [[package]] name = "huak-python-manager" version = "0.0.0" diff --git a/crates/huak-package-manager/src/ops/add.rs b/crates/huak-package-manager/src/ops/add.rs index c3230013..a17800a6 100644 --- a/crates/huak-package-manager/src/ops/add.rs +++ b/crates/huak-package-manager/src/ops/add.rs @@ -17,7 +17,7 @@ pub fn add_project_dependencies( let mut metadata = workspace.current_local_metadata()?; // Collect all dependencies that need to be added to the metadata file. - let mut deps: Vec = dependency_iter(dependencies) + let mut deps = dependency_iter(dependencies) .filter(|dep| !metadata.metadata().contains_dependency(dep)) .collect::>(); diff --git a/crates/huak-pyproject-toml/.gitignore b/crates/huak-pyproject-toml/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/crates/huak-pyproject-toml/.gitignore @@ -0,0 +1 @@ +/target diff --git a/crates/huak-pyproject-toml/Cargo.toml b/crates/huak-pyproject-toml/Cargo.toml new file mode 100644 index 00000000..f6a8091d --- /dev/null +++ b/crates/huak-pyproject-toml/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "huak-pyproject-toml" +version = "0.0.0" +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +thiserror.workspace = true +toml_edit.workspace = true + +[lints] +workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/huak-pyproject-toml/src/error.rs b/crates/huak-pyproject-toml/src/error.rs new file mode 100644 index 00000000..e2dde6cc --- /dev/null +++ b/crates/huak-pyproject-toml/src/error.rs @@ -0,0 +1,12 @@ +use thiserror::Error as ThisError; + +#[allow(clippy::enum_variant_names)] +#[derive(ThisError, Debug)] +pub enum Error { + #[error("{0}")] + IOError(#[from] std::io::Error), + #[error("{0}")] + TOMLEditError(#[from] toml_edit::TomlError), + #[error("a problem with utf-8 parsing occurred: {0}")] + Utf8Error(#[from] std::str::Utf8Error), +} diff --git a/crates/huak-pyproject-toml/src/lib.rs b/crates/huak-pyproject-toml/src/lib.rs new file mode 100644 index 00000000..32fdd317 --- /dev/null +++ b/crates/huak-pyproject-toml/src/lib.rs @@ -0,0 +1,658 @@ +//! ## huak-pyproject-toml +//! +//! Improve pyproject.toml management and robustness by implementing Huak's pyproject.toml library. +//! +//! Projects have manifest files named pyproject.toml (as specified in [PEP 517](https://peps.python.org/pep-0517/)). The data can consist of project metadata as well as tooling configuration. Here's Huak's pyproject.toml +//! +//! ```toml +//! [project] +//! name = "huak" +//! version = "0.0.20a1" +//! description = "A Python package manager written in Rust and inspired by Cargo." +//! authors = [ +//! {email = "cnpryer@gmail.com"}, +//! {name = "Chris Pryer"} +//! ] +//! readme = "README.md" +//! license = {text = "MIT"} +//! requires-python = ">=3.7" +//! classifiers = [ +//! "Programming Language :: Rust", +//! ] +//! +//! [project.urls] +//! issues = "https://github.com/cnpryer/huak/issues" +//! documentation = "https://github.com/cnpryer/huak" +//! homepage = "https://github.com/cnpryer/huak" +//! repository = "https://github.com/cnpryer/huak" +//! +//! [tool.maturin] +//! bindings = "bin" +//! manifest-path = "crates/huak-cli/Cargo.toml" +//! module-name = "huak" +//! python-source = "python" +//! strip = true +//! +//! [build-system] +//! requires = ["maturin>=0.14,<0.15"] +//! build-backend = "maturin" +//! +//! [tool.huak] +//! toolchain = "default" +//! ``` +//! +//! This manifest identifies the workspace for the Huak project. It contains metadata about the project, it's authors, build configuration, and config for other tools like maturin. At the bottom is the `[tool.huak]` table (see [PEP 518](https://peps.python.org/pep-0518/#tool-table)). +//! +//! ### `[tool.huak]` +//! +//! Huak's pyproject.toml implementation needs to expect a tool table, especially Huak's tool table. See: +//! +//! - #833 +//! - #814 +//! - #815 +//! +//! Example: +//! ```toml +//! [tool.huak] +//! toolchain = "3.11.6" +//! repositories = { package = "url to repo" } # TODO +//! +//! [tool.huak.run] # TODO: Compare with new project.run table. +//! hello-world = "python -c 'print('hello, world.')'" +//! +//! [tool.huak.workspace] +//! members = ["projects/*"] +//! ``` + +use error::Error; +use std::{fmt::Display, path::Path, str::FromStr}; +use toml_edit::{Array, Document, Formatted, InlineTable, Item, Table, Value}; +mod error; + +/// Huak's `PyProjectToml` implementation. +/// +/// - Core `PyProjectToml` +/// - Tool table +/// - Huak's table +pub struct PyProjectToml { + doc: Document, +} + +impl Default for PyProjectToml { + fn default() -> Self { + Self::new() + } +} + +impl PyProjectToml { + #[must_use] + pub fn new() -> Self { + Self { + doc: Document::new(), + } + } + + /// Read `PyProjectToml` from a toml file. + pub fn read_toml>(path: T) -> Result { + read_pyproject_toml(path) + } + + pub fn formatted(&mut self) -> &mut Self { + format_pyproject_toml(self); + self + } + + /// Write the `PyProjectToml` to a toml file. + pub fn write_toml>(&self, path: T) -> Result<(), Error> { + write_pyproject_toml(self, path) + } + + #[must_use] + pub fn get(&self, key: &str) -> Option<&Item> { + self.doc.get(key) + } + + pub fn get_mut(&mut self, key: &str) -> Option<&mut Item> { + self.doc.get_mut(key) + } + + // TODO(cnpryer): Tablelike or section(?) + #[must_use] + pub fn project_table(&self) -> Option<&Table> { + self.get("project").and_then(Item::as_table) + } + + pub fn project_table_mut(&mut self) -> Option<&mut Table> { + self.get_mut("project").and_then(Item::as_table_mut) + } + + // TODO(cnpryer): Tablelike or section(?) + #[must_use] + pub fn tool_table(&self) -> Option<&Table> { + self.get("tool").and_then(Item::as_table) + } + + // TODO(cnpryer): Tablelike or section(?) + pub fn tool_table_mut(&mut self) -> Option<&mut Table> { + self.get_mut("tool").and_then(Item::as_table_mut) + } + + #[must_use] + pub fn project_name(&self) -> Option<&Value> { + self.project_table() + .and_then(|it| it.get("name")) + .and_then(Item::as_value) + } + + pub fn set_project_name(&mut self, name: &str) -> &mut Self { + self.doc["project"]["name"] = Item::Value(Value::String(Formatted::new(name.to_string()))); + self + } + + #[must_use] + pub fn project_version(&self) -> Option<&Value> { + self.project_table() + .and_then(|it| it.get("version")) + .and_then(Item::as_value) + } + + pub fn set_project_version(&mut self, version: &str) -> &mut Self { + self.doc["project"]["version"] = + Item::Value(Value::String(Formatted::new(version.to_string()))); + self + } + + #[must_use] + pub fn project_description(&self) -> Option<&Value> { + self.project_table() + .and_then(|it| it.get("description")) + .and_then(Item::as_value) + } + + pub fn set_project_description(&mut self, description: &str) -> &mut Self { + self.doc["project"]["version"] = + Item::Value(Value::String(Formatted::new(description.to_string()))); + self + } + + #[must_use] + pub fn project_dependencies(&self) -> Option<&Array> { + self.project_table() + .and_then(|it| it.get("dependencies")) + .and_then(Item::as_array) + } + + pub fn project_dependencies_mut(&mut self) -> Option<&mut Array> { + self.project_table_mut() + .and_then(|it| it.get_mut("dependencies")) + .and_then(Item::as_array_mut) + } + + pub fn add_project_dependency(&mut self, dependency: &str) -> &mut Self { + let item = &mut self.doc["project"]["dependencies"]; + + add_array_str(item, dependency); + + self + } + + pub fn remove_project_dependency(&mut self, dependency: &str) -> &mut Self { + let item = &mut self.doc["project"]["dependencies"]; + + remove_array_dependency(item, dependency); + + self + } + + #[must_use] + pub fn project_optional_dependencies(&self) -> Option<&InlineTable> { + self.project_table() + .and_then(|it| it.get("optional-dependencies")) + .and_then(Item::as_inline_table) + } + + pub fn project_optional_dependencies_mut(&mut self) -> Option<&mut InlineTable> { + self.project_table_mut() + .and_then(|it| it.get_mut("optional-dependencies")) + .and_then(Item::as_inline_table_mut) + } + + pub fn add_project_optional_dependency(&mut self, dependency: &str, group: &str) -> &mut Self { + let item = &mut self.doc["project"]["optional-dependencies"][group]; + + add_array_str(item, dependency); + + self + } + + pub fn remove_project_optional_dependency( + &mut self, + dependency: &str, + group: &str, + ) -> &mut Self { + let item = &mut self.doc["project"]["optional-dependencies"][group]; + + remove_array_dependency(item, dependency); + + self + } +} + +/// Read and return a `PyProjectToml` from a pyproject.toml file. +fn read_pyproject_toml>(path: T) -> Result { + PyProjectToml::from_str(&std::fs::read_to_string(path)?) +} + +fn format_pyproject_toml(pyproject_toml: &mut PyProjectToml) -> &mut PyProjectToml { + // Format the dependencies + pyproject_toml.project_dependencies_mut().map(format_array); + pyproject_toml + .project_optional_dependencies_mut() + .map(format_inline_table); + + pyproject_toml +} + +fn format_inline_table(table: &mut InlineTable) { + todo!() +} + +fn format_array(array: &mut Array) { + todo!() +} + +/// Save the `PyProjectToml` to a filepath. +fn write_pyproject_toml>(toml: &PyProjectToml, path: T) -> Result<(), Error> { + Ok(std::fs::write(path, toml.to_string())?) +} + +fn add_array_str(item: &mut Item, s: &str) { + if let Some(array) = item.as_array_mut() { + array.push(s); + } +} + +fn remove_array_dependency(item: &mut Item, dependency: &str) { + if let Some(array) = item.as_array_mut() { + array.retain(|it| !value_is_dependency(it, dependency)); + } +} + +fn value_is_dependency(_value: &Value, _dependency: &str) -> bool { + todo!() +} + +impl FromStr for PyProjectToml { + type Err = Error; + + fn from_str(s: &str) -> Result { + Ok(PyProjectToml { + doc: Document::from_str(s)?, + }) + } +} + +impl AsMut for PyProjectToml { + fn as_mut(&mut self) -> &mut PyProjectToml { + self + } +} + +impl Display for PyProjectToml { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.doc) + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + use tempfile::TempDir; + use toml_edit::{Formatted, Value}; + + use super::*; + + #[test] + fn test_get_core() { + let pyproject_toml = PyProjectToml::from_str(mock_pyproject_toml_content()).unwrap(); + let name = pyproject_toml + .project_name() + .map(ToString::to_string) + .unwrap(); + let version = pyproject_toml + .project_version() + .map(ToString::to_string) + .unwrap(); + let dependencies = pyproject_toml.project_dependencies().map(|it| { + it.into_iter() + .map(ToString::to_string) + .collect::>() + }); + let optional_dependencies = pyproject_toml + .project_optional_dependencies() + .and_then(|it| it.get("dev")) + .and_then(|it| it.as_array()) + .map(|it| { + it.into_iter() + .map(ToString::to_string) + .collect::>() + }); + + assert_eq!(name, " \"huak\"".to_string()); + assert_eq!(version, " \"0.0.20a1\"".to_string()); + assert!(dependencies.is_none()); + assert!(optional_dependencies.is_none()); + } + + #[test] + fn test_get_tool() { + let pyproject_toml = PyProjectToml::from_str(mock_pyproject_toml_content()).unwrap(); + let tool = pyproject_toml.get("tool"); + let maturin = tool.as_ref().and_then(|it| it.get("maturin")); + let maturin_table = maturin + .and_then(Item::as_table) + .map(ToString::to_string) + .unwrap(); + + assert_eq!( + maturin_table, + r#"bindings = "bin" +manifest-path = "crates/huak-cli/Cargo.toml" +module-name = "huak" +python-source = "python" +strip = true +"# + .to_string() + ); + } + + #[test] + fn test_get_huak() { + let pyproject_toml = PyProjectToml::from_str(mock_pyproject_toml_content()).unwrap(); + let toolchain = pyproject_toml + .get("tool") + .and_then(|it| it.get("huak")) + .and_then(Item::as_table) + .and_then(|it| it.get("toolchain")) + .map(ToString::to_string) + .unwrap(); + + assert_eq!(toolchain, " \"default\"".to_string()); + } + + #[test] + fn test_read_file() { + let dir = TempDir::new().unwrap(); + let dir = dir.path(); + let workspace = dir.join("workspace"); + + std::fs::create_dir_all(&workspace).unwrap(); + + std::fs::write( + workspace.join("pyproject.toml"), + mock_pyproject_toml_content(), + ) + .unwrap(); + + let pyproject_toml = PyProjectToml::read_toml(workspace.join("pyproject.toml")).unwrap(); + + assert_eq!(&pyproject_toml.to_string(), mock_pyproject_toml_content()); + } + + #[test] + fn test_write_file() { + let dir = TempDir::new().unwrap(); + let dir = dir.path(); + let workspace = dir.join("workspace"); + + std::fs::create_dir_all(&workspace).unwrap(); + + let content = mock_pyproject_toml_content(); + + let pyproject_toml = PyProjectToml::from_str(content).unwrap(); + pyproject_toml + .write_toml(workspace.join("pyproject.toml")) + .unwrap(); + + let mut pyproject_toml = + PyProjectToml::read_toml(workspace.join("pyproject.toml")).unwrap(); + + assert_eq!(&pyproject_toml.formatted().to_string(), content); + } + + #[test] + fn test_update_core_section() { + let dir = TempDir::new().unwrap(); + let dir = dir.path(); + let workspace = dir.join("workspace"); + + std::fs::create_dir_all(&workspace).unwrap(); + + let content = mock_pyproject_toml_content(); + + let mut pyproject_toml = PyProjectToml::from_str(content).unwrap(); + + pyproject_toml + .set_project_name("new name") + .add_project_dependency("test") + .formatted() + .write_toml(workspace.join("pyproject.toml")) + .unwrap(); + let pyproject_toml = PyProjectToml::read_toml(workspace.join("pyproject.toml")).unwrap(); + + assert_eq!( + pyproject_toml + .get("project") + .and_then(|it| it.get("name")) + .and_then(Item::as_value) + .map(ToString::to_string) + .unwrap(), + " \"new name\"".to_string() + ); + + // assert_eq!( + // pyproject_toml + // .project_dependencies() + // .map(|it| { + // it.into_iter() + // .map(ToString::to_string) + // .collect::>() + // }) + // .unwrap(), + // vec!["\"test\""] + // ); + + assert_eq!( + pyproject_toml.to_string(), + r#"[build-system] +requires = ["maturin>=0.14,<0.15"] +build-backend = "maturin" + +[project] +name = "new name" +version = "0.0.20a1" +description = "A Python package manager written in Rust and inspired by Cargo." +authors = [ + {email = "cnpryer@gmail.com"}, + {name = "Chris Pryer"} +] +readme = "README.md" +license = {text = "MIT"} +requires-python = ">=3.7" +classifiers = [ + "Programming Language :: Rust", +] +dependencies = ["test"] + +[project.urls] +issues = "https://github.com/cnpryer/huak/issues" +documentation = "https://github.com/cnpryer/huak" +homepage = "https://github.com/cnpryer/huak" +repository = "https://github.com/cnpryer/huak" + +[tool.maturin] +bindings = "bin" +manifest-path = "crates/huak-cli/Cargo.toml" +module-name = "huak" +python-source = "python" +strip = true + +[tool.huak] +toolchain = "default" +"# + ); + } + + #[test] + fn test_update_tool_section() { + let dir = TempDir::new().unwrap(); + let dir = dir.path(); + let workspace = dir.join("workspace"); + + std::fs::create_dir_all(&workspace).unwrap(); + + let content = mock_pyproject_toml_content(); + + let mut pyproject_toml = PyProjectToml::from_str(content).unwrap(); + let tool = pyproject_toml.tool_table_mut().unwrap(); + let maturin = tool.get_mut("maturin").unwrap().as_table_mut().unwrap(); + maturin.insert( + "module-name", + Item::Value(Value::String(Formatted::new("new name".to_string()))), + ); + + pyproject_toml + .formatted() + .write_toml(workspace.join("pyproject.toml")) + .unwrap(); + let pyproject_toml = PyProjectToml::read_toml(workspace.join("pyproject.toml")).unwrap(); + + assert_eq!( + pyproject_toml + .get("tool") + .and_then(|tool| tool.get("maturin")) + .and_then(|maturin| maturin.get("module-name")) + .and_then(|name| name.as_value()) + .map(ToString::to_string) + .unwrap(), + " \"new name\"".to_string() + ); + + assert_eq!( + pyproject_toml.to_string(), + r#"[build-system] +requires = ["maturin>=0.14,<0.15"] +build-backend = "maturin" + +[project] +name = "huak" +version = "0.0.20a1" +description = "A Python package manager written in Rust and inspired by Cargo." +authors = [ + {email = "cnpryer@gmail.com"}, + {name = "Chris Pryer"} +] +readme = "README.md" +license = {text = "MIT"} +requires-python = ">=3.7" +classifiers = [ + "Programming Language :: Rust", +] +dependencies = [ + "test", +] # Trailing comment + +[project.urls] +issues = "https://github.com/cnpryer/huak/issues" +documentation = "https://github.com/cnpryer/huak" +homepage = "https://github.com/cnpryer/huak" +repository = "https://github.com/cnpryer/huak" + +[tool.maturin] +bindings = "bin" +manifest-path = "crates/huak-cli/Cargo.toml" +module-name = "new name" +python-source = "python" +strip = true + +[tool.huak] +toolchain = "default" +"# + ); + } + + #[test] + fn test_update_huak_section() { + let dir = TempDir::new().unwrap(); + let dir = dir.path(); + let workspace = dir.join("workspace"); + + std::fs::create_dir_all(&workspace).unwrap(); + + let content = mock_pyproject_toml_content(); + + let mut pyproject_toml = PyProjectToml::from_str(content).unwrap(); + let tool = pyproject_toml.tool_table_mut().unwrap(); + let huak = tool.get_mut("huak").unwrap().as_table_mut().unwrap(); + huak.insert( + "toolchain", + Item::Value(Value::String(Formatted::new("3.11".to_string()))), + ); + + pyproject_toml + .formatted() + .write_toml(workspace.join("pyproject.toml")) + .unwrap(); + let pyproject_toml = PyProjectToml::read_toml(workspace.join("pyproject.toml")).unwrap(); + + assert_eq!( + pyproject_toml + .get("tool") + .and_then(|it| it.get("huak")) + .and_then(|it| it.get("toolchain")) + .and_then(Item::as_value) + .map(ToString::to_string) + .unwrap(), + " \"3.11\"".to_string() + ); + } + + fn mock_pyproject_toml_content() -> &'static str { + r#"[build-system] +requires = ["maturin>=0.14,<0.15"] +build-backend = "maturin" + +[project] +name = "huak" +version = "0.0.20a1" +description = "A Python package manager written in Rust and inspired by Cargo." +authors = [ + {email = "cnpryer@gmail.com"}, + {name = "Chris Pryer"} +] +readme = "README.md" +license = {text = "MIT"} +requires-python = ">=3.7" +classifiers = [ + "Programming Language :: Rust", +] +dependencies = ["test"] # Trailing comment + +[project.urls] +issues = "https://github.com/cnpryer/huak/issues" +documentation = "https://github.com/cnpryer/huak" +homepage = "https://github.com/cnpryer/huak" +repository = "https://github.com/cnpryer/huak" + +[tool.maturin] +bindings = "bin" +manifest-path = "crates/huak-cli/Cargo.toml" +module-name = "huak" +python-source = "python" +strip = true + +[tool.huak] +toolchain = "default" +"# + } +}