diff --git a/src/python/jw/pkg/lib/version/Dependency.py b/src/python/jw/pkg/lib/version/Dependency.py new file mode 100644 index 00000000..18ba9380 --- /dev/null +++ b/src/python/jw/pkg/lib/version/Dependency.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import re + +from functools import cached_property +from typing import TYPE_CHECKING, override + +from ..log import WARNING, log +from .base import Boundary, Lookup, Syntax +from .Version import Version + +if TYPE_CHECKING: + from collections.abc import Sequence + from typing import ClassVar + +class Dependency: # export + + class Error(ValueError): + pass + + __SPLIT_RE: ClassVar[re.Pattern[str]] = re.compile('([=><]+)') + + @cached_property + def __parsed_spec(self) -> tuple[str, Boundary | None]: + parts = [part.strip() for part in self.__SPLIT_RE.split(self.__spec)] + match len(parts): + case 1: + if not parts[0]: + raise Dependency.Error(f'Invalid dependency spec "{self.__spec}"') + return parts[0], None + case 3: + if not parts[0] or not parts[2]: + raise Dependency.Error(f'Invalid dependency spec "{self.__spec}"') + if parts[2] == 'REVISION': + log( + WARNING, + f'Spec "{self.__spec}": a bare REVISION renders as a bare ' + 'number, which RPM reads as a version constraint, not a ' + 'release constraint' + ) + return parts[0], Boundary( + op = parts[1], + version = Version(parts[0], parts[2], self.__lookup_version), + ) + case _: + raise Dependency.Error(f'Invalid dependency spec "{self.__spec}"') + + def __version_boundaries( + self, + expanded: bool, + ) -> Sequence[Boundary]: + specified = self.__parsed_spec[1] + if specified is None: + return [] + if not expanded or not specified.version.is_full: + return (specified, ) + ret: list[Boundary] = [] + match specified.op: + case '>' | '>=': + ret.append(specified) + ret.append(Boundary('<', specified.version.next_binary_incompatible)) + case '=': + ret.append(Boundary('>=', specified.version)) + ret.append(Boundary('<', specified.version.next_binary_incompatible)) + case '<' | '<=': + ret.append(specified) + case _: + raise NotImplementedError( + ( + 'Expanding version boundary ' + f'"{self.full_name} {specified.op} {specified.version}" ' + 'is not yet implemented' + ) + ) + return ret + + # -- Public API + + def __init__( + self, + spec: str, + lookup_version: Lookup | None = None, + ) -> None: + self.__spec = spec + self.__lookup_version = lookup_version + + @override + def __str__(self) -> str: + return self.__spec + + @cached_property + def current_version(self) -> str: + if not self.__lookup_version: + raise Dependency.Error( + f'Tried to look up "{self.__spec}" for package ' + f'"{self.base_name}" without lookup function' + ) + return self.__lookup_version(self.base_name) + + @cached_property + def full_name(self) -> str: + return self.__parsed_spec[0] + + @cached_property + def base_name(self) -> str: + return Version.strip_package_suffix(self.full_name) + + def version_boundaries(self, expanded: bool = False) -> Sequence[Boundary]: + return self.__version_boundaries(expanded) + + def constraint_str( + self, + syntax: Syntax = Syntax.SEM_VER, + untemplated: bool = True, + include_revision: bool = True, + as_range: bool = False, + no_subpackages: bool = False, + quote: str | None = None, + ) -> str: + + def __str() -> str: + name = self.base_name if no_subpackages else self.full_name + if syntax is Syntax.NAMES_ONLY: + return name + ret: list[str] = [] + for boundary in self.version_boundaries(expanded = as_range, ): + op = boundary.format_op(syntax) + version = boundary.version.id(untemplated, include_revision) + ret.append(f'{name} {op} {version}') + if ret: + return ' '.join(ret) + return name + + return __str() if quote is None else f'{quote}{__str()}{quote}' + + @classmethod + def parse_deps_spec( + cls, + spec: str, + lookup_version: Lookup | None = None, + ) -> Sequence[Dependency]: + return [ + Dependency(spec = spec.strip(), lookup_version = lookup_version) + for spec in spec.split(',') + ] diff --git a/src/python/jw/pkg/lib/version/Makefile b/src/python/jw/pkg/lib/version/Makefile new file mode 100644 index 00000000..7a83c333 --- /dev/null +++ b/src/python/jw/pkg/lib/version/Makefile @@ -0,0 +1,4 @@ +TOPDIR = ../../../../../.. + +include $(TOPDIR)/make/proj.mk +include $(JWBDIR)/make/py-mod.mk diff --git a/src/python/jw/pkg/lib/version/Version.py b/src/python/jw/pkg/lib/version/Version.py new file mode 100644 index 00000000..4bf9af5d --- /dev/null +++ b/src/python/jw/pkg/lib/version/Version.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import re + +from functools import cached_property +from typing import TYPE_CHECKING, ClassVar, override + +from .base import Component, Lookup + +if TYPE_CHECKING: + from enum import Flag + +class Version: # export + + class Error(ValueError): + pass + + __FULL_ID_RE: ClassVar[ + re.Pattern[str], + ] = re.compile(r'[0-9]+\.[0-9]+\.[0-9]+-[0-9]+') + + __SUFFIX_RE: ClassVar[re.Pattern[str]] = re.compile('-dev$|-devel$|-run$') + + @cached_property + def __resolved_id(self) -> str: + if self.__lookup_version is None: + raise Version.Error( + f'Tried to look up version of {self.base_name} without lookup function' + ) + version = self.__lookup_version(self.base_name) + if self.__spec == 'REVISION': + parts = version.split('-', 1) + return parts[1] if len(parts) == 2 else '' + return version + + def __id(self, untemplate: bool, throw: bool = True) -> str: + if not untemplate: + return self.__spec + if self.__spec not in ['VERSION', 'REVISION', 'VERSION-REVISION']: + return self.__spec + try: + return self.__resolved_id + except Exception: + if throw: + raise + return self.__spec + + def __split_spec(self, untemplate: bool) -> tuple[str, str]: + parts = self.__id(untemplate).split('-') + if len(parts) == 1: + return parts[0], '' + return parts[0], parts[1] + + def __split_core(self, untemplate: bool) -> tuple[str, ...]: + return tuple(self.__split_spec(untemplate)[0].split('.')) + + def __parts_str(self, parts: Component, untemplate: bool) -> str: + # Without the widening, mypy will decry unreachable code below match block + match_subject: Flag = parts + match match_subject: + case Component.ID: + return self.__id(untemplate) + case Component.CORE: + return self.__split_spec(untemplate)[0] + case Component.MAJOR: + return self.__split_core(untemplate)[0] + case Component.MINOR: + return self.__split_core(untemplate)[1] + case Component.MICRO: + return self.__split_core(untemplate)[2] + case Component.REVISION: + return self.__split_spec(untemplate)[1] + case _: + pass + ret: list[str] = [] + for part in [ + Component.MAJOR, + Component.MINOR, + Component.MICRO, + Component.REVISION, + ]: + if part not in parts: + break + ret.append(self.__parts_str(part, untemplate)) + parts &= ~part + if parts: + raise Version.Error(f'Invalid version part combination {parts}') + if len(ret) < 4: + return '.'.join(ret) + return f'{".".join(ret[:3])}-{ret[3]}' + + def __init__( + self, name: str, spec: str, lookup_version: Lookup | None = None + ) -> None: + self.__name = name + self.__spec = spec + self.__lookup_version = lookup_version + + @override + def __repr__(self) -> str: + return self.__spec + + @override + def __str__(self) -> str: + return self.__id(untemplate = True, throw = False) + + @classmethod + def strip_package_suffix(cls, name: str) -> str: + return cls.__SUFFIX_RE.sub('', name) + + @property + def name(self) -> str: + return self.__name + + @property + def base_name(self) -> str: + return self.strip_package_suffix(self.__name) + + def parts_str(self, parts: Component, untemplate: bool) -> str: + return self.__parts_str(parts, untemplate) + + @property + def is_full(self) -> bool: + if self.__spec == 'VERSION-REVISION': + return True + return bool(self.__FULL_ID_RE.fullmatch(self.__spec)) + + def id(self, untemplate: bool, include_revision: bool = True) -> str: + if not include_revision and self.__spec == 'VERSION': + return self.core(untemplate) + return self.__id(untemplate) + + def core(self, untemplate: bool) -> str: + return self.__split_spec(untemplate)[0] + + def revision(self, untemplate: bool = True) -> str: + return self.__split_spec(untemplate)[1] + + @property + def major(self) -> int: + return int(self.__parts_str(Component.MAJOR, True)) + + @property + def minor(self) -> int: + return int(self.__parts_str(Component.MINOR, True)) + + @property + def micro(self) -> int: + return int(self.__parts_str(Component.MICRO, True)) + + @property + def next_binary_compatible(self) -> Version: + rev = self.revision(True) + m = re.match('[0-9]+', rev) + if not m: + raise Version.Error(f'Cannot increment non-numeric revision "{rev}"') + return Version( + self.__name, + f'{self.major}.{self.minor}.{self.micro}-{int(m.group()) + 1}', + self.__lookup_version, + ) + + @property + def next_binary_incompatible(self) -> Version: + return Version( + self.__name, + f'{self.major}.{self.minor}.{self.micro + 1}', + self.__lookup_version, + ) + + @property + def next_source_incompatible(self) -> Version: + return Version( + self.__name, + f'{self.major}.{self.minor + 1}.0', + self.__lookup_version, + ) diff --git a/src/python/jw/pkg/lib/version/base.py b/src/python/jw/pkg/lib/version/base.py new file mode 100644 index 00000000..cedbce01 --- /dev/null +++ b/src/python/jw/pkg/lib/version/base.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from collections.abc import Callable +from enum import Enum, Flag, auto +from typing import TYPE_CHECKING, NamedTuple, TypeAlias + +if TYPE_CHECKING: + from .Version import Version + +class Syntax(Enum): # export + DEBIAN = auto() + SEM_VER = auto() + NAMES_ONLY = auto() + +class Component(Flag): # export + ID = auto() + MAJOR = auto() + MINOR = auto() + MICRO = auto() + REVISION = auto() + CORE = MAJOR | MINOR | MICRO + +class Boundary(NamedTuple): # export + op: str + version: Version + + """Syntax-aware formatted version comparion operator""" + def format_op(self, syntax: Syntax) -> str: + if syntax is Syntax.DEBIAN: + match self.op: + case '<': + return '<<' + case '>': + return '>>' + case _: + return self.op + return self.op + +Lookup: TypeAlias = Callable[[str], str] # export diff --git a/test/unit/python/jw/pkg/lib/version/Makefile b/test/unit/python/jw/pkg/lib/version/Makefile new file mode 100644 index 00000000..de693650 --- /dev/null +++ b/test/unit/python/jw/pkg/lib/version/Makefile @@ -0,0 +1,7 @@ +TOPDIR = ../../../../../../.. + +include $(TOPDIR)/make/proj.mk +include $(JWBDIR)/make/py-run.mk + +all: +test: run diff --git a/test/unit/python/jw/pkg/lib/version/test.py b/test/unit/python/jw/pkg/lib/version/test.py new file mode 100644 index 00000000..84be31d9 --- /dev/null +++ b/test/unit/python/jw/pkg/lib/version/test.py @@ -0,0 +1,252 @@ +import io + +from jw.pkg.lib.log import add_capture_stream, rm_capture_stream +from jw.pkg.lib.version import Component, Dependency, Syntax, Version + +class FakeApp: + """Minimal stand-in for jw.pkg.App providing get_version()""" + + class Error(Exception): + pass + + def __init__(self, versions: dict[str, str]) -> None: + self.__versions = versions + + def get_version(self, project: str) -> str: + if project not in self.__versions: + raise self.Error(f"Can't get version of project {project}") + return self.__versions[project] + +app = FakeApp({'foo': '1.2.3-45', 'bar': '0.9.1-2'}) + +# Name only +d = Dependency('foo') +assert d.base_name == 'foo' +assert d.full_name == 'foo' +assert d.version_boundaries() == [] +assert d.constraint_str() == 'foo' + +# Subpackage suffixes are stripped from base_name +d = Dependency('foo-dev') +assert d.base_name == 'foo' +assert d.full_name == 'foo-dev' +d = Dependency('foo-devel') +assert d.base_name == 'foo' +assert d.full_name == 'foo-devel' +d = Dependency('foo-run') +assert d.base_name == 'foo' +assert d.full_name == 'foo-run' + +# A package literally named 'dev' keeps its name +d = Dependency('dev') +assert d.base_name == 'dev' +assert d.full_name == 'dev' + +# Operators with and without whitespace +for op in ['=', '==', '<', '<=', '>', '>=']: + d = Dependency(f'foo {op} 1.0') + assert d.base_name == 'foo' + assert d.full_name == 'foo' + b = d.version_boundaries() + assert len(b) == 1 + assert b[0].op == op + assert b[0].version.id(False) == '1.0' + d = Dependency(f'foo{op}1.0') + assert d.constraint_str(untemplated = False) == f'foo {op} 1.0' + +# Without a lookup, literals render as written and macros fail +d = Dependency('foo-devel >= 2.0') +assert d.constraint_str() == 'foo-devel >= 2.0' +try: + Dependency('foo = VERSION').constraint_str() + assert False, 'Should have raised' +except Version.Error: + pass + +# The lookup resolves the macros +d = Dependency('foo = VERSION', app.get_version) +assert d.constraint_str() == 'foo = 1.2.3-45' +d = Dependency('foo = VERSION', app.get_version) +assert d.constraint_str(include_revision = False) == 'foo = 1.2.3' +# Only the VERSION macro loses its revision, VERSION-REVISION and +# literals keep it +d = Dependency('foo = VERSION-REVISION', app.get_version) +assert d.constraint_str() == 'foo = 1.2.3-45' +assert d.constraint_str(include_revision = False) == 'foo = 1.2.3-45' +d = Dependency('foo = 1.2.3-45') +assert d.constraint_str(include_revision = False) == 'foo = 1.2.3-45' +# untemplated, the macro has no revision to strip +d = Dependency('foo = VERSION', app.get_version) +assert d.constraint_str(untemplated=False, include_revision=False) == \ + 'foo = VERSION' +# untemplated=False keeps the specifiers as written +d = Dependency('foo = VERSION', app.get_version) +assert d.constraint_str(untemplated = False) == 'foo = VERSION' +# REVISION resolves to the revision part +d = Dependency('foo = REVISION', app.get_version) +assert d.constraint_str() == 'foo = 45' +assert d.constraint_str(untemplated = False) == 'foo = REVISION' +# A bare REVISION warns: RPM reads a bare number as a version +buf = io.StringIO() +sd = add_capture_stream(buf) +d = Dependency('foo = REVISION', app.get_version) +assert d.constraint_str() == 'foo = 45' +assert 'not a release constraint' in buf.getvalue() +rm_capture_stream(sd) + +# The lookup uses the base name, not the subpackage name +d = Dependency('foo-devel = VERSION', app.get_version) +assert d.constraint_str() == 'foo-devel = 1.2.3-45' +# Unknown projects fail when untemplating +try: + Dependency('baz = VERSION', app.get_version).constraint_str() + assert False, 'Should have raised' +except FakeApp.Error: + pass + +# The lookup is cached: the mapper is called once +calls: list[str] = [] + +def mapper(project: str) -> str: + calls.append(project) + return '1.2.3-45' + +d = Dependency('foo = VERSION', mapper) +assert d.constraint_str() == 'foo = 1.2.3-45' +assert d.constraint_str() == 'foo = 1.2.3-45' +assert calls == ['foo'] + +# as_range expands full boundaries into a revision range +d = Dependency('foo = VERSION-REVISION', app.get_version) +assert d.constraint_str(as_range = True) == 'foo >= 1.2.3-45 foo < 1.2.4' +d = Dependency('foo >= VERSION-REVISION', app.get_version) +assert d.constraint_str(as_range = True) == 'foo >= 1.2.3-45 foo < 1.2.4' +d = Dependency('foo > 1.0.0-259', app.get_version) +assert d.constraint_str(as_range = True) == 'foo > 1.0.0-259 foo < 1.0.1' +# A raw render keeps the macro, with the computed bound alongside it +d = Dependency('foo = VERSION-REVISION', app.get_version) +constraint = d.constraint_str(untemplated = False, as_range = True) +assert constraint == 'foo >= VERSION-REVISION foo < 1.2.4' +# VERSION is not full, so a VERSION constraint is not expanded +d = Dependency('foo = VERSION', app.get_version) +assert d.constraint_str(as_range = True) == 'foo = 1.2.3-45' +d = Dependency('foo >= VERSION', app.get_version) +assert d.constraint_str(as_range = True) == 'foo >= 1.2.3-45' +# '<' and '<=' are not expanded +d = Dependency('foo <= VERSION', app.get_version) +assert d.constraint_str(as_range = True) == 'foo <= 1.2.3-45' +# versions without a full major.minor.micro-revision are not expanded +d = Dependency('foo = 1.0', app.get_version) +assert d.constraint_str(as_range = True) == 'foo = 1.0' +d = Dependency('foo = 1.2.3', app.get_version) +assert d.constraint_str(as_range = True) == 'foo = 1.2.3' +d = Dependency('foo = 1.0-rc1', app.get_version) +assert d.constraint_str(as_range = True) == 'foo = 1.0-rc1' +# unimplemented operators raise +d = Dependency('foo == 1.2.3-45', app.get_version) +try: + d.constraint_str(as_range = True) + assert False, 'Should have raised' +except NotImplementedError: + pass + +# Default syntax is SEM_VER +d = Dependency('foo < 2.0') +assert d.constraint_str(untemplated = False) == 'foo < 2.0' + +# DEBIAN converts strict inequalities, other operators pass through +d = Dependency('foo < 2.0') +assert d.constraint_str(Syntax.DEBIAN, untemplated = False) == 'foo << 2.0' +d = Dependency('foo > 1.0') +assert d.constraint_str(Syntax.DEBIAN, untemplated = False) == 'foo >> 1.0' +d = Dependency('foo <= 1.0') +assert d.constraint_str(Syntax.DEBIAN, untemplated = False) == 'foo <= 1.0' +d = Dependency('foo = 1.0') +assert d.constraint_str(Syntax.DEBIAN, untemplated = False) == 'foo = 1.0' + +# NAMES_ONLY drops the version +d = Dependency('foo-devel >= 1.0') +assert d.constraint_str(Syntax.NAMES_ONLY) == 'foo-devel' + +# no_subpackages strips the suffix from the name +d = Dependency('foo-devel >= 1.0') +assert d.constraint_str(no_subpackages = True, untemplated = False) == 'foo >= 1.0' + +# quote wraps the result +d = Dependency('foo-devel >= 1.0') +assert d.constraint_str(quote = '"', untemplated = False) == '"foo-devel >= 1.0"' + +# Version parts +v = Version('foo', '1.2.3-45', app.get_version) +assert v.major == 1 +assert v.minor == 2 +assert v.micro == 3 +assert v.core(True) == '1.2.3' +assert v.revision(True) == '45' +assert str(v.next_binary_compatible) == '1.2.3-46' +assert str(v.next_binary_incompatible) == '1.2.4' +assert str(v.next_source_incompatible) == '1.3.0' +assert v.parts_str(Component.ID, True) == '1.2.3-45' +assert v.parts_str(Component.CORE, True) == '1.2.3' +assert v.parts_str(Component.MAJOR | Component.MINOR, True) == '1.2' + +# next_binary_compatible steps to the next revision, dropping any suffix +for spec, expected in [ + ('1.2.3-4', '1.2.3-5'), + ('1.2.3-4blah', '1.2.3-5'), + ('1.2.3-4.myvariant', '1.2.3-5'), + ('1.2.3-4-broken', '1.2.3-5'), + ('1.0.0-259', '1.0.0-260'), +]: + v = Version('foo', spec) + assert str(v.next_binary_compatible) == expected, spec +# A macro resolves before the increment +v = Version('foo', 'VERSION-REVISION', app.get_version) +assert str(v.next_binary_compatible) == '1.2.3-46' +# A revision without a leading digit cannot be incremented +for spec in ['1.2.3-rc1', '1.2.3']: + v = Version('foo', spec) + try: + v.next_binary_compatible + assert False, f'Should have raised for {spec!r}' + except Version.Error: + pass + +# constructor +d = Dependency('foo-devel = 1.0') +assert d.base_name == 'foo' +assert d.full_name == 'foo-devel' +assert d.constraint_str(untemplated = False) == 'foo-devel = 1.0' + +# parse_deps_spec with a comma-separated string +deps = Dependency.parse_deps_spec('foo = 1.0, bar-devel >= 2.0, baz') +assert len(deps) == 3 +assert [d.full_name for d in deps] == ['foo', 'bar-devel', 'baz'] +assert deps[1].base_name == 'bar' +assert deps[1].constraint_str(untemplated = False) == 'bar-devel >= 2.0' +assert deps[2].constraint_str() == 'baz' + +# parse_deps_spec passes the lookup to the created packages +deps = Dependency.parse_deps_spec('foo = VERSION, bar', app.get_version) +assert deps[0].constraint_str() == 'foo = 1.2.3-45' +assert deps[1].constraint_str() == 'bar' + +# Malformed specs are rejected when parsed +try: + Dependency('foo = 1.0 > 2.0').full_name + assert False, 'Should have raised' +except Dependency.Error: + pass +# Empty names and versions are rejected +for bad in ['', 'foo =', ' = 1.0']: + try: + Dependency(bad).full_name + assert False, f'Should have raised for {bad!r}' + except Dependency.Error: + pass + +# str +assert str(Dependency('foo-devel >= 1.0')) == 'foo-devel >= 1.0' +assert str(Dependency('foo')) == 'foo' + +print('All lib.version tests passed')