Skip to main content

semifold_resolver/resolver/
python.rs

1use std::{collections::BTreeMap, path::Path};
2
3use semifold_core::{
4    DependencyKind, EcosystemId, EditSource, FileEdit, FileEditExpectation, FileHash, PackageId,
5    PackageSnapshot, VersionMap, VersionSource,
6};
7use semver::{Prerelease, Version};
8use serde::{Deserialize, Serialize};
9
10use crate::{
11    adapter::{
12        AdapterError, EcosystemAdapter, EcosystemPlanInput, ManifestDependency, PackageInspection,
13        PackageLocation, ParsedPackage,
14    },
15    config::{PackageConfig, ReleaseChannel},
16    error::ResolveError,
17};
18
19#[derive(Serialize, Deserialize, Debug)]
20struct PyProjectToml {
21    pub project: Option<ProjectMetadata>,
22    pub tool: Option<ToolMetadata>,
23}
24
25#[derive(Serialize, Deserialize, Debug)]
26struct ProjectMetadata {
27    pub name: String,
28    pub version: Option<String>,
29    pub dynamic: Option<Vec<String>>,
30    pub dependencies: Option<Vec<String>>,
31}
32
33#[derive(Serialize, Deserialize, Debug)]
34struct ToolMetadata {
35    pub poetry: Option<PoetryMetadata>,
36    pub hatch: Option<HatchMetadata>,
37}
38
39#[derive(Serialize, Deserialize, Debug)]
40struct PoetryMetadata {
41    pub name: Option<String>,
42    pub version: Option<String>,
43    pub dependencies: Option<BTreeMap<String, serde_json::Value>>,
44}
45
46#[derive(Serialize, Deserialize, Debug)]
47struct HatchMetadata {
48    pub version: Option<HatchVersion>,
49}
50
51#[derive(Serialize, Deserialize, Debug)]
52struct HatchVersion {
53    pub path: Option<String>,
54}
55
56/// Cargo.toml 结构(用于 maturin/PyO3 项目)
57#[derive(Serialize, Deserialize, Debug)]
58struct CargoToml {
59    pub package: Option<CargoPackage>,
60}
61
62#[derive(Serialize, Deserialize, Debug)]
63struct CargoPackage {
64    pub name: Option<String>,
65    pub version: Option<String>,
66}
67
68pub struct PythonResolver;
69
70fn parse_python_version(value: &str) -> Result<Version, ResolveError> {
71    if let Ok(version) = Version::parse(value) {
72        return Ok(version);
73    }
74
75    let (base, channel, sequence) = if let Some((base, sequence)) = value.rsplit_once(".post") {
76        (base, "post", sequence)
77    } else if let Some((base, sequence)) = value.rsplit_once("rc") {
78        (base, "rc", sequence)
79    } else if let Some((base, sequence)) = value.rsplit_once('a') {
80        (base, "alpha", sequence)
81    } else if let Some((base, sequence)) = value.rsplit_once('b') {
82        (base, "beta", sequence)
83    } else {
84        return Err(ResolveError::InvalidVersion {
85            version: value.to_string(),
86            reason: "expected SemVer or a supported PEP 440 pre/post-release".to_string(),
87        });
88    };
89    if sequence.is_empty() || !sequence.bytes().all(|byte| byte.is_ascii_digit()) {
90        return Err(ResolveError::InvalidVersion {
91            version: value.to_string(),
92            reason: "PEP 440 release sequence must be a non-negative integer".to_string(),
93        });
94    }
95    let mut version = Version::parse(base).map_err(|error| ResolveError::InvalidVersion {
96        version: value.to_string(),
97        reason: format!("invalid PEP 440 release base: {error}"),
98    })?;
99    version.pre = Prerelease::new(&format!("{channel}.{sequence}"))?;
100    Ok(version)
101}
102
103fn encode_python_version(version: &Version) -> Result<String, AdapterError> {
104    if version.pre.is_empty() {
105        return Ok(version.to_string());
106    }
107    if !version.build.is_empty() {
108        return Err(AdapterError::InvalidVersion {
109            ecosystem: EcosystemId::PYTHON,
110            version: version.clone(),
111            reason: "PEP 440 named channels do not support SemVer build metadata".to_string(),
112        });
113    }
114    let Some((channel, sequence)) = version.pre.as_str().split_once('.') else {
115        return Err(AdapterError::InvalidVersion {
116            ecosystem: EcosystemId::PYTHON,
117            version: version.clone(),
118            reason: "named release channels require a numeric sequence".to_string(),
119        });
120    };
121    if sequence.is_empty()
122        || !sequence.bytes().all(|byte| byte.is_ascii_digit())
123        || sequence.contains('.')
124    {
125        return Err(AdapterError::InvalidVersion {
126            ecosystem: EcosystemId::PYTHON,
127            version: version.clone(),
128            reason: "named release channel sequences must be non-negative integers".to_string(),
129        });
130    }
131
132    let base = format!("{}.{}.{}", version.major, version.minor, version.patch);
133    let encoded = match channel {
134        "alpha" => format!("{base}a{sequence}"),
135        "beta" => format!("{base}b{sequence}"),
136        "rc" => format!("{base}rc{sequence}"),
137        "post" => format!("{base}.post{sequence}"),
138        _ => {
139            return Err(AdapterError::InvalidVersion {
140                ecosystem: EcosystemId::PYTHON,
141                version: version.clone(),
142                reason: format!("unsupported Python release channel {channel}"),
143            });
144        }
145    };
146    Ok(encoded)
147}
148
149impl PythonResolver {
150    fn package_config(path: impl Into<std::path::PathBuf>) -> PackageConfig {
151        PackageConfig {
152            path: path.into(),
153            resolver: EcosystemId::PYTHON,
154            publish: None,
155            channel: ReleaseChannel::Stable,
156            channel_bump: None,
157            assets: Vec::new(),
158            github_release: None,
159            depends_on: vec![],
160        }
161    }
162
163    fn package_inspection(
164        id: PackageId,
165        package: ParsedPackage,
166        dependencies: Vec<ManifestDependency>,
167    ) -> Result<PackageInspection, AdapterError> {
168        let path = camino::Utf8PathBuf::from_path_buf(package.path).map_err(|path| {
169            AdapterError::InvalidInput {
170                reason: format!("Python package path is not valid UTF-8: {}", path.display()),
171            }
172        })?;
173        Ok(PackageInspection {
174            id,
175            manifest_name: package.name,
176            version: package.version,
177            version_source: package.version_source,
178            ecosystem: EcosystemId::PYTHON,
179            path,
180            publishable: !package.private,
181            dependencies,
182        })
183    }
184
185    pub fn plan_file_edits(
186        root: &Path,
187        package: &PackageSnapshot,
188        versions: &VersionMap,
189    ) -> Result<Vec<FileEdit>, ResolveError> {
190        let next_version =
191            versions
192                .get(&package.id)
193                .ok_or_else(|| ResolveError::InvalidConfig {
194                    path: root.join(package.path.as_std_path()),
195                    reason: format!("missing planned version for {}", package.id),
196                })?;
197        let version = PythonResolver
198            .encode_version(next_version)
199            .map_err(|error| ResolveError::InvalidVersion {
200                version: next_version.to_string(),
201                reason: error.to_string(),
202            })?;
203        let mut edits = Vec::new();
204        let mut configured_version_path = None;
205
206        let pyproject_path = package.path.join("pyproject.toml");
207        let pyproject_absolute = root.join(pyproject_path.as_std_path());
208        if pyproject_absolute.exists() {
209            let original = std::fs::read_to_string(&pyproject_absolute)?;
210            let mut document = original
211                .parse::<toml_edit::DocumentMut>()
212                .map_err(|error| ResolveError::ParseError {
213                    path: pyproject_absolute.clone(),
214                    reason: error.to_string(),
215                })?;
216            if let Some(project) = document
217                .get_mut("project")
218                .and_then(|item| item.as_table_mut())
219            {
220                let version_is_dynamic = project
221                    .get("dynamic")
222                    .and_then(|item| item.as_array())
223                    .is_some_and(|fields| {
224                        fields.iter().any(|field| field.as_str() == Some("version"))
225                    });
226                if !version_is_dynamic {
227                    project.insert("version", toml_edit::value(&version));
228                }
229            }
230            configured_version_path = document
231                .get("tool")
232                .and_then(|item| item.as_table())
233                .and_then(|tool| tool.get("hatch"))
234                .and_then(|item| item.as_table())
235                .and_then(|hatch| hatch.get("version"))
236                .and_then(|item| item.as_table())
237                .and_then(|version| version.get("path"))
238                .and_then(|item| item.as_str())
239                .map(str::to_string);
240            if let Some(poetry) = document
241                .get_mut("tool")
242                .and_then(|item| item.as_table_mut())
243                .and_then(|tool| tool.get_mut("poetry"))
244                .and_then(|item| item.as_table_mut())
245            {
246                poetry.insert("version", toml_edit::value(&version));
247            }
248            let new_content = document.to_string();
249            if new_content != original {
250                edits.push(Self::file_edit(
251                    package,
252                    pyproject_path.as_str(),
253                    original,
254                    new_content,
255                ));
256            }
257        }
258
259        let setup_path = package.path.join("setup.cfg");
260        let setup_absolute = root.join(setup_path.as_std_path());
261        if setup_absolute.exists() {
262            let original = std::fs::read_to_string(&setup_absolute)?;
263            let updated = original
264                .lines()
265                .map(|line| {
266                    let is_version = line
267                        .split_once('=')
268                        .is_some_and(|(key, _)| key.trim() == "version");
269                    if is_version {
270                        format!("version = {version}")
271                    } else {
272                        line.to_string()
273                    }
274                })
275                .collect::<Vec<_>>()
276                .join("\n");
277            if updated != original {
278                edits.push(Self::file_edit(
279                    package,
280                    setup_path.as_str(),
281                    original,
282                    updated,
283                ));
284            }
285        }
286
287        let package_name = package.manifest_name.replace('-', "_");
288        let mut version_paths = configured_version_path
289            .map(|path| vec![package.path.join(path)])
290            .unwrap_or_default();
291        version_paths.extend([
292            package.path.join(&package_name).join("__init__.py"),
293            package
294                .path
295                .join("src")
296                .join(&package_name)
297                .join("__init__.py"),
298            package.path.join(&package_name).join("__version__.py"),
299            package
300                .path
301                .join("src")
302                .join(&package_name)
303                .join("__version__.py"),
304            package.path.join("src").join("__init__.py"),
305        ]);
306        version_paths.dedup();
307        for relative in version_paths {
308            let absolute = root.join(relative.as_std_path());
309            if !absolute.exists() {
310                continue;
311            }
312            let original = std::fs::read_to_string(&absolute)?;
313            if let Some(updated) = Self::static_version_content(&original, &version) {
314                edits.push(Self::file_edit(
315                    package,
316                    relative.as_str(),
317                    original,
318                    updated,
319                ));
320                break;
321            }
322        }
323
324        Ok(edits)
325    }
326
327    fn file_edit(
328        package: &PackageSnapshot,
329        path: &str,
330        original: String,
331        new_content: String,
332    ) -> FileEdit {
333        FileEdit {
334            path: path.into(),
335            expected: FileEditExpectation::Existing {
336                hash: FileHash::from_bytes(original.as_bytes()),
337            },
338            new_content,
339            source: EditSource::PackageVersion {
340                package: package.id.clone(),
341            },
342        }
343    }
344
345    fn static_version_content(content: &str, version: &str) -> Option<String> {
346        let mut output = String::new();
347        let mut updated = false;
348        for line in content.lines() {
349            let trimmed = line.trim();
350            let dynamic = trimmed.contains("version(")
351                || trimmed.contains("get_version()")
352                || trimmed.contains("importlib")
353                || trimmed.contains("pkg_resources");
354            let static_assignment = trimmed
355                .strip_prefix("__version__")
356                .and_then(|rest| rest.find('=').map(|position| rest[position + 1..].trim()))
357                .is_some_and(|value| {
358                    (value.starts_with('"') && value.ends_with('"'))
359                        || (value.starts_with('\'') && value.ends_with('\''))
360                });
361            if !dynamic && static_assignment {
362                output.push_str(&format!("__version__ = \"{version}\"\n"));
363                updated = true;
364            } else {
365                output.push_str(line);
366                output.push('\n');
367            }
368        }
369        updated.then_some(output)
370    }
371
372    fn pep_dependency(specification: String) -> ManifestDependency {
373        let boundary = specification
374            .char_indices()
375            .find_map(|(index, character)| {
376                matches!(character, '<' | '>' | '=' | '~' | '!' | ';' | '[' | '@')
377                    .then_some(index)
378                    .or_else(|| character.is_whitespace().then_some(index))
379            })
380            .unwrap_or(specification.len());
381        let manifest_name = specification[..boundary].trim().to_string();
382        let requirement = specification[boundary..].trim();
383        let requirement = (!requirement.is_empty()).then(|| requirement.to_string());
384        ManifestDependency {
385            manifest_name,
386            kind: DependencyKind::Runtime,
387            requirement,
388        }
389    }
390
391    fn manifest_dependencies(
392        &self,
393        root: &Path,
394        pkg_path: &Path,
395    ) -> Result<Vec<ManifestDependency>, ResolveError> {
396        let pyproject_path = root.join(pkg_path).join("pyproject.toml");
397        if !pyproject_path.exists() {
398            return Ok(vec![]);
399        }
400
401        let pyproject: PyProjectToml =
402            toml_edit::de::from_str(&std::fs::read_to_string(&pyproject_path)?).map_err(|e| {
403                ResolveError::ParseError {
404                    path: pyproject_path,
405                    reason: e.to_string(),
406                }
407            })?;
408        let mut dependencies = Vec::new();
409        if let Some(project) = pyproject.project {
410            dependencies.extend(
411                project
412                    .dependencies
413                    .unwrap_or_default()
414                    .into_iter()
415                    .map(Self::pep_dependency),
416            );
417        }
418        if let Some(poetry) = pyproject.tool.and_then(|tool| tool.poetry) {
419            dependencies.extend(
420                poetry
421                    .dependencies
422                    .unwrap_or_default()
423                    .into_iter()
424                    .filter(|(name, _)| name != "python")
425                    .map(|(manifest_name, requirement)| ManifestDependency {
426                        manifest_name,
427                        kind: DependencyKind::Runtime,
428                        requirement: requirement
429                            .as_str()
430                            .map(str::to_string)
431                            .or_else(|| Some(requirement.to_string())),
432                    }),
433            );
434        }
435        Ok(dependencies)
436    }
437
438    fn resolve_pyproject(
439        &self,
440        root: &Path,
441        pkg_path: &Path,
442    ) -> Result<ParsedPackage, ResolveError> {
443        let pyproject_path = root.join(pkg_path).join("pyproject.toml");
444        if !pyproject_path.exists() {
445            return Err(ResolveError::FileOrDirNotFound {
446                path: pyproject_path.clone(),
447            });
448        }
449
450        let pyproject_str = std::fs::read_to_string(&pyproject_path)?;
451        let pyproject: PyProjectToml =
452            toml_edit::de::from_str(&pyproject_str).map_err(|e| ResolveError::ParseError {
453                path: pyproject_path.clone(),
454                reason: e.to_string(),
455            })?;
456
457        let (name, version) = if let Some(project) = pyproject.project {
458            // PEP 621 标准格式
459            let name = project.name.clone();
460
461            let is_version_dynamic = project
462                .dynamic
463                .as_ref()
464                .map(|d| d.iter().any(|field| field == "version"))
465                .unwrap_or(false);
466
467            let version = if is_version_dynamic {
468                // version 是动态的,尝试从其他地方获取
469                log::debug!(
470                    "Version is declared as dynamic in {}, attempting to extract from source files",
471                    pyproject_path.display()
472                );
473                self.extract_version_from_source(root, pkg_path, &name)
474                    .unwrap_or_else(|e| {
475                        log::warn!("Failed to extract dynamic version: {}, using default", e);
476                        "0.0.0".to_string()
477                    })
478            } else {
479                project.version.unwrap_or_else(|| "0.0.0".to_string())
480            };
481
482            (name, version)
483        } else if let Some(tool) = pyproject.tool {
484            if let Some(poetry) = tool.poetry {
485                // Poetry 格式
486                let name = poetry.name.ok_or(ResolveError::InvalidConfig {
487                    path: pyproject_path.clone(),
488                    reason: "Poetry project name not found".to_string(),
489                })?;
490                let version = poetry.version.unwrap_or_else(|| "0.0.0".to_string());
491                (name, version)
492            } else {
493                return Err(ResolveError::InvalidConfig {
494                    path: pyproject_path.clone(),
495                    reason: "No project metadata found in pyproject.toml".to_string(),
496                });
497            }
498        } else {
499            return Err(ResolveError::InvalidConfig {
500                path: pyproject_path.clone(),
501                reason: "No project metadata found in pyproject.toml".to_string(),
502            });
503        };
504
505        Ok(ParsedPackage {
506            name,
507            version: parse_python_version(&version)?,
508            version_source: VersionSource::PackageManifest,
509            path: pkg_path.to_path_buf(),
510            private: false,
511        })
512    }
513
514    /// 从 setup.cfg 解析元数据文件(fallback)
515    fn resolve_setup_cfg(
516        &self,
517        root: &Path,
518        pkg_path: &Path,
519    ) -> Result<ParsedPackage, ResolveError> {
520        let setup_cfg_path = root.join(pkg_path).join("setup.cfg");
521        if !setup_cfg_path.exists() {
522            return Err(ResolveError::FileOrDirNotFound {
523                path: setup_cfg_path.clone(),
524            });
525        }
526
527        let setup_cfg_str = std::fs::read_to_string(&setup_cfg_path)?;
528        // ini parse
529        let mut name: Option<String> = None;
530        let mut version: Option<String> = None;
531        let mut in_metadata = false;
532
533        for line in setup_cfg_str.lines() {
534            let trimmed = line.trim();
535            if trimmed == "[metadata]" {
536                in_metadata = true;
537                continue;
538            }
539            if trimmed.starts_with('[') {
540                in_metadata = false;
541            }
542            if in_metadata {
543                if let Some(rest) = trimmed.strip_prefix("name") {
544                    if let Some(val) = rest.trim().strip_prefix('=') {
545                        name = Some(val.trim().to_string());
546                    }
547                } else if let Some(rest) = trimmed.strip_prefix("version")
548                    && let Some(val) = rest.trim().strip_prefix('=')
549                {
550                    version = Some(val.trim().to_string());
551                }
552            }
553        }
554
555        let name = name.ok_or(ResolveError::InvalidConfig {
556            path: setup_cfg_path.clone(),
557            reason: "Package name not found in setup.cfg".to_string(),
558        })?;
559        let version = version.unwrap_or_else(|| "0.0.0".to_string());
560
561        Ok(ParsedPackage {
562            name,
563            version: parse_python_version(&version)?,
564            version_source: VersionSource::PackageManifest,
565            path: pkg_path.to_path_buf(),
566            private: false,
567        })
568    }
569
570    fn resolve_package(&self, root: &Path, pkg_path: &Path) -> Result<ParsedPackage, ResolveError> {
571        let setup_cfg_exists = root.join(pkg_path).join("setup.cfg").exists();
572        if root.join(pkg_path).join("pyproject.toml").exists() {
573            match self.resolve_pyproject(root, pkg_path) {
574                Ok(package) => return Ok(package),
575                Err(ResolveError::InvalidConfig { .. }) if setup_cfg_exists => {}
576                Err(error) => return Err(error),
577            }
578        }
579        if setup_cfg_exists {
580            return self.resolve_setup_cfg(root, pkg_path);
581        }
582        Err(ResolveError::FileOrDirNotFound {
583            path: root.join(pkg_path),
584        })
585    }
586
587    /// 从源文件中提取动态版本号
588    /// 当 pyproject.toml 中声明 `dynamic = ["version"]` 时使用
589    ///
590    /// 尝试从以下位置提取版本号(按优先级):
591    /// 1. `<package>/__init__.py` 中的 `__version__`
592    /// 2. `src/<package>/__init__.py` 中的 `__version__`
593    /// 3. `<package>/__version__.py` 中的 `__version__`
594    /// 4. `src/<package>/__version__.py` 中的 `__version__`
595    /// 5. `Cargo.toml` 中的 version(用于 maturin/PyO3 项目)
596    /// 6. Hatch 配置中的 version.path
597    fn extract_version_from_source(
598        &self,
599        root: &Path,
600        pkg_path: &Path,
601        package_name: &str,
602    ) -> Result<String, ResolveError> {
603        // 尝试从常见位置提取 __version__
604        let version_file_paths = vec![
605            root.join(pkg_path).join(package_name).join("__init__.py"),
606            root.join(pkg_path)
607                .join("src")
608                .join(package_name)
609                .join("__init__.py"),
610            root.join(pkg_path)
611                .join(package_name)
612                .join("__version__.py"),
613            root.join(pkg_path)
614                .join("src")
615                .join(package_name)
616                .join("__version__.py"),
617        ];
618
619        for file_path in &version_file_paths {
620            if file_path.exists()
621                && let Ok(content) = std::fs::read_to_string(file_path)
622                && let Some(version) = self.extract_version_from_content(&content)
623            {
624                log::debug!(
625                    "Extracted version '{}' from {}",
626                    version,
627                    file_path.display()
628                );
629                return Ok(version);
630            }
631        }
632
633        // 尝试从 Cargo.toml 获取版本(用于 maturin/PyO3 项目)
634        let cargo_toml_path = root.join(pkg_path).join("Cargo.toml");
635        if cargo_toml_path.exists() {
636            log::debug!("Found Cargo.toml, attempting to extract version for maturin/PyO3 project");
637            if let Ok(cargo_str) = std::fs::read_to_string(&cargo_toml_path)
638                && let Ok(cargo_toml) = toml_edit::de::from_str::<CargoToml>(&cargo_str)
639                && let Some(version) = cargo_toml.package.and_then(|p| p.version)
640            {
641                log::debug!(
642                    "Extracted version '{}' from Cargo.toml for maturin/PyO3 project",
643                    version
644                );
645                return Ok(version);
646            }
647        }
648
649        // 尝试从 Hatch 配置中获取 version.path
650        let pyproject_path = root.join(pkg_path).join("pyproject.toml");
651        if pyproject_path.exists()
652            && let Ok(pyproject_str) = std::fs::read_to_string(&pyproject_path)
653            && let Ok(pyproject) = toml_edit::de::from_str::<PyProjectToml>(&pyproject_str)
654            && let Some(tool) = pyproject.tool
655            && let Some(hatch) = tool.hatch
656            && let Some(version_config) = hatch.version
657            && let Some(version_path) = version_config.path
658        {
659            let hatch_version_file = root.join(pkg_path).join(version_path);
660            if hatch_version_file.exists()
661                && let Ok(content) = std::fs::read_to_string(&hatch_version_file)
662                && let Some(version) = self.extract_version_from_content(&content)
663            {
664                log::debug!(
665                    "Extracted version '{}' from Hatch version.path: {}",
666                    version,
667                    hatch_version_file.display()
668                );
669                return Ok(version);
670            }
671        }
672
673        Err(ResolveError::InvalidConfig {
674            path: root.join(pkg_path).to_path_buf(),
675            reason: format!(
676                "Could not extract version from source files for package '{}'. \
677                 Version is declared as dynamic but no __version__ found in common locations \
678                 (checked: __init__.py, __version__.py, Cargo.toml, Hatch version.path).",
679                package_name
680            ),
681        })
682    }
683
684    /// 从文件内容中提取 __version__ 值
685    /// 支持的格式:
686    /// - `__version__ = "1.0.0"`
687    /// - `__version__ = '1.0.0'`
688    /// - `__version__: str = "1.0.0"`
689    fn extract_version_from_content(&self, content: &str) -> Option<String> {
690        for line in content.lines() {
691            let trimmed = line.trim();
692            if trimmed.starts_with("__version__") {
693                // 排除动态获取的情况
694                if trimmed.contains("version(")
695                    || trimmed.contains("get_version()")
696                    || trimmed.contains("importlib")
697                    || trimmed.contains("pkg_resources")
698                {
699                    continue;
700                }
701
702                // 提取静态版本号
703                if let Some(pos) = trimmed.find('=') {
704                    let value_part = trimmed[pos + 1..].trim();
705
706                    // 处理单引号或双引号
707                    if let Some(version) = value_part
708                        .strip_prefix('"')
709                        .and_then(|s| s.strip_suffix('"'))
710                    {
711                        return Some(version.to_string());
712                    }
713                    if let Some(version) = value_part
714                        .strip_prefix('\'')
715                        .and_then(|s| s.strip_suffix('\''))
716                    {
717                        return Some(version.to_string());
718                    }
719                }
720            }
721        }
722        None
723    }
724}
725
726impl EcosystemAdapter for PythonResolver {
727    fn ecosystem(&self) -> EcosystemId {
728        EcosystemId::PYTHON
729    }
730
731    fn encode_version(&self, version: &Version) -> Result<String, AdapterError> {
732        encode_python_version(version)
733    }
734
735    fn discover(&self, root: &camino::Utf8Path) -> Result<Vec<PackageInspection>, AdapterError> {
736        let packages = self.discover_packages(root.as_std_path())?;
737        let mut inspections = packages
738            .into_iter()
739            .map(|package| {
740                let dependencies = self.manifest_dependencies(root.as_std_path(), &package.path)?;
741                Self::package_inspection(
742                    PackageId::new(package.name.clone()),
743                    package,
744                    dependencies,
745                )
746            })
747            .collect::<Result<Vec<_>, AdapterError>>()?;
748        inspections.sort_by(|left, right| {
749            left.id
750                .cmp(&right.id)
751                .then_with(|| left.path.cmp(&right.path))
752        });
753        Ok(inspections)
754    }
755
756    fn inspect(&self, location: &PackageLocation) -> Result<PackageInspection, AdapterError> {
757        if location.path.is_absolute()
758            || location
759                .path
760                .components()
761                .any(|component| component == camino::Utf8Component::ParentDir)
762        {
763            return Err(AdapterError::InvalidInput {
764                reason: format!(
765                    "Python package path must be relative to the project root: {}",
766                    location.path
767                ),
768            });
769        }
770        let package = self.resolve_package(
771            location.project_root.as_std_path(),
772            location.path.as_std_path(),
773        )?;
774        let dependencies = self.manifest_dependencies(
775            location.project_root.as_std_path(),
776            location.path.as_std_path(),
777        )?;
778        Self::package_inspection(location.id.clone(), package, dependencies)
779    }
780
781    fn plan_edits(&self, input: EcosystemPlanInput<'_>) -> Result<Vec<FileEdit>, AdapterError> {
782        if input
783            .workspace_packages
784            .iter()
785            .any(|package| package.ecosystem != EcosystemId::PYTHON)
786        {
787            return Err(AdapterError::InvalidInput {
788                reason: "Python edit planning received a non-Python workspace package".to_string(),
789            });
790        }
791        let workspace_packages = input
792            .workspace_packages
793            .iter()
794            .map(|package| (package.id.clone(), package))
795            .collect::<BTreeMap<_, _>>();
796        let released_packages = input
797            .released_packages
798            .iter()
799            .collect::<std::collections::BTreeSet<_>>();
800
801        released_packages
802            .into_iter()
803            .map(|id| {
804                let package = workspace_packages.get(id).copied().ok_or_else(|| {
805                    AdapterError::InvalidInput {
806                        reason: format!("released Python package {id} is not in the workspace"),
807                    }
808                })?;
809                Ok(Self::plan_file_edits(
810                    input.project_root.as_std_path(),
811                    package,
812                    input.versions,
813                )?)
814            })
815            .collect::<Result<Vec<_>, AdapterError>>()
816            .map(|edits| edits.into_iter().flatten().collect())
817    }
818}
819
820impl PythonResolver {
821    fn parse_package(
822        &self,
823        root: &Path,
824        pkg_config: &PackageConfig,
825    ) -> Result<ParsedPackage, ResolveError> {
826        self.resolve_package(root, &pkg_config.path)
827    }
828
829    fn discover_packages(&self, root: &Path) -> Result<Vec<ParsedPackage>, ResolveError> {
830        let mut packages = Vec::new();
831
832        // 检查是否是单包项目
833        if root.join("pyproject.toml").exists() || root.join("setup.cfg").exists() {
834            packages.push(self.parse_package(root, &Self::package_config("."))?);
835        }
836
837        // 检查常见的 monorepo 结构
838        let common_patterns = vec!["packages/*", "libs/*", "apps/*"];
839
840        for pattern in common_patterns {
841            let glob_pattern = root.join(pattern).display().to_string();
842            let paths = glob::glob(&glob_pattern)?
843                .map(|path| {
844                    path.map_err(|error| ResolveError::ParseError {
845                        path: error.path().to_path_buf(),
846                        reason: error.to_string(),
847                    })
848                })
849                .collect::<Result<Vec<_>, _>>()?;
850            for path in paths {
851                if path.join("pyproject.toml").exists() || path.join("setup.cfg").exists() {
852                    let rel_path = pathdiff::diff_paths(&path, root).unwrap_or(path.clone());
853                    packages.push(self.parse_package(root, &Self::package_config(rel_path))?);
854                }
855            }
856        }
857
858        Ok(packages)
859    }
860}
861
862#[cfg(test)]
863mod tests {
864    use std::{
865        fs,
866        path::{Path, PathBuf},
867        time::{SystemTime, UNIX_EPOCH},
868    };
869
870    use crate::{
871        adapter::{EcosystemAdapter, EcosystemPlanInput, PackageLocation},
872        config::{PackageConfig, ReleaseChannel},
873        error::ResolveError,
874        resolver::ResolverType,
875    };
876    use semifold_core::{EcosystemId, PackageId, PackageSnapshot, VersionMap, VersionSource};
877
878    use super::{PythonResolver, parse_python_version};
879
880    fn temp_dir(test_name: &str) -> PathBuf {
881        let nonce = SystemTime::now()
882            .duration_since(UNIX_EPOCH)
883            .unwrap()
884            .as_nanos();
885        let path = std::env::temp_dir().join(format!(
886            "semifold-python-resolver-{test_name}-{}-{nonce}",
887            std::process::id()
888        ));
889        fs::create_dir_all(&path).unwrap();
890        path
891    }
892
893    fn package_config(path: impl Into<PathBuf>) -> PackageConfig {
894        PackageConfig {
895            path: path.into(),
896            resolver: ResolverType::Python.into(),
897            publish: None,
898            channel: ReleaseChannel::Stable,
899            channel_bump: None,
900            assets: vec![],
901            github_release: None,
902            depends_on: vec![],
903        }
904    }
905
906    fn write_pyproject(root: &Path, path: &str, content: &str) {
907        let package_root = root.join(path);
908        fs::create_dir_all(&package_root).unwrap();
909        fs::write(package_root.join("pyproject.toml"), content).unwrap();
910    }
911
912    #[test]
913    fn resolves_pep_621_project_metadata() {
914        let root = temp_dir("pep-621");
915        write_pyproject(
916            &root,
917            ".",
918            "[project]\nname = \"example\"\nversion = \"1.2.3\"\n",
919        );
920
921        let package = PythonResolver
922            .parse_package(&root, &package_config("."))
923            .unwrap();
924
925        assert_eq!(package.name, "example");
926        assert_eq!(package.version, semver::Version::parse("1.2.3").unwrap());
927        assert_eq!(package.path, PathBuf::from("."));
928        assert!(!package.private);
929        fs::remove_dir_all(root).unwrap();
930    }
931
932    #[test]
933    fn encodes_and_parses_supported_pep_440_release_channels() {
934        let cases = [
935            ("1.2.3-alpha.4", "1.2.3a4"),
936            ("1.2.3-beta.4", "1.2.3b4"),
937            ("1.2.3-rc.4", "1.2.3rc4"),
938            ("1.2.3-post.4", "1.2.3.post4"),
939        ];
940
941        for (domain, encoded) in cases {
942            let domain = semver::Version::parse(domain).unwrap();
943            assert_eq!(PythonResolver.encode_version(&domain).unwrap(), encoded);
944            assert_eq!(parse_python_version(encoded).unwrap(), domain);
945        }
946    }
947
948    #[test]
949    fn rejects_python_channels_without_a_pep_440_encoding() {
950        let version = semver::Version::parse("1.2.3-nightly.0").unwrap();
951
952        assert!(matches!(
953            PythonResolver.encode_version(&version),
954            Err(crate::adapter::AdapterError::InvalidVersion {
955                ecosystem,
956                ..
957            }) if ecosystem == EcosystemId::PYTHON
958        ));
959    }
960
961    #[test]
962    fn plans_pep_440_post_release_versions() {
963        let root = temp_dir("pep-440-post-release-plan");
964        write_pyproject(
965            &root,
966            ".",
967            "[project]\nname = \"example\"\nversion = \"1.2.3\"\n",
968        );
969        let package = PackageSnapshot {
970            id: PackageId::new("example"),
971            manifest_name: "example".to_string(),
972            version: semver::Version::new(1, 2, 3),
973            version_source: VersionSource::PackageManifest,
974            ecosystem: EcosystemId::PYTHON,
975            path: ".".into(),
976            publishable: true,
977            dependencies: vec![],
978        };
979        let versions = VersionMap::from([(
980            package.id.clone(),
981            semver::Version::parse("1.2.3-post.0").unwrap(),
982        )]);
983
984        let edits = PythonResolver
985            .plan_edits(EcosystemPlanInput {
986                project_root: camino::Utf8Path::from_path(&root).unwrap(),
987                workspace_packages: std::slice::from_ref(&package),
988                released_packages: std::slice::from_ref(&package.id),
989                versions: &versions,
990            })
991            .unwrap();
992
993        assert!(
994            edits
995                .iter()
996                .any(|edit| edit.new_content.contains("version = \"1.2.3.post0\""))
997        );
998        fs::remove_dir_all(root).unwrap();
999    }
1000
1001    #[test]
1002    fn resolves_poetry_project_metadata() {
1003        let root = temp_dir("poetry");
1004        write_pyproject(
1005            &root,
1006            ".",
1007            "[tool.poetry]\nname = \"poetry-example\"\nversion = \"2.3.4\"\n",
1008        );
1009
1010        let package = PythonResolver
1011            .parse_package(&root, &package_config("."))
1012            .unwrap();
1013
1014        assert_eq!(package.name, "poetry-example");
1015        assert_eq!(package.version, semver::Version::parse("2.3.4").unwrap());
1016        fs::remove_dir_all(root).unwrap();
1017    }
1018
1019    #[test]
1020    fn resolves_hatch_dynamic_version_from_configured_source_file() {
1021        let root = temp_dir("hatch-dynamic-version");
1022        write_pyproject(
1023            &root,
1024            ".",
1025            "[project]\nname = \"hatch-example\"\ndynamic = [\"version\"]\n\n[tool.hatch.version]\npath = \"src/hatch_example/__init__.py\"\n",
1026        );
1027        let source_dir = root.join("src/hatch_example");
1028        fs::create_dir_all(&source_dir).unwrap();
1029        fs::write(
1030            source_dir.join("__init__.py"),
1031            "__version__: str = \"3.4.5\"\n",
1032        )
1033        .unwrap();
1034
1035        let package = PythonResolver
1036            .parse_package(&root, &package_config("."))
1037            .unwrap();
1038
1039        assert_eq!(package.name, "hatch-example");
1040        assert_eq!(package.version, semver::Version::parse("3.4.5").unwrap());
1041        fs::remove_dir_all(root).unwrap();
1042    }
1043
1044    #[test]
1045    fn falls_back_to_setup_cfg_metadata() {
1046        let root = temp_dir("setup-cfg");
1047        fs::write(
1048            root.join("pyproject.toml"),
1049            "[build-system]\nrequires = [\"setuptools\"]\n",
1050        )
1051        .unwrap();
1052        fs::write(
1053            root.join("setup.cfg"),
1054            "[metadata]\nname = cfg-example\nversion = 4.5.6\n\n[options]\npackages = find:\n",
1055        )
1056        .unwrap();
1057
1058        let package = PythonResolver
1059            .parse_package(&root, &package_config("."))
1060            .unwrap();
1061
1062        assert_eq!(package.name, "cfg-example");
1063        assert_eq!(package.version, semver::Version::parse("4.5.6").unwrap());
1064        fs::remove_dir_all(root).unwrap();
1065    }
1066
1067    #[test]
1068    fn discovers_root_and_common_monorepo_directories() {
1069        let root = temp_dir("monorepo");
1070        write_pyproject(
1071            &root,
1072            ".",
1073            "[project]\nname = \"root\"\nversion = \"1.0.0\"\n",
1074        );
1075        write_pyproject(
1076            &root,
1077            "packages/core",
1078            "[project]\nname = \"core\"\nversion = \"1.0.0\"\n",
1079        );
1080        write_pyproject(
1081            &root,
1082            "libs/helpers",
1083            "[project]\nname = \"helpers\"\nversion = \"1.0.0\"\n",
1084        );
1085        let app_dir = root.join("apps/cli");
1086        fs::create_dir_all(&app_dir).unwrap();
1087        fs::write(
1088            app_dir.join("setup.cfg"),
1089            "[metadata]\nname = cli\nversion = 1.0.0\n",
1090        )
1091        .unwrap();
1092
1093        let mut packages = PythonResolver.discover_packages(&root).unwrap();
1094        packages.sort_by(|left, right| left.name.cmp(&right.name));
1095
1096        assert_eq!(packages.len(), 4);
1097        assert_eq!(packages[0].name, "cli");
1098        assert_eq!(packages[0].path, PathBuf::from("apps/cli"));
1099        assert_eq!(packages[1].name, "core");
1100        assert_eq!(packages[1].path, PathBuf::from("packages/core"));
1101        assert_eq!(packages[2].name, "helpers");
1102        assert_eq!(packages[2].path, PathBuf::from("libs/helpers"));
1103        assert_eq!(packages[3].name, "root");
1104        assert_eq!(packages[3].path, PathBuf::from("."));
1105        fs::remove_dir_all(root).unwrap();
1106    }
1107
1108    #[test]
1109    fn adapter_discovers_and_inspects_manifest_dependencies_before_id_binding() {
1110        let root = temp_dir("adapter-inspection");
1111        write_pyproject(
1112            &root,
1113            ".",
1114            "[project]\nname = \"root\"\nversion = \"1.0.0\"\n",
1115        );
1116        write_pyproject(
1117            &root,
1118            "packages/core",
1119            "[project]\nname = \"core\"\nversion = \"1.0.0\"\n",
1120        );
1121        write_pyproject(
1122            &root,
1123            "packages/app",
1124            "[project]\nname = \"app\"\nversion = \"1.0.0\"\ndependencies = [\"core>=1\", \"requests>=2\"]\n",
1125        );
1126        let project_root = camino::Utf8PathBuf::from_path_buf(root.clone()).unwrap();
1127
1128        let discovered = PythonResolver.discover(&project_root).unwrap();
1129        assert_eq!(
1130            discovered
1131                .iter()
1132                .map(|package| package.id.as_str())
1133                .collect::<Vec<_>>(),
1134            ["app", "core", "root"]
1135        );
1136        let app = PythonResolver
1137            .inspect(&PackageLocation {
1138                id: PackageId::new("configured-app"),
1139                project_root,
1140                path: "packages/app".into(),
1141            })
1142            .unwrap();
1143
1144        assert_eq!(app.id, PackageId::new("configured-app"));
1145        assert_eq!(app.manifest_name, "app");
1146        assert_eq!(
1147            app.dependencies
1148                .iter()
1149                .map(|dependency| dependency.manifest_name.as_str())
1150                .collect::<Vec<_>>(),
1151            ["core", "requests"]
1152        );
1153        fs::remove_dir_all(root).unwrap();
1154    }
1155
1156    #[test]
1157    fn adapter_propagates_an_invalid_pyproject_instead_of_falling_back() {
1158        let root = temp_dir("invalid-pyproject");
1159        fs::write(root.join("pyproject.toml"), "[project\nname = \"broken\"\n").unwrap();
1160        let project_root = camino::Utf8PathBuf::from_path_buf(root.clone()).unwrap();
1161
1162        assert!(matches!(
1163            PythonResolver.inspect(&PackageLocation {
1164                id: PackageId::new("broken"),
1165                project_root,
1166                path: ".".into(),
1167            }),
1168            Err(crate::adapter::AdapterError::Manifest(
1169                ResolveError::ParseError { .. }
1170            ))
1171        ));
1172        fs::remove_dir_all(root).unwrap();
1173    }
1174
1175    #[test]
1176    fn parses_pep_621_and_poetry_internal_dependencies() {
1177        let root = temp_dir("dependencies");
1178        write_pyproject(
1179            &root,
1180            "packages/pep",
1181            "[project]\nname = \"pep\"\nversion = \"1.0.0\"\ndependencies = [\"core>=1.0.0\", \"helpers ~= 2.0\"]\n",
1182        );
1183        write_pyproject(
1184            &root,
1185            "packages/poetry",
1186            "[tool.poetry]\nname = \"poetry\"\nversion = \"1.0.0\"\n\n[tool.poetry.dependencies]\npython = \"^3.11\"\ncore = \"^1.0.0\"\nhelpers = { path = \"../helpers\" }\n",
1187        );
1188
1189        let resolver = PythonResolver;
1190        assert_eq!(
1191            resolver
1192                .manifest_dependencies(&root, Path::new("packages/pep"))
1193                .unwrap()
1194                .into_iter()
1195                .map(|dependency| dependency.manifest_name)
1196                .collect::<Vec<_>>(),
1197            vec!["core", "helpers"]
1198        );
1199        assert_eq!(
1200            resolver
1201                .manifest_dependencies(&root, Path::new("packages/poetry"))
1202                .unwrap()
1203                .into_iter()
1204                .map(|dependency| dependency.manifest_name)
1205                .collect::<Vec<_>>(),
1206            vec!["core", "helpers"]
1207        );
1208        fs::remove_dir_all(root).unwrap();
1209    }
1210
1211    #[test]
1212    fn plans_pyproject_and_static_init_version_without_writing() {
1213        let root = temp_dir("bump-pyproject");
1214        write_pyproject(
1215            &root,
1216            "packages/example",
1217            "[project]\nname = \"example\"\nversion = \"1.0.0\"\ndependencies = [\"requests>=2\"]\n\n[tool.custom]\npreserved = true\n",
1218        );
1219        let source_dir = root.join("packages/example/src/example");
1220        fs::create_dir_all(&source_dir).unwrap();
1221        fs::write(source_dir.join("__init__.py"), "__version__ = '1.0.0'\n").unwrap();
1222        let package = PackageSnapshot {
1223            id: PackageId::new("example"),
1224            manifest_name: "example".to_string(),
1225            version: semver::Version::new(1, 0, 0),
1226            version_source: VersionSource::PackageManifest,
1227            ecosystem: EcosystemId::PYTHON,
1228            path: "packages/example".into(),
1229            publishable: true,
1230            dependencies: vec![],
1231        };
1232
1233        let edits = PythonResolver::plan_file_edits(
1234            &root,
1235            &package,
1236            &VersionMap::from([(PackageId::new("example"), semver::Version::new(1, 1, 0))]),
1237        )
1238        .unwrap();
1239
1240        assert_eq!(edits.len(), 2);
1241        let pyproject = &edits
1242            .iter()
1243            .find(|edit| edit.path == "packages/example/pyproject.toml")
1244            .unwrap()
1245            .new_content;
1246        assert!(pyproject.contains("version = \"1.1.0\""));
1247        assert!(pyproject.contains("dependencies = [\"requests>=2\"]"));
1248        assert!(pyproject.contains("[tool.custom]"));
1249        assert!(pyproject.contains("preserved = true"));
1250        assert_eq!(
1251            edits
1252                .iter()
1253                .find(|edit| edit.path == "packages/example/src/example/__init__.py")
1254                .unwrap()
1255                .new_content,
1256            "__version__ = \"1.1.0\"\n"
1257        );
1258        assert!(
1259            fs::read_to_string(root.join("packages/example/pyproject.toml"))
1260                .unwrap()
1261                .contains("version = \"1.0.0\"")
1262        );
1263        fs::remove_dir_all(root).unwrap();
1264    }
1265
1266    #[test]
1267    fn plans_setup_cfg_version() {
1268        let root = temp_dir("bump-setup-cfg");
1269        fs::write(
1270            root.join("setup.cfg"),
1271            "[metadata]\nname = cfg-example\nversion = 1.0.0\nversion_file = VERSION\n\n[options]\npackages = find:\n",
1272        )
1273        .unwrap();
1274        let package = PackageSnapshot {
1275            id: PackageId::new("cfg-example"),
1276            manifest_name: "cfg-example".to_string(),
1277            version: semver::Version::new(1, 0, 0),
1278            version_source: VersionSource::PackageManifest,
1279            ecosystem: EcosystemId::PYTHON,
1280            path: ".".into(),
1281            publishable: true,
1282            dependencies: vec![],
1283        };
1284
1285        let edits = PythonResolver::plan_file_edits(
1286            &root,
1287            &package,
1288            &VersionMap::from([(PackageId::new("cfg-example"), semver::Version::new(1, 0, 1))]),
1289        )
1290        .unwrap();
1291
1292        assert_eq!(edits.len(), 1);
1293        let setup_cfg = &edits[0].new_content;
1294        assert!(setup_cfg.contains("name = cfg-example"));
1295        assert!(setup_cfg.contains("version = 1.0.1"));
1296        assert!(setup_cfg.contains("version_file = VERSION"));
1297        assert!(
1298            fs::read_to_string(root.join("setup.cfg"))
1299                .unwrap()
1300                .contains("version = 1.0.0")
1301        );
1302        fs::remove_dir_all(root).unwrap();
1303    }
1304
1305    #[test]
1306    fn plans_the_configured_hatch_version_source() {
1307        let root = temp_dir("hatch-version-source");
1308        write_pyproject(
1309            &root,
1310            ".",
1311            "[project]\nname = \"hatch-example\"\ndynamic = [\"version\"]\n\n[tool.hatch.version]\npath = \"src/hatch_example/version.py\"\n",
1312        );
1313        let source = root.join("src/hatch_example/version.py");
1314        fs::create_dir_all(source.parent().unwrap()).unwrap();
1315        fs::write(&source, "__version__ = \"1.0.0\"\n").unwrap();
1316        let package = PackageSnapshot {
1317            id: PackageId::new("hatch-example"),
1318            manifest_name: "hatch-example".to_string(),
1319            version: semver::Version::new(1, 0, 0),
1320            version_source: VersionSource::PackageManifest,
1321            ecosystem: EcosystemId::PYTHON,
1322            path: ".".into(),
1323            publishable: true,
1324            dependencies: vec![],
1325        };
1326
1327        let edits = PythonResolver::plan_file_edits(
1328            &root,
1329            &package,
1330            &VersionMap::from([(
1331                PackageId::new("hatch-example"),
1332                semver::Version::new(1, 0, 1),
1333            )]),
1334        )
1335        .unwrap();
1336
1337        assert_eq!(edits.len(), 1);
1338        assert_eq!(edits[0].path, "./src/hatch_example/version.py");
1339        assert_eq!(edits[0].new_content, "__version__ = \"1.0.1\"\n");
1340        assert_eq!(
1341            fs::read_to_string(source).unwrap(),
1342            "__version__ = \"1.0.0\"\n"
1343        );
1344        fs::remove_dir_all(root).unwrap();
1345    }
1346
1347    #[test]
1348    fn dynamic_python_version_never_plans_a_cargo_edit() {
1349        let root = temp_dir("dynamic-cargo-version");
1350        write_pyproject(
1351            &root,
1352            ".",
1353            "[project]\nname = \"native-example\"\ndynamic = [\"version\"]\n",
1354        );
1355        fs::write(
1356            root.join("Cargo.toml"),
1357            "[package]\nname = \"native-example\"\nversion = \"1.0.0\"\n",
1358        )
1359        .unwrap();
1360        let package = PackageSnapshot {
1361            id: PackageId::new("native-example"),
1362            manifest_name: "native-example".to_string(),
1363            version: semver::Version::new(1, 0, 0),
1364            version_source: VersionSource::PackageManifest,
1365            ecosystem: EcosystemId::PYTHON,
1366            path: ".".into(),
1367            publishable: true,
1368            dependencies: vec![],
1369        };
1370
1371        let versions = VersionMap::from([(
1372            PackageId::new("native-example"),
1373            semver::Version::new(1, 0, 1),
1374        )]);
1375        let edits = PythonResolver
1376            .plan_edits(EcosystemPlanInput {
1377                project_root: camino::Utf8Path::from_path(&root).unwrap(),
1378                workspace_packages: std::slice::from_ref(&package),
1379                released_packages: std::slice::from_ref(&package.id),
1380                versions: &versions,
1381            })
1382            .unwrap();
1383
1384        assert!(edits.is_empty());
1385        assert!(
1386            fs::read_to_string(root.join("Cargo.toml"))
1387                .unwrap()
1388                .contains("version = \"1.0.0\"")
1389        );
1390        fs::remove_dir_all(root).unwrap();
1391    }
1392}