use anyhow::Result;
use serde::Deserialize;
use std::path::Path;
use std::time::Duration;
use tokio::process::Command;
use super::PackageInfo;
const PYTHON_OPERATION_TIMEOUT_SECS: u64 = 300;
#[derive(Deserialize)]
struct PyProjectToml {
project: Option<PyProject>,
}
#[derive(Deserialize)]
struct PyProject {
name: String,
version: String,
}
pub async fn get_package_info(repo_path: &Path) -> Option<PackageInfo> {
let pyproject_path = repo_path.join("pyproject.toml");
if pyproject_path.exists() {
if let Ok(content) = tokio::fs::read_to_string(&pyproject_path).await {
if let Ok(pyproject) = toml::from_str::<PyProjectToml>(&content) {
if let Some(project) = pyproject.project {
return Some(PackageInfo {
manager: super::PackageManager::PyPI,
name: project.name,
version: project.version,
});
}
}
}
}
let setup_py_path = repo_path.join("setup.py");
if setup_py_path.exists() {
if let Ok(output) = Command::new("python")
.args(["-c", "import setuptools; print('OK')"])
.current_dir(repo_path)
.output()
.await
{
if output.status.success() {
return Some(PackageInfo {
manager: super::PackageManager::PyPI,
name: "unknown".to_string(),
version: "unknown".to_string(),
});
}
}
}
None
}
pub async fn publish(repo_path: &Path, dry_run: bool) -> (bool, String) {
let twine_check = Command::new("twine").arg("--version").output().await;
if twine_check.is_err() {
return (
false,
"twine not installed (run: pip install twine)".to_string(),
);
}
let build_result = build_package(repo_path).await;
if let Err(e) = build_result {
return (false, format!("build failed: {}", e));
}
let mut args = vec!["upload"];
let dist_path = repo_path.join("dist");
if !dist_path.exists() {
return (false, "dist directory not found after build".to_string());
}
args.push("dist/*");
if dry_run {
args.clear();
args.push("check");
args.push("dist/*");
}
let timeout_duration = Duration::from_secs(PYTHON_OPERATION_TIMEOUT_SECS);
let result = tokio::time::timeout(
timeout_duration,
Command::new("twine")
.args(&args)
.current_dir(repo_path)
.output(),
)
.await;
match result {
Ok(Ok(output)) => {
if output.status.success() {
if dry_run {
(true, "dry-run ok".to_string())
} else {
(true, "published".to_string())
}
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let combined = format!("{}{}", stdout, stderr);
let error_message = clean_python_error(&combined);
if combined.contains("File already exists") || combined.contains("already exists") {
(true, "already published".to_string())
} else {
(false, error_message)
}
}
}
Ok(Err(e)) => (false, format!("twine command failed: {}", e)),
Err(_) => (false, "python operation timed out".to_string()),
}
}
async fn build_package(repo_path: &Path) -> Result<()> {
let build_result = Command::new("python")
.args(["-m", "build"])
.current_dir(repo_path)
.output()
.await;
if let Ok(output) = build_result {
if output.status.success() {
return Ok(());
}
}
let setup_result = Command::new("python")
.args(["setup.py", "sdist", "bdist_wheel"])
.current_dir(repo_path)
.output()
.await?;
if setup_result.status.success() {
Ok(())
} else {
Err(anyhow::anyhow!("Failed to build package"))
}
}
fn clean_python_error(error: &str) -> String {
if error.contains("File already exists") {
"already published".to_string()
} else if error.contains("Invalid or non-existent authentication") {
"not authenticated (configure ~/.pypirc)".to_string()
} else if error.contains("403") {
"permission denied (check PyPI permissions)".to_string()
} else {
error
.lines()
.find(|line| !line.trim().is_empty() && !line.contains("Uploading"))
.map(|line| line.trim().to_string())
.unwrap_or_else(|| error.trim().to_string())
}
}