Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions posit-bakery/posit_bakery/config/image/parsed_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
"""Parsed version representation for Posit calver-flavored semver strings.

Provides ``ParsedVersion``, a value type that round-trips the input string,
supports comparison per semver §11, and warns (rather than raising) on
unparseable input.
"""

import logging
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING

# Local under TYPE_CHECKING to avoid a circular import: version.py imports
# this module at runtime, so importing ImageVersion eagerly would cycle.
if TYPE_CHECKING:
from posit_bakery.config.image.version import ImageVersion

log = logging.getLogger(__name__)

# Anchored grammar:
# <release> one or more dot-separated digit groups, minimum two groups
# -<prerelease> optional, semver prerelease alphabet
# +<build> optional, semver build alphabet
_VERSION_RE = re.compile(
r"^(?P<release>\d+(?:\.\d+)+)"
r"(?:-(?P<prerelease>[0-9A-Za-z.-]+))?"
r"(?:\+(?P<build>[0-9A-Za-z.-]+))?$"
)


@dataclass(frozen=True)
class ParsedVersion:
"""A parsed Posit calver/semver version string.

The original string is preserved verbatim so ``str(parsed) == original``.
Comparison follows semver §11: release tuples first (zero-padded to equal
length), then prerelease presence (a version with a prerelease is less
than the same version without), then prerelease segments. Build metadata
is preserved in ``original`` but ignored for comparison.
"""

original: str
release: tuple[int, ...]
prerelease: str | None = None
build: str | None = None

def __str__(self) -> str:
return self.original

@classmethod
def parse(cls, value: str) -> "ParsedVersion | None":
"""Parse a version string. Returns ``None`` on failure and logs a warning."""
if not isinstance(value, str):
log.warning("Unparseable version string: %r", value)
return None
match = _VERSION_RE.match(value)
if match is None:
log.warning("Unparseable version string: %r", value)
return None
release = tuple(int(part) for part in match.group("release").split("."))
return cls(
original=value,
release=release,
prerelease=match.group("prerelease"),
build=match.group("build"),
)

def _release_key(self, length: int) -> tuple[int, ...]:
"""Zero-pad ``self.release`` to ``length`` for length-tolerant comparison."""
return self.release + (0,) * (length - len(self.release))

@staticmethod
def _prerelease_segment_key(segment: str) -> tuple[int, int | str]:
"""Per semver §11.4.3, numeric segments rank below alphanumeric ones."""
if segment.isdigit():
return (0, int(segment))
return (1, segment)

def _prerelease_key(self) -> tuple[int, tuple[tuple[int, int | str], ...]]:
"""Comparison key for the prerelease component.

``(0, ())`` for an absent prerelease ranks above ``(-1, ...)`` for any
present prerelease, matching semver §11.3 ("a version with a prerelease
is less than the same version without").
"""
if self.prerelease is None:
return (0, ())
segments = tuple(self._prerelease_segment_key(s) for s in self.prerelease.split("."))
return (-1, segments)

def _compare_key(self, other: "ParsedVersion"):
"""Build (self_key, other_key) for ordered comparison against ``other``."""
length = max(len(self.release), len(other.release))
return (
(self._release_key(length), self._prerelease_key()),
(other._release_key(length), other._prerelease_key()),
)

def __eq__(self, other: object) -> bool:
if not isinstance(other, ParsedVersion):
return NotImplemented
a, b = self._compare_key(other)
return a == b

def __lt__(self, other: "ParsedVersion") -> bool:
if not isinstance(other, ParsedVersion):
return NotImplemented
a, b = self._compare_key(other)
return a < b

def __le__(self, other: "ParsedVersion") -> bool:
if not isinstance(other, ParsedVersion):
return NotImplemented
a, b = self._compare_key(other)
return a <= b

def __gt__(self, other: "ParsedVersion") -> bool:
if not isinstance(other, ParsedVersion):
return NotImplemented
a, b = self._compare_key(other)
return a > b

def __ge__(self, other: "ParsedVersion") -> bool:
if not isinstance(other, ParsedVersion):
return NotImplemented
a, b = self._compare_key(other)
return a >= b

def __hash__(self) -> int:
# Hash with trailing zeros stripped so 2026.4.0 and 2026.4.0.0 hash the same.
stripped = self.release
while len(stripped) > 1 and stripped[-1] == 0:
stripped = stripped[:-1]
return hash((stripped, self._prerelease_key()))


