Skip to main content

pro_core/
path_dep.rs

1//! Local path dependencies support
2//!
3//! Path dependencies allow referencing local Python packages:
4//!
5//! ```toml
6//! [tool.rx.dependencies]
7//! my-lib = { path = "../my-lib" }
8//! my-utils = { path = "./packages/utils", editable = true }
9//! ```
10
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13
14use serde::{Deserialize, Serialize};
15
16use crate::pep::PyProject;
17use crate::{Error, Result};
18
19/// A local path dependency
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct PathDependency {
22    /// Package name
23    pub name: String,
24    /// Path to the package (relative to project root or absolute)
25    pub path: PathBuf,
26    /// Whether to install as editable (default: true)
27    #[serde(default = "default_editable")]
28    pub editable: bool,
29    /// Optional extras to install
30    #[serde(default)]
31    pub extras: Vec<String>,
32}
33
34fn default_editable() -> bool {
35    true
36}
37
38impl PathDependency {
39    /// Create a new path dependency
40    pub fn new(name: impl Into<String>, path: impl Into<PathBuf>) -> Self {
41        Self {
42            name: name.into(),
43            path: path.into(),
44            editable: true,
45            extras: Vec::new(),
46        }
47    }
48
49    /// Set editable mode
50    pub fn with_editable(mut self, editable: bool) -> Self {
51        self.editable = editable;
52        self
53    }
54
55    /// Add extras
56    pub fn with_extras(mut self, extras: Vec<String>) -> Self {
57        self.extras = extras;
58        self
59    }
60
61    /// Resolve the path relative to a base directory
62    pub fn resolve_path(&self, base_dir: &Path) -> PathBuf {
63        if self.path.is_absolute() {
64            self.path.clone()
65        } else {
66            base_dir.join(&self.path)
67        }
68    }
69
70    /// Check if the path dependency is valid (path exists and has pyproject.toml)
71    pub fn validate(&self, base_dir: &Path) -> Result<()> {
72        let resolved = self.resolve_path(base_dir);
73
74        if !resolved.exists() {
75            return Err(Error::Config(format!(
76                "Path dependency '{}' not found: {}",
77                self.name,
78                resolved.display()
79            )));
80        }
81
82        let pyproject_path = resolved.join("pyproject.toml");
83        if !pyproject_path.exists() {
84            return Err(Error::Config(format!(
85                "Path dependency '{}' has no pyproject.toml: {}",
86                self.name,
87                resolved.display()
88            )));
89        }
90
91        Ok(())
92    }
93
94    /// Get the package version from pyproject.toml
95    pub fn get_version(&self, base_dir: &Path) -> Result<Option<String>> {
96        let resolved = self.resolve_path(base_dir);
97        let pyproject = PyProject::load(&resolved)?;
98        Ok(pyproject.version().map(String::from))
99    }
100
101    /// Get transitive dependencies from the path dependency
102    pub fn get_dependencies(&self, base_dir: &Path) -> Result<Vec<String>> {
103        let resolved = self.resolve_path(base_dir);
104        let pyproject = PyProject::load(&resolved)?;
105        Ok(pyproject.dependencies().to_vec())
106    }
107}
108
109/// Load path dependencies from pyproject.toml [tool.rx.dependencies]
110pub fn load_path_dependencies(project_dir: &Path) -> Result<HashMap<String, PathDependency>> {
111    let pyproject = PyProject::load(project_dir)?;
112
113    let mut path_deps = HashMap::new();
114
115    let rx_config = match pyproject.tool.get("rx") {
116        Some(c) => c,
117        None => return Ok(path_deps),
118    };
119
120    let deps_config = match rx_config.get("dependencies") {
121        Some(c) => c,
122        None => return Ok(path_deps),
123    };
124
125    let deps_table = match deps_config.as_table() {
126        Some(t) => t,
127        None => return Ok(path_deps),
128    };
129
130    for (name, value) in deps_table {
131        // Only process table entries with a "path" key
132        if let Some(table) = value.as_table() {
133            if let Some(path_value) = table.get("path") {
134                if let Some(path_str) = path_value.as_str() {
135                    let editable = table
136                        .get("editable")
137                        .and_then(|v| v.as_bool())
138                        .unwrap_or(true);
139
140                    let extras: Vec<String> = table
141                        .get("extras")
142                        .and_then(|v| v.as_array())
143                        .map(|arr| {
144                            arr.iter()
145                                .filter_map(|v| v.as_str().map(String::from))
146                                .collect()
147                        })
148                        .unwrap_or_default();
149
150                    let dep = PathDependency {
151                        name: name.clone(),
152                        path: PathBuf::from(path_str),
153                        editable,
154                        extras,
155                    };
156
157                    path_deps.insert(name.clone(), dep);
158                }
159            }
160        }
161    }
162
163    Ok(path_deps)
164}
165
166/// Install a path dependency
167pub async fn install_path_dependency(
168    dep: &PathDependency,
169    base_dir: &Path,
170    site_packages: &Path,
171) -> Result<()> {
172    let resolved_path = dep.resolve_path(base_dir);
173
174    // Validate the dependency
175    dep.validate(base_dir)?;
176
177    if dep.editable {
178        // Editable install: create .pth file pointing to the source
179        install_editable(&dep.name, &resolved_path, site_packages)?;
180    } else {
181        // Regular install: copy the package to site-packages
182        install_copy(&dep.name, &resolved_path, site_packages)?;
183    }
184
185    Ok(())
186}
187
188/// Install as editable (create .pth file)
189fn install_editable(name: &str, source_path: &Path, site_packages: &Path) -> Result<()> {
190    // Find the package directory (either src/<name> or <name>)
191    let package_dir = find_package_dir(name, source_path)?;
192
193    // Create .pth file
194    let pth_filename = format!("{}.pth", name.replace('-', "_"));
195    let pth_path = site_packages.join(pth_filename);
196
197    // The .pth file should contain the parent of the package directory
198    let pth_content = package_dir
199        .parent()
200        .unwrap_or(&package_dir)
201        .to_string_lossy()
202        .to_string();
203
204    std::fs::write(&pth_path, pth_content).map_err(Error::Io)?;
205
206    // Also create egg-link for compatibility
207    let egg_link_path = site_packages.join(format!("{}.egg-link", name.replace('-', "_")));
208    let egg_link_content = format!(
209        "{}\n.",
210        package_dir.parent().unwrap_or(&package_dir).display()
211    );
212    std::fs::write(&egg_link_path, egg_link_content).map_err(Error::Io)?;
213
214    tracing::info!(
215        "Installed {} (editable) from {}",
216        name,
217        source_path.display()
218    );
219
220    Ok(())
221}
222
223/// Install by copying the package
224fn install_copy(name: &str, source_path: &Path, site_packages: &Path) -> Result<()> {
225    let package_dir = find_package_dir(name, source_path)?;
226    let package_name = package_dir
227        .file_name()
228        .ok_or_else(|| Error::Config("Invalid package directory".to_string()))?;
229
230    let dest_dir = site_packages.join(package_name);
231
232    // Remove existing if present
233    if dest_dir.exists() {
234        std::fs::remove_dir_all(&dest_dir).map_err(Error::Io)?;
235    }
236
237    // Copy the package
238    copy_dir_recursive(&package_dir, &dest_dir)?;
239
240    tracing::info!("Installed {} (copied) from {}", name, source_path.display());
241
242    Ok(())
243}
244
245/// Find the Python package directory within a project
246fn find_package_dir(name: &str, project_path: &Path) -> Result<PathBuf> {
247    let normalized_name = name.replace('-', "_");
248
249    // Try common layouts:
250    // 1. src/<name>/
251    let src_layout = project_path.join("src").join(&normalized_name);
252    if src_layout.exists() && src_layout.join("__init__.py").exists() {
253        return Ok(src_layout);
254    }
255
256    // 2. <name>/
257    let flat_layout = project_path.join(&normalized_name);
258    if flat_layout.exists() && flat_layout.join("__init__.py").exists() {
259        return Ok(flat_layout);
260    }
261
262    // 3. Check if project root itself is a package (single-file module)
263    let root_init = project_path.join("__init__.py");
264    if root_init.exists() {
265        return Ok(project_path.to_path_buf());
266    }
267
268    // 4. Look for any directory with __init__.py
269    if let Ok(entries) = std::fs::read_dir(project_path) {
270        for entry in entries.flatten() {
271            let path = entry.path();
272            if path.is_dir() && path.join("__init__.py").exists() {
273                return Ok(path);
274            }
275        }
276    }
277
278    // 5. Also check src/ directory
279    let src_dir = project_path.join("src");
280    if src_dir.exists() {
281        if let Ok(entries) = std::fs::read_dir(&src_dir) {
282            for entry in entries.flatten() {
283                let path = entry.path();
284                if path.is_dir() && path.join("__init__.py").exists() {
285                    return Ok(path);
286                }
287            }
288        }
289    }
290
291    Err(Error::Config(format!(
292        "Could not find Python package in {}. Expected src/{}/ or {}/",
293        project_path.display(),
294        normalized_name,
295        normalized_name
296    )))
297}
298
299/// Recursively copy a directory
300fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
301    std::fs::create_dir_all(dst).map_err(Error::Io)?;
302
303    for entry in std::fs::read_dir(src).map_err(Error::Io)? {
304        let entry = entry.map_err(Error::Io)?;
305        let src_path = entry.path();
306        let dst_path = dst.join(entry.file_name());
307
308        if src_path.is_dir() {
309            // Skip common non-package directories
310            let name = entry.file_name();
311            let name_str = name.to_string_lossy();
312            if name_str == "__pycache__"
313                || name_str == ".git"
314                || name_str == ".venv"
315                || name_str == "venv"
316                || name_str.ends_with(".egg-info")
317            {
318                continue;
319            }
320            copy_dir_recursive(&src_path, &dst_path)?;
321        } else {
322            // Skip .pyc files
323            if src_path.extension().is_some_and(|e| e == "pyc") {
324                continue;
325            }
326            std::fs::copy(&src_path, &dst_path).map_err(Error::Io)?;
327        }
328    }
329
330    Ok(())
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use tempfile::TempDir;
337
338    #[test]
339    fn test_path_dependency_resolve() {
340        let dep = PathDependency::new("my-lib", "../my-lib");
341        let base = PathBuf::from("/workspace/app");
342        let resolved = dep.resolve_path(&base);
343        assert_eq!(resolved, PathBuf::from("/workspace/app/../my-lib"));
344    }
345
346    #[test]
347    fn test_path_dependency_absolute() {
348        let dep = PathDependency::new("my-lib", "/absolute/path/my-lib");
349        let base = PathBuf::from("/workspace/app");
350        let resolved = dep.resolve_path(&base);
351        assert_eq!(resolved, PathBuf::from("/absolute/path/my-lib"));
352    }
353
354    #[test]
355    fn test_find_package_dir_src_layout() {
356        let temp = TempDir::new().unwrap();
357        let project = temp.path();
358
359        // Create src/my_lib/__init__.py
360        let pkg_dir = project.join("src").join("my_lib");
361        std::fs::create_dir_all(&pkg_dir).unwrap();
362        std::fs::write(pkg_dir.join("__init__.py"), "").unwrap();
363
364        let found = find_package_dir("my-lib", project).unwrap();
365        assert_eq!(found, pkg_dir);
366    }
367
368    #[test]
369    fn test_find_package_dir_flat_layout() {
370        let temp = TempDir::new().unwrap();
371        let project = temp.path();
372
373        // Create my_lib/__init__.py
374        let pkg_dir = project.join("my_lib");
375        std::fs::create_dir_all(&pkg_dir).unwrap();
376        std::fs::write(pkg_dir.join("__init__.py"), "").unwrap();
377
378        let found = find_package_dir("my-lib", project).unwrap();
379        assert_eq!(found, pkg_dir);
380    }
381}