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
« 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)."""
3from __future__ import annotations
5from collections.abc import Mapping, Sequence
6from dataclasses import dataclass
8from packaging.version import InvalidVersion, Version
10from lpip.errors import VersionTagNotFoundError
11from lpip.gitutils import git_ls_remote_tags
12from lpip.models import PackageSource
15@dataclass(frozen=True)
16class VersionRef:
17 version: Version
18 tag: str
21@dataclass(frozen=True)
22class VersionsReport:
23 package: str
24 refs: tuple[VersionRef, ...]
25 other_tags: tuple[str, ...]
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
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]
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 )
68def unversioned_tags(tags: Sequence[str]) -> tuple[str, ...]:
69 return tuple(sorted(tag for tag in tags if version_from_tag(tag) is None))
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)
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))
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)