# Sentinel: sorts strictly below every parseable ParsedVersion. The parser only
# ever produces non-negative release components, so a release tuple containing
# -1 is unreachable from `parse()` and compares less than every real version
# under tuple ordering after zero-padding.
ParsedVersion.MIN = ParsedVersion( # type: ignore[attr-defined]
original="",
release=(-1,),
prerelease=None,
build=None,
)


def version_sort_key(image_version: "ImageVersion") -> ParsedVersion:
"""Sort key for ``ImageVersion``: unparseable / matrix versions sort first.

Use as: ``sorted(versions, key=version_sort_key)``. Parseable versions
sort in ascending semver order; ``ImageVersion`` instances whose
``parsed_version`` is ``None`` (matrix versions or unparseable names)
collapse to ``ParsedVersion.MIN`` and lead the sorted list.
"""
parsed = image_version.parsed_version
return parsed if parsed is not None else ParsedVersion.MIN
13 changes: 13 additions & 0 deletions posit-bakery/posit_bakery/config/image/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from posit_bakery.config.shared import BakeryPathMixin, BakeryYAMLModel
from posit_bakery.const import DevVersionInclusionEnum, JINJA2_TEMPLATE_EXTENSIONS
from .build_os import DEFAULT_PLATFORMS, TargetPlatform
from .parsed_version import ParsedVersion
from .variant import ImageVariant
from .version_os import ImageVersionOS
from ..templating import jinja2_env
Expand Down Expand Up @@ -334,6 +335,18 @@ def supported_platforms(self) -> list[TargetPlatform]:
platforms.append(platform)
return platforms

@property
def parsed_version(self) -> ParsedVersion | None:
"""Return the parsed semver/calver representation of ``self.name``.

Returns ``None`` for matrix versions (without warning) and for
unparseable names (with a single ``log.warning`` from
``ParsedVersion.parse``).
"""
if self.isMatrixVersion:
return None
return ParsedVersion.parse(self.name)

def generate_template_values(
self,
variant: Union["ImageVariant", None] = None,
Expand Down
187 changes: 187 additions & 0 deletions posit-bakery/test/config/image/test_parsed_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import logging
from unittest.mock import MagicMock

import pytest

from posit_bakery.config.image.parsed_version import ParsedVersion
from posit_bakery.config.image.parsed_version import version_sort_key

pytestmark = [
pytest.mark.unit,
pytest.mark.config,
]


class TestParseUnparseable:
@pytest.mark.parametrize(
"value",
[
"",
"latest",
"R4.3.3-python3.11.15",
"v1.2.3",
"not a version",
"1", # only one release component
],
)
def test_returns_none(self, value, caplog):
"""Unparseable inputs return None and emit exactly one log.warning."""
caplog.set_level(logging.WARNING)
result = ParsedVersion.parse(value)
assert result is None
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
assert len(warnings) == 1
assert "Unparseable version string" in warnings[0].message
assert repr(value) in warnings[0].message


class TestParseRoundtrip:
@pytest.mark.parametrize(
"value,release,prerelease,build",
[
# The six exemplars from issue #499.
("2026.04.0+526.pro2", (2026, 4, 0), None, "526.pro2"),
("2026.05.0-daily+92", (2026, 5, 0), "daily", "92"),
("2026.03.1", (2026, 3, 1), None, None),
("2026.04.0-dev+485-gdb8245deea", (2026, 4, 0), "dev", "485-gdb8245deea"),
("2026.04.1", (2026, 4, 1), None, None),
("2026.05.0-dev+62-g1ca9367735", (2026, 5, 0), "dev", "62-g1ca9367735"),
# Older Package Manager stable: -N is build metadata, but parses as
# a prerelease segment under the regex. Round-trips losslessly.
("2025.12.0-14", (2025, 12, 0), "14", None),
# Edge: 2-component release.
("2026.4", (2026, 4), None, None),
# Edge: 4-component release.
("2026.4.0.1", (2026, 4, 0, 1), None, None),
# Edge: leading zeros preserved in original; parsed numerically.
("2026.04.01", (2026, 4, 1), None, None),
# Edge: prerelease only.
("2026.4.0-rc.1", (2026, 4, 0), "rc.1", None),
# Edge: build only.
("2026.4.0+abc", (2026, 4, 0), None, "abc"),
],
)
def test_parses_and_roundtrips(self, value, release, prerelease, build, caplog):
"""All valid inputs parse correctly, decompose as expected, and ``str()`` round-trips."""
caplog.set_level(logging.WARNING)
parsed = ParsedVersion.parse(value)
assert parsed is not None
assert parsed.original == value
assert parsed.release == release
assert parsed.prerelease == prerelease
assert parsed.build == build
assert str(parsed) == value
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]


