Coverage for src/lpip/versions.py: 94%

68 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-17 20:19 +0000

1"""Map Git tags to PEP 440 versions (ADR-0016).""" 

2 

3from __future__ import annotations 

4 

5from collections.abc import Mapping, Sequence 

6from dataclasses import dataclass 

7 

8from packaging.version import InvalidVersion, Version 

9 

10from lpip.errors import VersionTagNotFoundError 

11from lpip.gitutils import git_ls_remote_tags 

12from lpip.models import PackageSource 

13 

14 

15@dataclass(frozen=True) 

16class VersionRef: 

17 version: Version 

18 tag: str 

19 

20 

21@dataclass(frozen=True) 

22class VersionsReport: 

23 package: str 

24 refs: tuple[VersionRef, ...] 

25 other_tags: tuple[str, ...] 

26 

27 

28def version_from_tag(tag: str) -> Version | None: 

29 """PEP 440 as-is, or after one leading ``v`` / ``V``.""" 

30 candidates = [tag] 

31 if len(tag) > 1 and tag[0] in "vV": 

32 candidates.append(tag[1:]) 

33 for candidate in candidates: 

34 try: 

35 return Version(candidate) 

36 except InvalidVersion: 

37 continue 

38 return None 

39 

40 

41def select_tag_for_version(version: Version, tags: Sequence[str]) -> str: 

42 pep = str(version) 

43 for candidate in (pep, f"v{pep}", f"V{pep}"): 43 ↛ 46line 43 didn't jump to line 46 because the loop on line 43 didn't complete

44 if candidate in tags: 

45 return candidate 

46 return sorted(tags)[0] 

47 

48 

49def collect_version_refs( 

50 tags: Sequence[str], 

51 *, 

52 include_prereleases: bool, 

53) -> tuple[VersionRef, ...]: 

54 grouped: dict[Version, list[str]] = {} 

55 for tag in tags: 

56 parsed = version_from_tag(tag) 

57 if parsed is None: 

58 continue 

59 if parsed.is_prerelease and not include_prereleases: 

60 continue 

61 grouped.setdefault(parsed, []).append(tag) 

62 return tuple( 

63 VersionRef(version=ver, tag=select_tag_for_version(ver, grouped[ver])) 

64 for ver in sorted(grouped) 

65 ) 

66 

67 

68def unversioned_tags(tags: Sequence[str]) -> tuple[str, ...]: 

69 return tuple(sorted(tag for tag in tags if version_from_tag(tag) is None)) 

70 

71 

72def format_versions_report(report: VersionsReport) -> str: 

73 """Pip-like text: name (latest) and newest-first versions.""" 

74 if report.refs: 74 ↛ 80line 74 didn't jump to line 80 because the condition on line 74 was always true

75 latest = str(report.refs[-1].version) 

76 listed = ", ".join(str(ref.version) for ref in reversed(report.refs)) 

77 header = f"{report.package} ({latest})" 

78 body = f"Available versions: {listed}" 

79 else: 

80 header = report.package 

81 body = "Available versions: (none)" 

82 lines = [header, body] 

83 if report.other_tags: 

84 lines.append(f"Other tags: {', '.join(report.other_tags)}") 

85 return "\n".join(lines) 

86 

87 

88def tag_for_version( 

89 package: PackageSource, 

90 version: Version, 

91 *, 

92 environ: Mapping[str, str] | None = None, 

93) -> str: 

94 """Return the Git tag for a PEP 440 version (ADR-0018).""" 

95 tags = git_ls_remote_tags(package.url, environ=environ) 

96 refs = collect_version_refs(tags, include_prereleases=True) 

97 for ref in refs: 

98 if ref.version == version: 

99 return ref.tag 

100 raise VersionTagNotFoundError(package.name, str(version)) 

101 

102 

103def list_package_versions( 

104 package: PackageSource, 

105 *, 

106 include_prereleases: bool = False, 

107 include_other: bool = False, 

108 environ: Mapping[str, str] | None = None, 

109) -> VersionsReport: 

110 tags = git_ls_remote_tags(package.url, environ=environ) 

111 refs = collect_version_refs(tags, include_prereleases=include_prereleases) 

112 others = unversioned_tags(tags) if include_other else () 

113 return VersionsReport(package=package.name, refs=refs, other_tags=others)