lpip.versions

src/lpip/versions.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
"""Map Git tags to PEP 440 versions (ADR-0016)."""

from __future__ import annotations

from collections.abc import Mapping, Sequence
from dataclasses import dataclass

from packaging.version import InvalidVersion, Version

from lpip.errors import VersionTagNotFoundError
from lpip.gitutils import git_ls_remote_tags
from lpip.models import PackageSource


@dataclass(frozen=True)
class VersionRef:
    version: Version
    tag: str


@dataclass(frozen=True)
class VersionsReport:
    package: str
    refs: tuple[VersionRef, ...]
    other_tags: tuple[str, ...]


def version_from_tag(tag: str) -> Version | None:
    """PEP 440 as-is, or after one leading ``v`` / ``V``."""
    candidates = [tag]
    if len(tag) > 1 and tag[0] in "vV":
        candidates.append(tag[1:])
    for candidate in candidates:
        try:
            return Version(candidate)
        except InvalidVersion:
            continue
    return None


def select_tag_for_version(version: Version, tags: Sequence[str]) -> str:
    pep = str(version)
    for candidate in (pep, f"v{pep}", f"V{pep}"):
        if candidate in tags:
            return candidate
    return sorted(tags)[0]


def collect_version_refs(
    tags: Sequence[str],
    *,
    include_prereleases: bool,
) -> tuple[VersionRef, ...]:
    grouped: dict[Version, list[str]] = {}
    for tag in tags:
        parsed = version_from_tag(tag)
        if parsed is None:
            continue
        if parsed.is_prerelease and not include_prereleases:
            continue
        grouped.setdefault(parsed, []).append(tag)
    return tuple(
        VersionRef(version=ver, tag=select_tag_for_version(ver, grouped[ver]))
        for ver in sorted(grouped)
    )


def unversioned_tags(tags: Sequence[str]) -> tuple[str, ...]:
    return tuple(sorted(tag for tag in tags if version_from_tag(tag) is None))


def format_versions_report(report: VersionsReport) -> str:
    """Pip-like text: name (latest) and newest-first versions."""
    if report.refs:
        latest = str(report.refs[-1].version)
        listed = ", ".join(str(ref.version) for ref in reversed(report.refs))
        header = f"{report.package} ({latest})"
        body = f"Available versions: {listed}"
    else:
        header = report.package
        body = "Available versions: (none)"
    lines = [header, body]
    if report.other_tags:
        lines.append(f"Other tags: {', '.join(report.other_tags)}")
    return "\n".join(lines)


def tag_for_version(
    package: PackageSource,
    version: Version,
    *,
    environ: Mapping[str, str] | None = None,
) -> str:
    """Return the Git tag for a PEP 440 version (ADR-0018)."""
    tags = git_ls_remote_tags(package.url, environ=environ)
    refs = collect_version_refs(tags, include_prereleases=True)
    for ref in refs:
        if ref.version == version:
            return ref.tag
    raise VersionTagNotFoundError(package.name, str(version))


def list_package_versions(
    package: PackageSource,
    *,
    include_prereleases: bool = False,
    include_other: bool = False,
    environ: Mapping[str, str] | None = None,
) -> VersionsReport:
    tags = git_ls_remote_tags(package.url, environ=environ)
    refs = collect_version_refs(tags, include_prereleases=include_prereleases)
    others = unversioned_tags(tags) if include_other else ()
    return VersionsReport(package=package.name, refs=refs, other_tags=others)