def _p(s: str) -> ParsedVersion:
"""Helper: parse a known-good string and assert success."""
parsed = ParsedVersion.parse(s)
assert parsed is not None, f"failed to parse {s!r}"
return parsed


class TestComparison:
@pytest.mark.parametrize(
"ascending",
[
# Each list is in strictly ascending order.
["2026.04.0", "2026.04.1"], # patch increment
["2026.04.1", "2026.05.0"], # minor increment
["2026.04.9", "2026.04.10"], # rollover (the bug from #499)
["2026.04.0-daily", "2026.04.0-dev", "2026.04.0"], # lexicographic prerelease order; presence < absence
["2026.04.0-1", "2026.04.0-alpha"], # numeric < alphanumeric segment
["2026.03.1", "2026.04.0-daily", "2026.04.0", "2026.04.1"],
],
)
def test_ordered_chain(self, ascending):
parsed = [_p(s) for s in ascending]
for i in range(len(parsed)):
for j in range(i + 1, len(parsed)):
a, b = parsed[i], parsed[j]
assert a < b, f"expected {a} < {b}"
assert b > a
assert a <= b
assert b >= a
assert a != b

@pytest.mark.parametrize(
"a,b",
[
# Zero-padding equivalence.
("2026.4.0", "2026.04.0"),
# Trailing-zero release-tuple equivalence.
("2026.4.0", "2026.4.0.0"),
# Build metadata ignored for comparison.
("2026.04.0+a", "2026.04.0+b"),
# Mixed: zero-padding + build difference.
("2026.4.0+x", "2026.04.0+y"),
],
)
def test_equality(self, a, b):
pa, pb = _p(a), _p(b)
assert pa == pb
assert not (pa < pb)
assert not (pa > pb)
assert pa <= pb
assert pa >= pb
assert hash(pa) == hash(pb)

def test_set_dedup_uses_comparison_equality(self):
"""ParsedVersions equal under spec rules collapse in a set."""
items = {_p("2026.4.0"), _p("2026.04.0"), _p("2026.04.0+x")}
assert len(items) == 1

def test_str_preserves_original_after_equality(self):
"""Equality does not erase the original string."""
a = _p("2026.4.0+x")
b = _p("2026.04.0+y")
assert a == b
assert str(a) == "2026.4.0+x"
assert str(b) == "2026.04.0+y"


class TestMinSentinel:
@pytest.mark.parametrize(
"value",
["2026.04.0", "2026.04.0-daily", "1.0.0", "2025.12.0-14"],
)
def test_min_less_than_any_parseable(self, value):
parsed = _p(value)
assert ParsedVersion.MIN < parsed
assert parsed > ParsedVersion.MIN
assert ParsedVersion.MIN != parsed

def test_min_equal_to_self(self):
assert ParsedVersion.MIN == ParsedVersion.MIN
assert hash(ParsedVersion.MIN) == hash(ParsedVersion.MIN)

def test_min_str_is_empty(self):
assert str(ParsedVersion.MIN) == ""


class TestVersionSortKey:
def test_sorts_with_unparseable_first(self):
"""Mixed list sorts: matrix/garbage first (via MIN), then ascending parseable."""

# Build mock ImageVersions with the three relevant fields.
def mock_iv(name: str, *, is_matrix: bool = False) -> MagicMock:
iv = MagicMock()
iv.name = name
iv.isMatrixVersion = is_matrix
iv.parsed_version = None if is_matrix else ParsedVersion.parse(name)
return iv

items = [
mock_iv("2026.04.1"),
mock_iv("R4.3.3-python3.11.15", is_matrix=True),
mock_iv("2026.04.0"),
mock_iv("garbage"), # unparseable, parsed_version is None
mock_iv("2026.05.0-dev+62-g1ca9367735"),
]
ordered = sorted(items, key=version_sort_key)
names = [iv.name for iv in ordered]
# Two unparseable/matrix entries lead, in original relative order
# (Python's sort is stable). Parseable entries follow in ascending order.
assert names[:2] == ["R4.3.3-python3.11.15", "garbage"]
assert names[2:] == ["2026.04.0", "2026.04.1", "2026.05.0-dev+62-g1ca9367735"]
Loading
Loading