From 65767b9e512043af2333a9fd3063673b2f40e21f Mon Sep 17 00:00:00 2001 From: Steven Kessler Date: Wed, 1 Jan 2025 13:12:02 -0500 Subject: [PATCH] feat(#44): added scripts migrations for poetry projects --- Cargo.lock | 14 +-- Cargo.toml | 2 +- src/migrators/mod.rs | 4 + src/utils/pyproject.rs | 277 ++++++++++++++++++++++++++++++++++++++++- 4 files changed, 287 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6d4834e..339b4f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1173,9 +1173,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "reqwest" -version = "0.12.11" +version = "0.12.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe060fe50f524be480214aba758c71f99f90ee8c83c5a36b5e9e1d568eb4eb3" +checksum = "43e734407157c3c2034e0258f5e4473ddb361b1e85f95a66690d67264d7cd1da" dependencies = [ "base64", "bytes", @@ -1495,9 +1495,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.92" +version = "2.0.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70ae51629bf965c5c098cc9e87908a3df5301051a9e087d6f9bef5c9771ed126" +checksum = "9c786062daee0d6db1132800e623df74274a0a87322d8e183338e01b3d98d058" dependencies = [ "proc-macro2", "quote", @@ -1804,7 +1804,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uv-migrator" -version = "2025.2.7" +version = "2025.3.0" dependencies = [ "clap", "dirs", @@ -2129,9 +2129,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.6.20" +version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" +checksum = "e6f5bb5257f2407a5425c6e749bfd9692192a73e70a6060516ac04f889087d68" dependencies = [ "memchr", ] diff --git a/Cargo.toml b/Cargo.toml index 27ceb0d..bb57b52 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-migrator" -version = "2025.2.7" +version = "2025.3.0" edition = "2021" authors = ["stvnksslr@gmail.com"] description = "Tool for converting various python package soltutions to use the uv solution by astral" diff --git a/src/migrators/mod.rs b/src/migrators/mod.rs index 154da77..225f5df 100644 --- a/src/migrators/mod.rs +++ b/src/migrators/mod.rs @@ -266,6 +266,10 @@ pub fn run_migration( file_tracker.track_file(&pyproject_path)?; pyproject::append_tool_sections(project_dir)?; + info!("Migrating Poetry scripts"); + file_tracker.track_file(&pyproject_path)?; + pyproject::update_scripts(project_dir)?; + // Reorder TOML sections as the final step info!("Reordering pyproject.toml sections"); file_tracker.track_file(&pyproject_path)?; diff --git a/src/utils/pyproject.rs b/src/utils/pyproject.rs index 43b21e5..3677189 100644 --- a/src/utils/pyproject.rs +++ b/src/utils/pyproject.rs @@ -1,7 +1,8 @@ use crate::utils::toml::{read_toml, update_section, write_toml}; use log::{debug, info}; -use std::path::Path; -use toml_edit::{Array, Formatted, Item, Value}; +use std::{fs, path::Path}; +use toml_edit::Table; +use toml_edit::{Array, DocumentMut, Formatted, Item, Value}; pub fn update_pyproject_toml(project_dir: &Path, extra_urls: &[String]) -> Result<(), String> { let pyproject_path = project_dir.join("pyproject.toml"); @@ -80,6 +81,70 @@ pub fn update_url(project_dir: &Path, url: &str) -> Result<(), String> { Ok(()) } +fn convert_script_format(poetry_script: &str) -> String { + // Poetry format: 'package.module:function' + // UV format: "package.module:function" + poetry_script.trim_matches('\'').to_string() +} + +pub fn migrate_poetry_scripts(doc: &DocumentMut) -> Option { + let poetry_scripts = doc.get("tool")?.get("poetry")?.get("scripts")?.as_table()?; + + let mut scripts_table = Table::new(); + + for (script_name, script_value) in poetry_scripts.iter() { + if let Some(script_str) = script_value.as_str() { + // Convert Poetry script format to UV format + let converted_script = convert_script_format(script_str); + scripts_table.insert( + script_name, + toml_edit::Item::Value(Value::String(Formatted::new(converted_script))), + ); + } + } + + if !scripts_table.is_empty() { + Some(scripts_table) + } else { + None + } +} + +pub fn update_scripts(project_dir: &Path) -> Result<(), String> { + let pyproject_path = project_dir.join("pyproject.toml"); + let content = fs::read_to_string(&pyproject_path) + .map_err(|e| format!("Failed to read pyproject.toml: {}", e))?; + + let doc = content + .parse::() + .map_err(|e| format!("Failed to parse TOML: {}", e))?; + + if let Some(scripts_table) = migrate_poetry_scripts(&doc) { + let mut new_doc = doc.clone(); + + // Remove old poetry scripts section + if let Some(tool) = new_doc.get_mut("tool") { + if let Some(poetry) = tool.get_mut("poetry") { + if let Some(table) = poetry.as_table_mut() { + table.remove("scripts"); + } + } + } + + // Add new scripts section + update_section( + &mut new_doc, + &["project", "scripts"], + Item::Table(scripts_table), + ); + + write_toml(&pyproject_path, &mut new_doc)?; + info!("Successfully migrated Poetry scripts to UV format"); + } + + Ok(()) +} + pub fn update_project_version(project_dir: &Path, version: &str) -> Result<(), String> { let pyproject_path = project_dir.join("pyproject.toml"); let mut doc = read_toml(&pyproject_path)?; @@ -144,3 +209,211 @@ pub fn append_tool_sections(project_dir: &Path) -> Result<(), String> { Ok(()) } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + use tempfile::TempDir; + use toml_edit::DocumentMut; + + fn create_test_pyproject(content: &str) -> (TempDir, PathBuf) { + let temp_dir = TempDir::new().unwrap(); + let project_dir = temp_dir.path().to_path_buf(); + let pyproject_path = project_dir.join("pyproject.toml"); + fs::write(&pyproject_path, content).unwrap(); + (temp_dir, project_dir) + } + + #[test] + fn test_basic_script_migration() { + let content = r#" +[tool.poetry] +name = "test-project" +version = "0.1.0" + +[tool.poetry.scripts] +cli = "my_package.cli:main" +serve = "my_package.server:run_server" +"#; + let (_temp_dir, project_dir) = create_test_pyproject(content); + update_scripts(&project_dir).unwrap(); + + let new_content = fs::read_to_string(project_dir.join("pyproject.toml")).unwrap(); + let doc = new_content.parse::().unwrap(); + + assert!(doc + .get("tool") + .unwrap() + .get("poetry") + .unwrap() + .get("scripts") + .is_none()); + + let scripts = doc + .get("project") + .unwrap() + .get("scripts") + .unwrap() + .as_table() + .unwrap(); + assert_eq!( + scripts.get("cli").unwrap().as_str().unwrap(), + "my_package.cli:main" + ); + assert_eq!( + scripts.get("serve").unwrap().as_str().unwrap(), + "my_package.server:run_server" + ); + } + + #[test] + fn test_script_with_single_quotes() { + let content = r#" +[tool.poetry.scripts] +start = 'package.module:func' +"#; + let (_temp_dir, project_dir) = create_test_pyproject(content); + update_scripts(&project_dir).unwrap(); + + let new_content = fs::read_to_string(project_dir.join("pyproject.toml")).unwrap(); + let doc = new_content.parse::().unwrap(); + + let scripts = doc + .get("project") + .unwrap() + .get("scripts") + .unwrap() + .as_table() + .unwrap(); + assert_eq!( + scripts.get("start").unwrap().as_str().unwrap(), + "package.module:func" + ); + } + + #[test] + fn test_multiple_complex_scripts() { + let content = r#" +[tool.poetry.scripts] +cli = "package.commands.cli:main_func" +web = "package.web.server:start_server" +worker = "package.workers.background:process_queue" +"#; + let (_temp_dir, project_dir) = create_test_pyproject(content); + update_scripts(&project_dir).unwrap(); + + let new_content = fs::read_to_string(project_dir.join("pyproject.toml")).unwrap(); + let doc = new_content.parse::().unwrap(); + + let scripts = doc + .get("project") + .unwrap() + .get("scripts") + .unwrap() + .as_table() + .unwrap(); + assert_eq!( + scripts.get("cli").unwrap().as_str().unwrap(), + "package.commands.cli:main_func" + ); + assert_eq!( + scripts.get("web").unwrap().as_str().unwrap(), + "package.web.server:start_server" + ); + assert_eq!( + scripts.get("worker").unwrap().as_str().unwrap(), + "package.workers.background:process_queue" + ); + } + + #[test] + fn test_empty_scripts_section() { + let content = r#" +[tool.poetry] +name = "test-project" +version = "0.1.0" + +[tool.poetry.scripts] +"#; + let (_temp_dir, project_dir) = create_test_pyproject(content); + update_scripts(&project_dir).unwrap(); + + let new_content = fs::read_to_string(project_dir.join("pyproject.toml")).unwrap(); + let doc = new_content.parse::().unwrap(); + + assert!(doc.get("project").and_then(|p| p.get("scripts")).is_none()); + } + + #[test] + fn test_no_scripts_section() { + let content = r#" +[tool.poetry] +name = "test-project" +version = "0.1.0" +"#; + let (_temp_dir, project_dir) = create_test_pyproject(content); + update_scripts(&project_dir).unwrap(); + + let new_content = fs::read_to_string(project_dir.join("pyproject.toml")).unwrap(); + let doc = new_content.parse::().unwrap(); + + assert!(doc.get("project").and_then(|p| p.get("scripts")).is_none()); + } + + #[test] + fn test_script_format_conversion() { + let test_cases = vec![ + ("'package.module:func'", "package.module:func"), + ("package.module:func", "package.module:func"), + ("'module:main'", "module:main"), + ( + "'deeply.nested.module:complex_func'", + "deeply.nested.module:complex_func", + ), + ]; + + for (input, expected) in test_cases { + assert_eq!(convert_script_format(input), expected); + } + } + + #[test] + fn test_preserve_other_sections() { + let content = r#" +[tool.poetry] +name = "test-project" +version = "0.1.0" + +[tool.poetry.scripts] +cli = "package.cli:main" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.other] +setting = "value" +"#; + let (_temp_dir, project_dir) = create_test_pyproject(content); + update_scripts(&project_dir).unwrap(); + + let new_content = fs::read_to_string(project_dir.join("pyproject.toml")).unwrap(); + let doc = new_content.parse::().unwrap(); + + assert!(doc.get("build-system").is_some()); + assert!(doc.get("tool").unwrap().get("other").is_some()); + assert_eq!( + doc.get("tool") + .unwrap() + .get("other") + .unwrap() + .get("setting") + .unwrap() + .as_str() + .unwrap(), + "value" + ); + } +}