diff --git a/Cargo.lock b/Cargo.lock index caf727c0..8995c40f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -582,6 +582,7 @@ dependencies = [ "hex", "huak-dev", "huak-home", + "huak-pyproject-toml", "huak-python-manager", "huak-toolchain", "huak-workspace", @@ -601,6 +602,16 @@ dependencies = [ "toml_edit 0.21.0", ] +[[package]] +name = "huak-pyproject-toml" +version = "0.0.0" +dependencies = [ + "pep508_rs", + "tempfile", + "thiserror", + "toml_edit 0.21.0", +] + [[package]] name = "huak-python-manager" version = "0.0.0" diff --git a/crates/huak-package-manager/Cargo.toml b/crates/huak-package-manager/Cargo.toml index b77bddf1..2547baa5 100644 --- a/crates/huak-package-manager/Cargo.toml +++ b/crates/huak-package-manager/Cargo.toml @@ -34,6 +34,7 @@ huak-toolchain = { path = "../huak-toolchain" } hex.workspace = true sha2.workspace = true huak-workspace = { path = "../huak-workspace" } +huak-pyproject-toml = { path = "../huak-pyproject-toml" } [dev-dependencies] huak-dev = { path = "../huak-dev" } diff --git a/crates/huak-package-manager/src/error.rs b/crates/huak-package-manager/src/error.rs index 2116cdbb..7cff2198 100644 --- a/crates/huak-package-manager/src/error.rs +++ b/crates/huak-package-manager/src/error.rs @@ -53,6 +53,8 @@ pub enum Error { #[error("a project already exists")] ProjectFound, #[error("{0}")] + PyProjectTomlError(#[from] huak_pyproject_toml::Error), + #[error("{0}")] PythonManagerError(#[from] huak_python_manager::Error), #[error("a python module could not be found: {0}")] PythonModuleNotFound(String), diff --git a/crates/huak-package-manager/src/lib.rs b/crates/huak-package-manager/src/lib.rs index 6f70f1fe..0b2d583f 100644 --- a/crates/huak-package-manager/src/lib.rs +++ b/crates/huak-package-manager/src/lib.rs @@ -67,7 +67,7 @@ pub use fs::{copy_dir, last_path_component, CopyDirOptions}; pub use git::{default_python_gitignore, init as git_init}; pub use metadata::{ default_package_entrypoint_string, default_package_test_file_contents, - default_pyproject_toml_contents, LocalMetadata, PyProjectToml, + default_pyproject_toml_contents, LocalMetadata, }; pub use package::{importable_package_name, Package}; pub use python_environment::{ diff --git a/crates/huak-package-manager/src/metadata.rs b/crates/huak-package-manager/src/metadata.rs index c02953c2..ac99317a 100644 --- a/crates/huak-package-manager/src/metadata.rs +++ b/crates/huak-package-manager/src/metadata.rs @@ -1,23 +1,16 @@ -use std::{ffi::OsStr, fmt::Display, path::PathBuf, str::FromStr}; - -use indexmap::IndexMap; -use pep440_rs::Version; -use pep508_rs::Requirement; -use pyproject_toml::{BuildSystem, Project, PyProjectToml as ProjectToml}; -use serde::{Deserialize, Serialize}; -use toml::Table; - -use crate::{dependency::Dependency, Error, HuakResult}; +use crate::{Error, HuakResult}; +use huak_pyproject_toml::PyProjectToml; +use std::{ffi::OsStr, path::PathBuf, str::FromStr}; +use toml_edit::Document; const DEFAULT_METADATA_FILE_NAME: &str = "pyproject.toml"; /// A `LocalMetadata` struct used to manage local `Metadata` files such as /// the pyproject.toml (). -#[derive(Debug)] pub struct LocalMetadata { /// The core `Metadata`. /// See https://packaging.python.org/en/latest/specifications/core-metadata/. - metadata: Metadata, // TODO: https://github.com/cnpryer/huak/issues/574 + metadata: PyProjectToml, // TODO: https://github.com/cnpryer/huak/issues/574 /// The path to the `LocalMetadata` file. path: PathBuf, } @@ -42,14 +35,9 @@ impl LocalMetadata { /// Create a `LocalMetadata` template. pub fn template>(path: T) -> LocalMetadata { LocalMetadata { - metadata: Metadata { - build_system: BuildSystem { - requires: vec![Requirement::from_str("hatchling").unwrap()], - build_backend: Some(String::from("hatchling.build")), - backend_path: None, - }, - project: PyProjectToml::default().project.clone().unwrap(), - tool: None, + metadata: PyProjectToml { + doc: Document::from_str(&default_pyproject_toml_contents("project name")) + .expect("template pyproject.toml contents"), }, path: path.into(), } @@ -57,274 +45,33 @@ impl LocalMetadata { /// Get a reference to the core `Metadata`. #[must_use] - pub fn metadata(&self) -> &Metadata { + pub fn metadata(&self) -> &PyProjectToml { &self.metadata } /// Get a mutable reference to the core `Metadata`. - pub fn metadata_mut(&mut self) -> &mut Metadata { + pub fn metadata_mut(&mut self) -> &mut PyProjectToml { &mut self.metadata } /// Write the `LocalMetadata` file to its path. pub fn write_file(&self) -> HuakResult<()> { - let string = self.to_string_pretty()?; - Ok(std::fs::write(&self.path, string)?) - } - - /// Serialize the `Metadata` to a formatted string. - pub fn to_string_pretty(&self) -> HuakResult { - Ok(toml_edit::ser::to_string_pretty(&self.metadata)?) - } -} - -impl Display for LocalMetadata { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{:?}", self.metadata) + Ok(self.metadata.write_toml(&self.path)?) } } /// Create `LocalMetadata` from a pyproject.toml file. fn pyproject_toml_metadata>(path: T) -> HuakResult { let path = path.into(); - let pyproject_toml = PyProjectToml::new(&path)?; - let project = match pyproject_toml.project.as_ref() { - Some(it) => it, - None => { - return Err(Error::InternalError(format!( - "{} is missing a project table", - path.display() - ))) - } - } - .to_owned(); - let build_system = pyproject_toml.build_system.clone(); - let tool = pyproject_toml.tool; - - let metadata = Metadata { - build_system, - project, - tool, + let pyproject_toml = PyProjectToml::read_toml(&path)?; + let local_metadata = LocalMetadata { + metadata: pyproject_toml, + path, }; - let local_metadata = LocalMetadata { metadata, path }; Ok(local_metadata) } -/// The `Metadata` of a `Package`. -/// -/// See for more about core metadata. -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "kebab-case")] -pub struct Metadata { - /// The build system used for the `Package`. - build_system: BuildSystem, - /// The `Project` table. - project: Project, - /// The `Tool` table. - tool: Option, -} - -impl Metadata { - #[allow(dead_code)] - pub fn project(&self) -> &Project { - &self.project - } - - pub fn project_name(&self) -> &str { - &self.project.name - } - - pub fn set_project_name(&mut self, name: String) { - self.project.name = name; - } - - pub fn project_version(&self) -> Option<&Version> { - self.project.version.as_ref() - } - - pub fn dependencies(&self) -> Option<&[Requirement]> { - self.project.dependencies.as_deref() - } - - pub fn contains_dependency(&self, dependency: &Dependency) -> bool { - if let Some(deps) = self.dependencies() { - for d in deps { - if d.name == dependency.name() { - return true; - } - } - } - false - } - - pub fn contains_dependency_any(&self, dependency: &Dependency) -> bool { - if self.contains_dependency(dependency) { - return true; - } - - if let Some(deps) = self.optional_dependencies().as_ref() { - if deps.is_empty() { - return false; - } - for d in deps.values().flatten() { - if d.name == dependency.name() { - return true; - } - } - } - - false - } - - pub fn add_dependency(&mut self, dependency: &Dependency) { - self.project - .dependencies - .get_or_insert_with(Vec::new) - .push(dependency.requirement().to_owned()); - } - - pub fn optional_dependencies(&self) -> Option<&IndexMap>> { - self.project.optional_dependencies.as_ref() - } - - pub fn contains_optional_dependency(&self, dependency: &Dependency, group: &str) -> bool { - if let Some(deps) = self.optional_dependencies().as_ref() { - if let Some(g) = deps.get(group) { - if deps.is_empty() { - return false; - } - for d in g { - if d.name == dependency.name() { - return true; - } - } - } - } - - false - } - - pub fn optional_dependency_group(&self, group: &str) -> Option<&Vec> { - self.project - .optional_dependencies - .as_ref() - .and_then(|deps| deps.get(group)) - } - - pub fn add_optional_dependency(&mut self, dependency: &Dependency, group: &str) { - self.project - .optional_dependencies - .get_or_insert_with(IndexMap::new) - .entry(group.to_string()) - .or_default() - .push(dependency.requirement().to_owned()); - } - - pub fn remove_dependency(&mut self, dependency: &Dependency) { - self.project.dependencies.as_mut().and_then(|deps| { - deps.iter() - .position(|dep| dep.name == dependency.name()) - .map(|i| deps.remove(i)) - }); - } - - pub fn remove_optional_dependency(&mut self, dependency: &Dependency, group: &str) { - self.project - .optional_dependencies - .as_mut() - .and_then(|g| g.get_mut(group)) - .and_then(|deps| { - deps.iter() - .position(|dep| dep.name == dependency.name()) - .map(|i| deps.remove(i)) - }); - } - - pub fn add_script(&mut self, name: &str, entrypoint: &str) { - self.project - .scripts - .get_or_insert_with(IndexMap::new) - .entry(name.to_string()) - .or_insert(entrypoint.to_string()); - } - - pub fn tool(&self) -> Option<&Table> { - self.tool.as_ref() - } -} - -impl Default for Metadata { - fn default() -> Self { - // Initializing a `Package` from a `&str` would not include any additional - // `Metadata` besides the name. - let build_system = BuildSystem { - requires: vec![Requirement::from_str("hatchling").unwrap()], - build_backend: Some(String::from("hatchling.build")), - backend_path: None, - }; - - let project = Project::new(String::from("Default Project")); - - Metadata { - build_system, - project, - tool: None, - } - } -} - -impl PartialEq for Metadata { - fn eq(&self, other: &Self) -> bool { - self.project == other.project && self.tool == other.tool - } -} - -impl Eq for Metadata {} - -/// A pyproject.toml as specified in PEP 621 with tool table. -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "kebab-case")] -pub struct PyProjectToml { - #[serde(flatten)] - inner: ProjectToml, - tool: Option
, -} - -impl std::ops::Deref for PyProjectToml { - type Target = ProjectToml; - - fn deref(&self) -> &Self::Target { - &self.inner - } -} - -impl std::ops::DerefMut for PyProjectToml { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.inner - } -} - -impl PyProjectToml { - /// Initialize a `PyProjectToml` from its path. - pub fn new>(path: T) -> HuakResult { - let contents = std::fs::read_to_string(path.into())?; - let pyproject_toml: PyProjectToml = toml::from_str(&contents)?; - - Ok(pyproject_toml) - } -} - -impl Default for PyProjectToml { - fn default() -> Self { - Self { - inner: ProjectToml::new(&default_pyproject_toml_contents("")) - .expect("valid pyproject.toml contents"), - tool: None, - } - } -} - #[must_use] pub fn default_pyproject_toml_contents(name: &str) -> String { format!( @@ -370,14 +117,22 @@ mod tests { .join("pyproject.toml"); let local_metadata = LocalMetadata::new(path).unwrap(); - assert_eq!(local_metadata.metadata.project_name(), "mock_project"); assert_eq!( - *local_metadata.metadata.project_version().unwrap(), - Version::from_str("0.0.1").unwrap() + local_metadata.metadata.project_name().unwrap().to_string(), + "mock_project" ); - assert!(local_metadata.metadata.dependencies().is_some()); + assert_eq!( + *local_metadata + .metadata + .project_version() + .unwrap() + .to_string(), + "0.0.1".to_string() + ); + assert!(local_metadata.metadata.project_dependencies().is_some()); } + #[ignore = "unsupported"] #[test] fn toml_to_string_pretty() { let path = dev_resources_dir() @@ -386,7 +141,7 @@ mod tests { let local_metadata = LocalMetadata::new(path).unwrap(); assert_eq!( - local_metadata.to_string_pretty().unwrap(), + local_metadata.metadata.to_string(), r#"[build-system] requires = ["hatchling"] build-backend = "hatchling.build" @@ -395,7 +150,7 @@ build-backend = "hatchling.build" name = "mock_project" version = "0.0.1" description = "" -dependencies = ["click ==8.1.3"] +dependencies = ["click == 8.1.3"] [[project.authors]] name = "Chris Pryer" @@ -419,8 +174,8 @@ dev = [ let local_metadata = LocalMetadata::new(path).unwrap(); assert_eq!( - local_metadata.metadata.dependencies().unwrap(), - vec![Requirement::from_str("click==8.1.3").unwrap()] + local_metadata.metadata.project_dependencies().unwrap(), + vec!["click == 8.1.7".to_string()] ); } @@ -432,15 +187,13 @@ dev = [ let local_metadata = LocalMetadata::new(path).unwrap(); assert_eq!( - &**local_metadata + local_metadata .metadata - .optional_dependency_group("dev") + .project_optional_dependencies() + .unwrap() + .get("dev") .unwrap(), - vec![ - Requirement::from_str("pytest>=6").unwrap(), - Requirement::from_str("black==22.8.0").unwrap(), - Requirement::from_str("isort==5.12.0").unwrap() - ] + &vec!["pytest == 7.4.3".to_string(), "ruff".to_string(),] ); } @@ -450,16 +203,13 @@ dev = [ .join("mock-project") .join("pyproject.toml"); let mut local_metadata = LocalMetadata::new(path).unwrap(); - let dep = Dependency::from(Requirement { - name: "test".to_string(), - extras: None, - version_or_url: None, - marker: None, - }); - local_metadata.metadata.add_dependency(&dep); + local_metadata + .metadata + .add_project_dependency("test") + .formatted(); assert_eq!( - local_metadata.to_string_pretty().unwrap(), + local_metadata.metadata.to_string(), r#"[build-system] requires = ["hatchling"] build-backend = "hatchling.build" @@ -469,7 +219,7 @@ name = "mock_project" version = "0.0.1" description = "" dependencies = [ - "click ==8.1.3", + "click == 8.1.7", "test", ] @@ -479,9 +229,8 @@ email = "cnpryer@gmail.com" [project.optional-dependencies] dev = [ - "pytest >=6", - "black ==22.8.0", - "isort ==5.12.0", + "pytest == 7.4.3", + "ruff", ] "# ); @@ -496,12 +245,12 @@ dev = [ local_metadata .metadata - .add_optional_dependency(&Dependency::from_str("test1").unwrap(), "dev"); + .add_project_optional_dependency("test1", "dev"); local_metadata .metadata - .add_optional_dependency(&Dependency::from_str("test2").unwrap(), "new-group"); + .add_project_optional_dependency("test2", "new-group"); assert_eq!( - local_metadata.to_string_pretty().unwrap(), + local_metadata.metadata.formatted().to_string(), r#"[build-system] requires = ["hatchling"] build-backend = "hatchling.build" @@ -510,7 +259,9 @@ build-backend = "hatchling.build" name = "mock_project" version = "0.0.1" description = "" -dependencies = ["click ==8.1.3"] +dependencies = [ + "click == 8.1.7", +] [[project.authors]] name = "Chris Pryer" @@ -518,12 +269,13 @@ email = "cnpryer@gmail.com" [project.optional-dependencies] dev = [ - "pytest >=6", - "black ==22.8.0", - "isort ==5.12.0", + "pytest == 7.4.3", + "ruff", "test1", ] -new-group = ["test2"] +new-group = [ + "test2", +] "# ); } @@ -534,12 +286,10 @@ new-group = ["test2"] .join("mock-project") .join("pyproject.toml"); let mut local_metadata = LocalMetadata::new(path).unwrap(); - local_metadata - .metadata - .remove_dependency(&Dependency::from_str("click").unwrap()); + local_metadata.metadata.remove_project_dependency("click"); assert_eq!( - local_metadata.to_string_pretty().unwrap(), + local_metadata.metadata.formatted().to_string(), r#"[build-system] requires = ["hatchling"] build-backend = "hatchling.build" @@ -556,9 +306,8 @@ email = "cnpryer@gmail.com" [project.optional-dependencies] dev = [ - "pytest >=6", - "black ==22.8.0", - "isort ==5.12.0", + "pytest == 7.4.3", + "ruff", ] "# ); @@ -573,9 +322,9 @@ dev = [ local_metadata .metadata - .remove_optional_dependency(&Dependency::from_str("isort").unwrap(), "dev"); + .remove_project_optional_dependency("ruff", "dev"); assert_eq!( - local_metadata.to_string_pretty().unwrap(), + local_metadata.metadata.formatted().to_string(), r#"[build-system] requires = ["hatchling"] build-backend = "hatchling.build" @@ -584,7 +333,9 @@ build-backend = "hatchling.build" name = "mock_project" version = "0.0.1" description = "" -dependencies = ["click ==8.1.3"] +dependencies = [ + "click == 8.1.7", +] [[project.authors]] name = "Chris Pryer" @@ -592,8 +343,7 @@ email = "cnpryer@gmail.com" [project.optional-dependencies] dev = [ - "pytest >=6", - "black ==22.8.0", + "pytest == 7.4.3", ] "# ); diff --git a/crates/huak-package-manager/src/ops/add.rs b/crates/huak-package-manager/src/ops/add.rs index c3230013..5b201320 100644 --- a/crates/huak-package-manager/src/ops/add.rs +++ b/crates/huak-package-manager/src/ops/add.rs @@ -13,12 +13,11 @@ pub fn add_project_dependencies( options: &AddOptions, ) -> HuakResult<()> { let workspace = config.workspace(); - let package = workspace.current_package()?; 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) - .filter(|dep| !metadata.metadata().contains_dependency(dep)) + let mut deps = dependency_iter(dependencies) + .filter(|dep| !metadata.metadata().contains_project_dependency(dep.name())) .collect::>(); if deps.is_empty() { @@ -41,14 +40,15 @@ pub fn add_project_dependencies( } } - if !metadata.metadata().contains_dependency(dep) { - metadata.metadata_mut().add_dependency(dep); + if !metadata.metadata().contains_project_dependency(dep.name()) { + metadata + .metadata_mut() + .add_project_dependency(&dep.to_string()); } } - if package.metadata() != metadata.metadata() { - metadata.write_file()?; - } + metadata.metadata_mut().formatted(); + metadata.write_file()?; Ok(()) } @@ -60,12 +60,16 @@ pub fn add_project_optional_dependencies( options: &AddOptions, ) -> HuakResult<()> { let workspace = config.workspace(); - let package = workspace.current_package()?; let mut metadata = workspace.current_local_metadata()?; // Collect all dependencies that need to be added. + // TODO(cnpryer): Allow let mut deps = dependency_iter(dependencies) - .filter(|dep| !metadata.metadata().contains_optional_dependency(dep, group)) + .filter(|dep| { + !metadata + .metadata() + .contains_project_optional_dependency(dep.name(), group) + }) .collect::>(); if deps.is_empty() { @@ -88,14 +92,18 @@ pub fn add_project_optional_dependencies( } } - if !metadata.metadata().contains_optional_dependency(dep, group) { - metadata.metadata_mut().add_optional_dependency(dep, group); + if !metadata + .metadata() + .contains_project_optional_dependency(dep.name(), group) + { + metadata + .metadata_mut() + .add_project_optional_dependency(&dep.to_string(), group); } } - if package.metadata() != metadata.metadata() { - metadata.write_file()?; - } + metadata.metadata_mut().formatted(); + metadata.write_file()?; Ok(()) } @@ -140,7 +148,7 @@ mod tests { let metadata = ws.current_local_metadata().unwrap(); assert!(venv.contains_module("ruff").unwrap()); - assert!(metadata.metadata().contains_dependency(&dep)); + assert!(metadata.metadata().contains_project_dependency(dep.name())); } #[test] @@ -172,15 +180,15 @@ mod tests { install_options: InstallOptions { values: None }, }; - add_project_optional_dependencies(&[String::from("ruff")], group, &config, &options) + add_project_optional_dependencies(&[String::from("isort")], group, &config, &options) .unwrap(); - let dep = Dependency::from_str("ruff").unwrap(); + let dep = Dependency::from_str("isort").unwrap(); let metadata = ws.current_local_metadata().unwrap(); - assert!(venv.contains_module("ruff").unwrap()); + assert!(venv.contains_module("isort").unwrap()); assert!(metadata .metadata() - .contains_optional_dependency(&dep, "dev")); + .contains_project_optional_dependency(dep.name(), "dev")); } } diff --git a/crates/huak-package-manager/src/ops/build.rs b/crates/huak-package-manager/src/ops/build.rs index f26cd64d..cfca5508 100644 --- a/crates/huak-package-manager/src/ops/build.rs +++ b/crates/huak-package-manager/src/ops/build.rs @@ -10,7 +10,6 @@ pub struct BuildOptions { pub fn build_project(config: &Config, options: &BuildOptions) -> HuakResult<()> { let workspace = config.workspace(); - let package = workspace.current_package()?; let mut metadata = workspace.current_local_metadata()?; let python_env = workspace.resolve_python_environment()?; @@ -21,7 +20,10 @@ pub fn build_project(config: &Config, options: &BuildOptions) -> HuakResult<()> } // Add the installed `build` package to the metadata file. - if !metadata.metadata().contains_dependency_any(&build_dep) { + if !metadata + .metadata() + .contains_project_dependency_any(build_dep.name()) + { for pkg in python_env .installed_packages()? .iter() @@ -29,13 +31,12 @@ pub fn build_project(config: &Config, options: &BuildOptions) -> HuakResult<()> { metadata .metadata_mut() - .add_optional_dependency(&Dependency::from_str(&pkg.to_string())?, "dev"); + .add_project_optional_dependency(&pkg.to_string(), "dev"); } } - if package.metadata() != metadata.metadata() { - metadata.write_file()?; - } + metadata.metadata_mut().formatted(); + metadata.write_file()?; // Run `build`. let mut cmd = Command::new(python_env.python_path()); diff --git a/crates/huak-package-manager/src/ops/format.rs b/crates/huak-package-manager/src/ops/format.rs index c57a6e85..206e6ad8 100644 --- a/crates/huak-package-manager/src/ops/format.rs +++ b/crates/huak-package-manager/src/ops/format.rs @@ -10,7 +10,6 @@ pub struct FormatOptions { pub fn format_project(config: &Config, options: &FormatOptions) -> HuakResult<()> { let workspace = config.workspace(); - let package = workspace.current_package()?; let mut metadata = workspace.current_local_metadata()?; let python_env = workspace.resolve_python_environment()?; @@ -29,7 +28,11 @@ pub fn format_project(config: &Config, options: &FormatOptions) -> HuakResult<() // Add the installed `ruff` package to the metadata file if not already there. let new_format_deps = format_deps .iter() - .filter(|dep| !metadata.metadata().contains_dependency_any(dep)) + .filter(|dep| { + !metadata + .metadata() + .contains_project_dependency_any(dep.name()) + }) .map(Dependency::name) .collect::>(); @@ -41,13 +44,11 @@ pub fn format_project(config: &Config, options: &FormatOptions) -> HuakResult<() { metadata .metadata_mut() - .add_optional_dependency(&Dependency::from_str(&pkg.to_string())?, "dev"); + .add_project_optional_dependency(&pkg.to_string(), "dev"); } } - if package.metadata() != metadata.metadata() { - metadata.write_file()?; - } + metadata.write_file()?; // Run `ruff` for formatting imports and the rest of the Python code in the workspace. // NOTE: This needs to be refactored https://github.com/cnpryer/huak/issues/784, https://github.com/cnpryer/huak/issues/718 diff --git a/crates/huak-package-manager/src/ops/init.rs b/crates/huak-package-manager/src/ops/init.rs index ba29773e..bbf8c72b 100644 --- a/crates/huak-package-manager/src/ops/init.rs +++ b/crates/huak-package-manager/src/ops/init.rs @@ -1,3 +1,5 @@ +use toml_edit::{Item, Table}; + use super::init_git; use crate::{ default_package_entrypoint_string, importable_package_name, last_path_component, Config, @@ -11,11 +13,23 @@ pub fn init_app_project(config: &Config, options: &WorkspaceOptions) -> HuakResu let workspace = config.workspace(); let mut metadata = workspace.current_local_metadata()?; - let as_dep = Dependency::from_str(metadata.metadata().project_name())?; - let entry_point = default_package_entrypoint_string(&importable_package_name(as_dep.name())?); - metadata - .metadata_mut() - .add_script(as_dep.name(), &entry_point); + let Some(name) = metadata.metadata().project_name() else { + return Err(Error::InternalError("missing project name".to_string())); + }; + let as_dep = Dependency::from_str(&name)?; + let _entry_point = default_package_entrypoint_string(&importable_package_name(as_dep.name())?); + + if let Some(table) = metadata.metadata_mut().project_table_mut() { + let scripts = &mut table["scripts"]; + + if scripts.is_none() { + *scripts = Item::Table(Table::new()); + } + + let importable = importable_package_name(&name)?; + scripts[name] = toml_edit::value(format!("{importable}.main:main")); + } + metadata.write_file() } @@ -33,14 +47,14 @@ pub fn init_lib_project(config: &Config, options: &WorkspaceOptions) -> HuakResu } let name = last_path_component(&config.workspace_root)?; - metadata.metadata_mut().set_project_name(name); + metadata.metadata_mut().set_project_name(&name); metadata.write_file() } #[cfg(test)] mod tests { use super::*; - use crate::{default_pyproject_toml_contents, PyProjectToml, TerminalOptions, Verbosity}; + use crate::{default_pyproject_toml_contents, TerminalOptions, Verbosity}; use tempfile::tempdir; #[test] @@ -66,7 +80,7 @@ mod tests { let metadata = ws.current_local_metadata().unwrap(); assert_eq!( - metadata.to_string_pretty().unwrap(), + metadata.metadata().to_string(), default_pyproject_toml_contents("mock-project") ); } @@ -93,11 +107,9 @@ mod tests { let ws = config.workspace(); let metadata = ws.current_local_metadata().unwrap(); - let pyproject_toml = PyProjectToml::default(); - pyproject_toml.project.clone().unwrap().name = String::from("mock-project"); assert_eq!( - metadata.to_string_pretty().unwrap(), + metadata.metadata().to_string(), r#"[build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/crates/huak-package-manager/src/ops/install.rs b/crates/huak-package-manager/src/ops/install.rs index 20bba174..ad5f13db 100644 --- a/crates/huak-package-manager/src/ops/install.rs +++ b/crates/huak-package-manager/src/ops/install.rs @@ -1,4 +1,4 @@ -use crate::{Config, Dependency, HuakResult, InstallOptions}; +use crate::{Config, HuakResult, InstallOptions}; pub fn install_project_dependencies( groups: Option<&Vec>, @@ -6,46 +6,46 @@ pub fn install_project_dependencies( options: &InstallOptions, ) -> HuakResult<()> { let workspace = config.workspace(); - let package = workspace.current_package()?; let metadata = workspace.current_local_metadata()?; - let binding = Vec::new(); // TODO let mut dependencies = Vec::new(); if let Some(gs) = groups { // If the group "required" is passed and isn't a valid optional dependency group // then install just the required dependencies. - if package + // TODO(cnpryer): Refactor/move + if metadata .metadata() - .optional_dependency_group("required") - .is_none() - && gs.contains(&"required".to_string()) + .project_optional_dependency_groups() + .map_or(false, |it| it.iter().any(|s| s == "required")) { - if let Some(reqs) = package.metadata().dependencies() { - dependencies.extend(reqs.iter().map(Dependency::from)); + if let Some(reqs) = metadata.metadata().project_dependencies() { + dependencies.extend(reqs); } - } else { + } else if let Some(optional_deps) = metadata.metadata().project_optional_dependencies() { for g in gs { - package - .metadata() - .optional_dependency_group(g) - .unwrap_or(&binding) - .iter() - .for_each(|req| { - dependencies.push(Dependency::from(req)); - }); + // TODO(cnpryer): Perf + if let Some(deps) = optional_deps.get(&g.to_string()) { + dependencies.extend(deps.iter().cloned()); + } } } } else { // If no groups are passed then install all dependencies listed in the metadata file // including the optional dependencies. - if let Some(reqs) = package.metadata().dependencies() { - dependencies.extend(reqs.iter().map(Dependency::from)); + if let Some(reqs) = metadata.metadata().project_dependencies() { + dependencies.extend(reqs); } - if let Some(deps) = metadata.metadata().optional_dependencies() { - deps.values().for_each(|reqs| { - dependencies.extend(reqs.iter().map(Dependency::from).collect::>()); - }); + + // TODO(cnpryer): Install optional as opt-in + if let Some(groups) = metadata.metadata().project_optional_dependency_groups() { + for key in groups { + if let Some(g) = metadata.metadata().project_optional_dependencies() { + if let Some(it) = g.get(&key) { + dependencies.extend(it.iter().cloned()); + } + } + } } } diff --git a/crates/huak-package-manager/src/ops/lint.rs b/crates/huak-package-manager/src/ops/lint.rs index f3daef77..423f28f9 100644 --- a/crates/huak-package-manager/src/ops/lint.rs +++ b/crates/huak-package-manager/src/ops/lint.rs @@ -11,7 +11,6 @@ pub struct LintOptions { pub fn lint_project(config: &Config, options: &LintOptions) -> HuakResult<()> { let workspace = config.workspace(); - let package = workspace.current_package()?; let mut metadata = workspace.current_local_metadata()?; let python_env = workspace.resolve_python_environment()?; @@ -56,7 +55,11 @@ pub fn lint_project(config: &Config, options: &LintOptions) -> HuakResult<()> { // Add installed lint deps (potentially both `mypy` and `ruff`) to metadata file if not already there. let new_lint_deps = lint_deps .iter() - .filter(|dep| !metadata.metadata().contains_dependency_any(dep)) + .filter(|dep| { + !metadata + .metadata() + .contains_project_dependency_any(dep.name()) + }) .map(Dependency::name) .collect::>(); @@ -68,13 +71,12 @@ pub fn lint_project(config: &Config, options: &LintOptions) -> HuakResult<()> { { metadata .metadata_mut() - .add_optional_dependency(&Dependency::from_str(&pkg.to_string())?, "dev"); + .add_project_optional_dependency(&pkg.to_string(), "dev"); } } - if package.metadata() != metadata.metadata() { - metadata.write_file()?; - } + metadata.metadata_mut().formatted(); + metadata.write_file()?; Ok(()) } diff --git a/crates/huak-package-manager/src/ops/new.rs b/crates/huak-package-manager/src/ops/new.rs index a6f7c191..a14585b8 100644 --- a/crates/huak-package-manager/src/ops/new.rs +++ b/crates/huak-package-manager/src/ops/new.rs @@ -1,7 +1,9 @@ +use toml_edit::{Item, Table}; + use super::{create_workspace, init_git}; use crate::{ - default_package_entrypoint_string, default_package_test_file_contents, importable_package_name, - last_path_component, Config, Dependency, Error, HuakResult, LocalMetadata, WorkspaceOptions, + default_package_test_file_contents, importable_package_name, last_path_component, Config, + Dependency, Error, HuakResult, LocalMetadata, WorkspaceOptions, }; use std::str::FromStr; @@ -13,18 +15,25 @@ pub fn new_app_project(config: &Config, options: &WorkspaceOptions) -> HuakResul let name = last_path_component(workspace.root().as_path())?; let as_dep = Dependency::from_str(&name)?; - metadata.metadata_mut().set_project_name(name); + metadata.metadata_mut().set_project_name(&name); let src_path = workspace.root().join("src"); let importable_name = importable_package_name(as_dep.name())?; std::fs::write( - src_path.join(&importable_name).join("main.py"), + src_path.join(importable_name).join("main.py"), super::DEFAULT_PYTHON_MAIN_FILE_CONTENTS, )?; - let entry_point = default_package_entrypoint_string(&importable_name); - metadata - .metadata_mut() - .add_script(as_dep.name(), &entry_point); + + if let Some(table) = metadata.metadata_mut().project_table_mut() { + let scripts = &mut table["scripts"]; + + if scripts.is_none() { + *scripts = Item::Table(Table::new()); + } + + let importable = importable_package_name(&name)?; + scripts[name] = toml_edit::value(format!("{importable}.main:main")); + } metadata.write_file() } @@ -45,7 +54,10 @@ pub fn new_lib_project(config: &Config, options: &WorkspaceOptions) -> HuakResul } let name = &last_path_component(&config.workspace_root)?; - metadata.metadata_mut().set_project_name(name.to_string()); + metadata.metadata_mut().set_project_name(name); + + metadata.metadata_mut().formatted(); + metadata.write_file()?; metadata.write_file()?; let as_dep = Dependency::from_str(name)?; @@ -68,6 +80,7 @@ pub fn new_lib_project(config: &Config, options: &WorkspaceOptions) -> HuakResul mod tests { use super::*; use crate::{TerminalOptions, Verbosity}; + use huak_pyproject_toml::value_to_sanitized_string; use tempfile::tempdir; #[test] @@ -108,7 +121,11 @@ def test_version(): let expected_init_file = "__version__ = \"0.0.1\" "; - assert!(metadata.metadata().project().scripts.is_none()); + assert!(metadata + .metadata() + .project_table() + .and_then(|it| it.get("scripts")) + .is_none()); assert_eq!(test_file, expected_test_file); assert_eq!(init_file, expected_init_file); } @@ -145,8 +162,17 @@ if __name__ == "__main__": "#; assert_eq!( - metadata.metadata().project().scripts.as_ref().unwrap()["mock-project"], - format!("{}.main:main", "mock_project") + value_to_sanitized_string( + metadata + .metadata() + .project_table() + .unwrap() + .get("scripts") + .unwrap()["mock-project"] + .as_value() + .unwrap() + ), + "mock_project.main:main".to_string() ); assert_eq!(main_file, expected_main_file); } diff --git a/crates/huak-package-manager/src/ops/publish.rs b/crates/huak-package-manager/src/ops/publish.rs index 38385490..13a23364 100644 --- a/crates/huak-package-manager/src/ops/publish.rs +++ b/crates/huak-package-manager/src/ops/publish.rs @@ -10,7 +10,6 @@ pub struct PublishOptions { pub fn publish_project(config: &Config, options: &PublishOptions) -> HuakResult<()> { let workspace = config.workspace(); - let package = workspace.current_package()?; let mut metadata = workspace.current_local_metadata()?; let python_env = workspace.resolve_python_environment()?; @@ -21,7 +20,10 @@ pub fn publish_project(config: &Config, options: &PublishOptions) -> HuakResult< } // Add the installed `twine` package to the metadata file if it isn't already there. - if !metadata.metadata().contains_dependency_any(&pub_dep) { + if !metadata + .metadata() + .contains_project_dependency_any(pub_dep.name()) + { for pkg in python_env .installed_packages()? .iter() @@ -29,13 +31,12 @@ pub fn publish_project(config: &Config, options: &PublishOptions) -> HuakResult< { metadata .metadata_mut() - .add_optional_dependency(&Dependency::from_str(&pkg.to_string())?, "dev"); + .add_project_optional_dependency(&pkg.to_string(), "dev"); } } - if package.metadata() != metadata.metadata() { - metadata.write_file()?; - } + metadata.metadata_mut().formatted(); + metadata.write_file()?; // Run `twine`. let mut cmd = Command::new(python_env.python_path()); diff --git a/crates/huak-package-manager/src/ops/remove.rs b/crates/huak-package-manager/src/ops/remove.rs index 7b70b1c8..ba22cd93 100644 --- a/crates/huak-package-manager/src/ops/remove.rs +++ b/crates/huak-package-manager/src/ops/remove.rs @@ -10,35 +10,39 @@ pub fn remove_project_dependencies( options: &RemoveOptions, ) -> HuakResult<()> { let workspace = config.workspace(); - let package = workspace.current_package()?; let mut metadata = workspace.current_local_metadata()?; // Collect any dependencies to remove from the metadata file. let deps = dependency_iter(dependencies) - .filter(|dep| metadata.metadata().contains_dependency_any(dep)) + .filter(|dep| { + metadata + .metadata() + .contains_project_dependency_any(dep.name()) + }) .collect::>(); if deps.is_empty() { return Ok(()); } - // Get all groups from the metadata file to include in the removal process. - let mut groups = Vec::new(); - if let Some(deps) = metadata.metadata().optional_dependencies() { - groups.extend(deps.keys().map(ToString::to_string)); - } + let optional_groups = metadata.metadata().project_optional_dependency_groups(); + for dep in &deps { - metadata.metadata_mut().remove_dependency(dep); - for group in &groups { - metadata - .metadata_mut() - .remove_optional_dependency(dep, group); + metadata + .metadata_mut() + .remove_project_dependency(dep.name()); + + if let Some(groups) = optional_groups.as_ref() { + for g in groups { + metadata + .metadata_mut() + .remove_project_optional_dependency(dep.name(), g); + } } } - if package.metadata() != metadata.metadata() { - metadata.write_file()?; - } + metadata.metadata_mut().formatted(); + metadata.write_file()?; // Uninstall the dependencies from the Python environment if an environment is found. match workspace.current_python_environment() { @@ -91,14 +95,18 @@ mod tests { .unwrap(); let metadata = ws.current_local_metadata().unwrap(); let venv_had_package = venv.contains_package(&test_package); - let toml_had_package = metadata.metadata().contains_dependency(&test_dep); + let toml_had_package = metadata + .metadata() + .contains_project_dependency(test_dep.name()); remove_project_dependencies(&["click".to_string()], &config, &options).unwrap(); let ws = config.workspace(); let metadata = ws.current_local_metadata().unwrap(); let venv_contains_package = venv.contains_package(&test_package); - let toml_contains_package = metadata.metadata().contains_dependency(&test_dep); + let toml_contains_package = metadata + .metadata() + .contains_project_dependency(test_dep.name()); assert!(venv_had_package); assert!(toml_had_package); @@ -134,23 +142,24 @@ mod tests { initialize_venv(ws.root().join(".venv"), &ws.environment()).unwrap(); let metadata = ws.current_local_metadata().unwrap(); let venv = ws.resolve_python_environment().unwrap(); - let test_package = Package::from_str("black==22.8.0").unwrap(); - let test_dep = Dependency::from_str("black==22.8.0").unwrap(); + let test_dep = Dependency::from_str("ruff").unwrap(); venv.install_packages(&[&test_dep], &options.install_options, &config) .unwrap(); - let venv_had_package = venv.contains_module(test_package.name()).unwrap(); + let venv_had_package = venv.contains_module(test_dep.name()).unwrap(); let toml_had_package = metadata .metadata() - .contains_optional_dependency(&test_dep, "dev"); + .contains_project_optional_dependency(test_dep.name(), "dev"); - remove_project_dependencies(&["black".to_string()], &config, &options).unwrap(); + remove_project_dependencies(&["ruff".to_string()], &config, &options).unwrap(); let ws = config.workspace(); let metadata = ws.current_local_metadata().unwrap(); let venv_contains_package = venv - .contains_module(metadata.metadata().project_name()) + .contains_module(&metadata.metadata().project_name().unwrap().to_string()) .unwrap(); - let toml_contains_package = metadata.metadata().contains_dependency(&test_dep); + let toml_contains_package = metadata + .metadata() + .contains_project_dependency(test_dep.name()); assert!(venv_had_package); assert!(toml_had_package); diff --git a/crates/huak-package-manager/src/ops/test.rs b/crates/huak-package-manager/src/ops/test.rs index 248adbde..951757f1 100644 --- a/crates/huak-package-manager/src/ops/test.rs +++ b/crates/huak-package-manager/src/ops/test.rs @@ -10,7 +10,6 @@ pub struct TestOptions { pub fn test_project(config: &Config, options: &TestOptions) -> HuakResult<()> { let workspace = config.workspace(); - let package = workspace.current_package()?; let mut metadata = workspace.current_local_metadata()?; let python_env = workspace.resolve_python_environment()?; @@ -21,7 +20,10 @@ pub fn test_project(config: &Config, options: &TestOptions) -> HuakResult<()> { } // Add the installed `pytest` package to the metadata file if it isn't already there. - if !metadata.metadata().contains_dependency_any(&test_dep) { + if !metadata + .metadata() + .contains_project_dependency_any(test_dep.name()) + { for pkg in python_env .installed_packages()? .iter() @@ -29,13 +31,12 @@ pub fn test_project(config: &Config, options: &TestOptions) -> HuakResult<()> { { metadata .metadata_mut() - .add_optional_dependency(&Dependency::from_str(&pkg.to_string())?, "dev"); + .add_project_optional_dependency(&pkg.to_string(), "dev"); } } - if package.metadata() != metadata.metadata() { - metadata.write_file()?; - } + metadata.metadata_mut().formatted(); + metadata.write_file()?; // Run `pytest` with the package directory added to the command's `PYTHONPATH`. let mut cmd = Command::new(python_env.python_path()); diff --git a/crates/huak-package-manager/src/ops/update.rs b/crates/huak-package-manager/src/ops/update.rs index 174b1fd3..fe454d03 100644 --- a/crates/huak-package-manager/src/ops/update.rs +++ b/crates/huak-package-manager/src/ops/update.rs @@ -13,7 +13,6 @@ pub fn update_project_dependencies( options: &UpdateOptions, ) -> HuakResult<()> { let workspace = config.workspace(); - let package = workspace.current_package()?; let mut metadata = workspace.current_local_metadata()?; let python_env = workspace.resolve_python_environment()?; @@ -21,7 +20,10 @@ pub fn update_project_dependencies( if let Some(it) = dependencies.as_ref() { let deps = dependency_iter(it) .filter_map(|dep| { - if metadata.metadata().contains_dependency_any(&dep) { + if metadata + .metadata() + .contains_project_dependency_any(dep.name()) + { Some(dep) } else { None @@ -37,44 +39,58 @@ pub fn update_project_dependencies( } else { let mut deps = metadata .metadata() - .dependencies() - .map_or(Vec::new(), |reqs| { - reqs.iter().map(Dependency::from).collect::>() - }); - - if let Some(odeps) = metadata.metadata().optional_dependencies() { - odeps.values().for_each(|reqs| { - deps.extend(reqs.iter().map(Dependency::from).collect::>()); - }); + .project_dependencies() + .map_or(Vec::new(), |reqs| reqs.into_iter().collect::>()); + + if let Some(gs) = metadata.metadata().project_optional_dependency_groups() { + if let Some(optional_deps) = metadata.metadata().project_optional_dependencies() { + for g in gs { + // TODO(cnpryer): Perf + if let Some(it) = optional_deps.get(&g.to_string()) { + deps.extend(it.iter().cloned()); + } + } + } } deps.dedup(); + python_env.update_packages(&deps, &options.install_options, config)?; } - // Get all groups from the metadata file to include in the removal process. - let mut groups = Vec::new(); - if let Some(deps) = metadata.metadata().optional_dependencies() { - groups.extend(deps.keys().map(ToString::to_string)); - } + let groups = metadata.metadata().project_optional_dependency_groups(); for pkg in python_env.installed_packages()? { let dep = &Dependency::from_str(&pkg.to_string())?; - if metadata.metadata().contains_dependency(dep) { - metadata.metadata_mut().remove_dependency(dep); - metadata.metadata_mut().add_dependency(dep); + if metadata.metadata().contains_project_dependency(dep.name()) { + metadata + .metadata_mut() + .remove_project_dependency(dep.name()); + metadata + .metadata_mut() + .add_project_dependency(&dep.to_string()); } - for g in &groups { - if metadata.metadata().contains_optional_dependency(dep, g) { - metadata.metadata_mut().remove_optional_dependency(dep, g); - metadata.metadata_mut().add_optional_dependency(dep, g); + + if let Some(gs) = groups.as_ref() { + for g in gs { + if metadata + .metadata() + .contains_project_optional_dependency(dep.name(), g) + { + metadata + .metadata_mut() + .remove_project_optional_dependency(dep.name(), g); + metadata + .metadata_mut() + .add_project_optional_dependency(&dep.to_string(), g); + } } } } - if package.metadata() != metadata.metadata() { - metadata.write_file()?; - } + metadata.metadata_mut().formatted(); + metadata.write_file()?; + Ok(()) } diff --git a/crates/huak-package-manager/src/package.rs b/crates/huak-package-manager/src/package.rs index 397b2fa0..ffe647bc 100644 --- a/crates/huak-package-manager/src/package.rs +++ b/crates/huak-package-manager/src/package.rs @@ -1,4 +1,5 @@ -use crate::{metadata::Metadata, Error, HuakResult}; +use crate::{Error, HuakResult}; +use huak_pyproject_toml::PyProjectToml; use lazy_static::lazy_static; use pep440_rs::{Operator, Version, VersionSpecifiers}; use regex::Regex; @@ -23,12 +24,11 @@ lazy_static! { /// /// assert_eq!(package.version, Version::from_str("0.0.1").unwrap())); /// ``` -#[derive(Clone)] pub struct Package { /// Information used to identify the `Package`. id: PackageId, - /// The `Package`'s core `Metadata`. - metadata: Metadata, + /// The `Package`'s core `PyProjectToml` metadata. + metadata: PyProjectToml, } impl Package { @@ -44,12 +44,31 @@ impl Package { &self.id.version } - /// Get a reference to the `Package`'s core `Metadata`. + /// Get a reference to the `Package`'s core `PyProjectToml` metadata. #[must_use] - pub fn metadata(&self) -> &Metadata { + pub fn metadata(&self) -> &PyProjectToml { &self.metadata } + pub fn try_from_metadata(metadata: &PyProjectToml) -> HuakResult { + let Some(name) = metadata.project_name() else { + return Err(Error::InternalError("missing project name".to_string())); + }; + + let Some(version) = metadata.project_version() else { + return Err(Error::InternalError("missing project version".to_string())); + }; + + Ok(Self { + id: PackageId { + name, + version: Version::from_str(&version) + .map_err(|e| Error::InvalidVersionString(e.to_string()))?, + }, + metadata: metadata.clone(), + }) + } + // TODO: I want this implemented with `FromStr`. /// Initialize a `Package` from a `&str`. /// @@ -89,8 +108,8 @@ impl Package { version: version_specifer.version().to_owned(), }; - let mut metadata = Metadata::default(); - metadata.set_project_name(name); + let mut metadata = PyProjectToml::default(); + metadata.set_project_name(&name); let package = Package { id, metadata }; @@ -117,21 +136,6 @@ impl<'a> Iterator for PackageIter<'a> { } } -impl From for Package { - fn from(value: Metadata) -> Self { - Package { - id: PackageId { - name: value.project_name().to_string(), - version: value - .project_version() - .unwrap_or(&Version::from_str("0.0.1").unwrap()) - .clone(), - }, - metadata: value, - } - } -} - /// Two `Package`s are currently considered partially equal if their names are the same. /// NOTE: This may change in the future. impl PartialEq for Package { diff --git a/crates/huak-package-manager/src/workspace.rs b/crates/huak-package-manager/src/workspace.rs index 2037e700..80a19348 100644 --- a/crates/huak-package-manager/src/workspace.rs +++ b/crates/huak-package-manager/src/workspace.rs @@ -54,7 +54,7 @@ impl Workspace { // Currently only pyproject.toml `LocalMetadata` file is supported. let metadata = self.current_local_metadata()?; - let package = Package::from(metadata.metadata().clone()); + let package = Package::try_from_metadata(metadata.metadata())?; Ok(package) } @@ -218,7 +218,11 @@ fn resolve_local_toolchain( // Use workspace project metadata and return if a toolchain is listed. if let Ok(metadata) = workspace.current_local_metadata() { - if let Some(table) = metadata.metadata().tool().and_then(|it| it.get("huak")) { + if let Some(table) = metadata + .metadata() + .tool_table() + .and_then(|it| it.get("huak")) + { if let Some(path) = table .get("toolchain") .map(std::string::ToString::to_string) 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..4469346d --- /dev/null +++ b/crates/huak-pyproject-toml/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "huak-pyproject-toml" +version = "0.0.0" +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +# pep508_rs = { workspace = true, features = ["serde", "toml"] } +pep508_rs.workspace = true +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..60f5c128 --- /dev/null +++ b/crates/huak-pyproject-toml/src/lib.rs @@ -0,0 +1,773 @@ +//! ## huak-pyproject-toml +//! +//! 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/*"] +//! ``` + +pub use error::Error; +use pep508_rs::Requirement; +use std::{collections::HashMap, fmt::Display, path::Path, str::FromStr}; +use toml_edit::{Array, Document, Formatted, Item, Table, Value}; +pub use utils::value_to_sanitized_string; +use utils::{format_array, format_table, sanitize_str}; + +mod error; +mod utils; + +#[derive(Clone)] +/// Huak's `PyProjectToml` implementation. +/// +/// - Core `PyProjectToml` +/// - Tool table +/// - Huak's table +pub struct PyProjectToml { + pub 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 { + self.project_table() + .and_then(|it| it.get("name")) + .and_then(Item::as_value) + .map(value_to_sanitized_string) + } + + 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 { + self.project_table() + .and_then(|it| it.get("version")) + .and_then(Item::as_value) + .map(value_to_sanitized_string) + } + + 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 { + self.project_table() + .and_then(|it| it.get("description")) + .and_then(Item::as_value) + .map(value_to_sanitized_string) + } + + 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> { + let Some(array) = self + .project_table() + .and_then(|it| it.get("dependencies")) + .and_then(Item::as_array) + else { + return None; + }; + + Some( + array + .into_iter() + .map(value_to_sanitized_string) + .collect::>(), + ) + } + + 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 + } + + #[must_use] + pub fn contains_project_dependency_any(&self, dependency: &str) -> bool { + self.project_dependencies().map_or(false, |it| { + it.iter().any(|v| matches_dependency(v, dependency)) + }) || self.contains_project_optional_dependency_any(dependency) + } + + #[must_use] + pub fn contains_project_dependency(&self, dependency: &str) -> bool { + self.project_dependencies().map_or(false, |it| { + it.iter().any(|v| matches_dependency(v, dependency)) + }) + } + + 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_dependency_groups(&self) -> Option> { + // TODO(cnpryer): Perf + self.project_optional_dependencies() + .map(|it| it.keys().cloned().collect::>()) + } + + #[must_use] + pub fn project_optional_dependencies(&self) -> Option>> { + let Some(table) = self + .project_table() + .and_then(|it| it.get("optional-dependencies")) + .and_then(Item::as_table) + else { + return None; + }; + + let mut deps = HashMap::new(); + let groups = table.iter().map(|(k, _)| k).collect::>(); + + for it in &groups { + if let Some(array) = table.get(it).and_then(|item| item.as_array()) { + deps.insert( + sanitize_str(it), + array + .iter() + .map(value_to_sanitized_string) + .collect::>(), + ); + } + } + + Some(deps) + } + + pub fn project_optional_dependencies_mut(&mut self) -> Option<&mut Table> { + self.project_table_mut() + .and_then(|it| it.get_mut("optional-dependencies")) + .and_then(Item::as_table_mut) + } + + pub fn add_project_optional_dependency(&mut self, dependency: &str, group: &str) -> &mut Self { + let item: &mut Item = &mut self.doc["project"]["optional-dependencies"]; + + if item.is_none() { + *item = Item::Table(Table::new()); + } + + add_array_str(&mut item[group], 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 + } + + #[must_use] + pub fn contains_project_optional_dependency_any(&self, dependency: &str) -> bool { + let Some(keys) = self.project_optional_dependency_groups() else { + return false; + }; + + for key in keys { + if self.contains_project_optional_dependency(dependency, &key) { + return true; + } + } + + false + } + + #[must_use] + pub fn contains_project_optional_dependency(&self, dependency: &str, group: &str) -> bool { + // TODO(cnpryer): Perf + self.project_optional_dependencies().map_or(false, |it| { + it.get(&group.to_string()).map_or(false, |g| { + g.iter().any(|s| matches_dependency(s, dependency)) + }) + }) + } +} + +/// 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)?) +} + +#[allow(dead_code)] +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_table); + + pyproject_toml +} + +/// Save the `PyProjectToml` to a filepath. +fn write_pyproject_toml>(toml: &PyProjectToml, path: T) -> Result<(), Error> { + Ok(std::fs::write(path, toml.to_string())?) +} + +// TODO(cnpryer): If contains requirement +fn add_array_str(item: &mut Item, s: &str) { + if item.is_none() { + *item = Item::Value(Value::Array(Array::new())); + } + + // Replace the entry if it exists + if let Some(index) = item.as_array().and_then(|it| { + it.iter() + .position(|v| v.as_str().map_or(false, |x| matches_dependency(x, s))) + }) { + if let Some(array) = item.as_array_mut() { + array.replace(index, s); + } + } else { + item.as_array_mut().get_or_insert(&mut Array::new()).push(s); + } +} + +fn remove_array_dependency(item: &mut Item, dependency: &str) { + if let Some(array) = item.as_array_mut() { + array.retain(|it| { + it.as_str() + .map_or(false, |s| !matches_dependency(s, dependency)) + }); + + if let Some(it) = array.get_mut(0) { + let Some(s) = it.as_str() else { + return; + }; + *it = Value::String(Formatted::new(s.trim_start().to_string())); + } + } +} + +fn matches_dependency(s: &str, dependency: &str) -> bool { + let Ok(req) = Requirement::from_str(dependency) else { + return false; + }; + + Requirement::from_str(s).map_or(false, |it| it.name == req.name) +} + +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().unwrap(); + let version = pyproject_toml.project_version().unwrap(); + let dependencies = pyproject_toml + .project_dependencies() + .map(|it| it.into_iter().collect::>()); + let optional_dependencies = pyproject_toml.project_optional_dependencies(); + + assert_eq!(name, "huak".to_string()); + assert_eq!(version, "0.0.20a1".to_string()); + assert!(dependencies.is_some()); + assert!(pyproject_toml.contains_project_dependency("test")); + assert!(optional_dependencies.is_none()); + assert!(!pyproject_toml.contains_project_optional_dependency("test", "test")); + } + + #[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 pyproject_toml = PyProjectToml::read_toml(workspace.join("pyproject.toml")).unwrap(); + + assert_eq!(&pyproject_toml.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") + .add_project_dependency("new") + .remove_project_dependency("test") + .add_project_optional_dependency("test", "test") + .add_project_optional_dependency("new", "test") + .remove_project_optional_dependency("test", "test") + .formatted() + .write_toml(workspace.join("pyproject.toml")) + .unwrap(); + let pyproject_toml = PyProjectToml::read_toml(workspace.join("pyproject.toml")).unwrap(); + let optional_deps = pyproject_toml.project_optional_dependencies().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!(optional_deps.get("test").unwrap(), &vec!["new".to_string()]); + + 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 = [ + "new", +] # 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" + +[project.optional-dependencies] +test = [ + "new", +] + +[tool.maturin] +bindings = "bin" +manifest-path = "crates/huak-cli/Cargo.toml" +module-name = "huak" +python-source = "python" +strip = true + +[tool.huak] +toolchain = "default" + +[tool.huak.run] +hello-world = "python -c 'print(\"hello, world.\")'" + +[tool.huak.workspace] +members = ["projects/*"] +"# + ); + } + + #[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" + +[tool.huak.run] +hello-world = "python -c 'print(\"hello, world.\")'" + +[tool.huak.workspace] +members = ["projects/*"] +"# + ); + } + + #[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" + +[tool.huak.run] +hello-world = "python -c 'print(\"hello, world.\")'" + +[tool.huak.workspace] +members = ["projects/*"] +"# + } +} diff --git a/crates/huak-pyproject-toml/src/utils.rs b/crates/huak-pyproject-toml/src/utils.rs new file mode 100644 index 00000000..baa3fcc0 --- /dev/null +++ b/crates/huak-pyproject-toml/src/utils.rs @@ -0,0 +1,69 @@ +use toml_edit::{Array, RawString, Table, Value}; + +#[must_use] +pub fn value_to_sanitized_string(value: &Value) -> String { + match value { + Value::String(string) => sanitize_str(string.value()), + _ => value.to_string(), + } +} + +pub(crate) fn sanitize_str(s: &str) -> String { + s.trim_matches('\n') + .trim() + .trim_start_matches(['\\', '\'', '"']) + .to_string() +} + +pub fn format_table(table: &mut Table) { + for array in table.iter_mut().filter_map(|(_, v)| v.as_array_mut()) { + format_array(array); + } +} + +/// See Rye for original implementation +/// Reformats a TOML array to multi line while trying to +/// preserve all comments and move them around. This also makes +/// the array to have a trailing comma. +pub fn format_array(array: &mut Array) { + if array.is_empty() { + return; + } + + for item in array.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(""); + } + + array.set_trailing(&{ + let mut comments = find_comments(Some(array.trailing())).peekable(); + let mut rv = String::new(); + if comments.peek().is_some() { + for comment in comments { + rv.push_str("\n "); + rv.push_str(comment); + } + } + rv.push('\n'); + rv + }); + + array.set_trailing_comma(true); +} + +fn find_comments(s: Option<&RawString>) -> impl Iterator { + s.and_then(|x| x.as_str()) + .unwrap_or("") + .lines() + .filter_map(|line| { + let line = line.trim(); + line.starts_with('#').then_some(line) + }) +} diff --git a/dev-resources/mock-project/pyproject.toml b/dev-resources/mock-project/pyproject.toml index a54d8459..e16cee2d 100644 --- a/dev-resources/mock-project/pyproject.toml +++ b/dev-resources/mock-project/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" name = "mock_project" version = "0.0.1" description = "" -dependencies = ["click == 8.1.3"] +dependencies = ["click == 8.1.7"] [[project.authors]] name = "Chris Pryer" @@ -14,7 +14,6 @@ email = "cnpryer@gmail.com" [project.optional-dependencies] dev = [ - "pytest >= 6", - "black == 22.8.0", - "isort == 5.12.0", + "pytest == 7.4.3", + "ruff", ]