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#[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 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 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 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 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 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 fn extract_version_from_source(
598 &self,
599 root: &Path,
600 pkg_path: &Path,
601 package_name: &str,
602 ) -> Result<String, ResolveError> {
603 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 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 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 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 if trimmed.contains("version(")
695 || trimmed.contains("get_version()")
696 || trimmed.contains("importlib")
697 || trimmed.contains("pkg_resources")
698 {
699 continue;
700 }
701
702 if let Some(pos) = trimmed.find('=') {
704 let value_part = trimmed[pos + 1..].trim();
705
706 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 if root.join("pyproject.toml").exists() || root.join("setup.cfg").exists() {
834 packages.push(self.parse_package(root, &Self::package_config("."))?);
835 }
836
837 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}