diff --git a/src/python/jw/pkg/App.py b/src/python/jw/pkg/App.py index 07f23d2d..fe93c585 100644 --- a/src/python/jw/pkg/App.py +++ b/src/python/jw/pkg/App.py @@ -11,10 +11,11 @@ from enum import Enum, auto from functools import cache from typing import TYPE_CHECKING, override -from .lib.ExecApp import ExecApp as Base from .lib.Distro import Distro +from .lib.ExecApp import ExecApp as Base from .lib.log import DEBUG, ERR, log from .lib.ProjectConf import ProjectConf +from .lib.version.Dependency import Dependency if TYPE_CHECKING: import argparse @@ -435,7 +436,7 @@ class App(Base): return self.find_dir(name, ['/tmpl'], ['/opt/' + name + '/share/tmpl']) def strip_module_from_spec(self, mod: str) -> str: - return re.sub(r'-dev$|-devel$|-run$', '', re.split('([=><]+)', mod)[0].strip()) + return Dependency(mod).base_name @cache def get_value(self, project: str, section: str, key: str) -> str | None: diff --git a/src/python/jw/pkg/cmds/projects/BaseCmdPkgRelations.py b/src/python/jw/pkg/cmds/projects/BaseCmdPkgRelations.py index 66004f96..98c42c27 100644 --- a/src/python/jw/pkg/cmds/projects/BaseCmdPkgRelations.py +++ b/src/python/jw/pkg/cmds/projects/BaseCmdPkgRelations.py @@ -4,8 +4,8 @@ import re from typing import TYPE_CHECKING, cast, override +from ...lib.version.base import Syntax from .Cmd import Cmd, Parent -from .lib.pkg_relations import VersionSyntax from .lib.pkg_relations import pkg_relations as pkg_relations_list if TYPE_CHECKING: @@ -30,7 +30,11 @@ class BaseCmdPkgRelations(Cmd): no_subpackages = args.no_subpackages, dont_strip_revision = args.dont_strip_revision, expand_semver_revision_range = args.expand_semver_revision_range, - syntax = VersionSyntax[args.syntax.replace('-', '_')], + syntax = { + 'semver': Syntax.SEM_VER, + 'debian': Syntax.DEBIAN, + 'names-only': Syntax.NAMES_ONLY, + }[args.syntax], recursive = args.recursive, dont_expand_version_macros = args.dont_expand_version_macros, ignore = set(re.split(self.arg_sep, args.ignore)), diff --git a/src/python/jw/pkg/cmds/projects/CmdCreateFile.py b/src/python/jw/pkg/cmds/projects/CmdCreateFile.py index b852ac80..dfe5b95f 100644 --- a/src/python/jw/pkg/cmds/projects/CmdCreateFile.py +++ b/src/python/jw/pkg/cmds/projects/CmdCreateFile.py @@ -3,8 +3,9 @@ from enum import Enum, auto from typing import override from ...lib.log import WARNING, log +from ...lib.version.base import Syntax from .Cmd import Cmd, Parent -from .lib.pkg_relations import VersionSyntax, pkg_relations +from .lib.pkg_relations import pkg_relations from .lib.templates import ListDict, RenderValues, tmpl_render def key_value(s: str) -> tuple[str, str]: @@ -36,7 +37,7 @@ class CmdCreateFile(Cmd): # export flavours = ['run'], subsections = ['jw'], seed_pkgs = [module], - syntax = VersionSyntax.names_only, + syntax = Syntax.NAMES_ONLY, no_subpackages = True, recursive = True, quote = False, diff --git a/src/python/jw/pkg/cmds/projects/lib/pkg_relations.py b/src/python/jw/pkg/cmds/projects/lib/pkg_relations.py index 2b470d79..9a14fbe9 100644 --- a/src/python/jw/pkg/cmds/projects/lib/pkg_relations.py +++ b/src/python/jw/pkg/cmds/projects/lib/pkg_relations.py @@ -1,14 +1,7 @@ -import re - -from enum import Enum, auto - from ....App import App, Scope from ....lib.log import DEBUG, log - -class VersionSyntax(Enum): - semver = auto() - debian = auto() - names_only = auto() +from ....lib.version.base import Syntax +from ....lib.version.Dependency import Dependency def pkg_relations( app: App, @@ -20,7 +13,7 @@ def pkg_relations( no_subpackages: bool = False, dont_strip_revision: bool = False, expand_semver_revision_range: bool = False, - syntax: VersionSyntax = VersionSyntax.semver, + syntax: Syntax = Syntax.SEM_VER, recursive: bool = False, dont_expand_version_macros: bool = False, ignore: set[str] = set(), @@ -34,8 +27,7 @@ def pkg_relations( subsections = app.distro.os_cascade subsections.append('jw') - expand_semver_revision_range = expand_semver_revision_range - if syntax == VersionSyntax.debian: + if syntax == Syntax.DEBIAN: expand_semver_revision_range = True if skip_excluded: @@ -58,113 +50,48 @@ def pkg_relations( ), ) - version_pattern = re.compile('[0-9-.]*') ret: list[str] = [] for flavour in flavours: # build / release / run / devel cur_pkgs = seed_pkgs.copy() - visited = set() + visited_pkgs: set[str] = set() while len(cur_pkgs): cur_pkg = cur_pkgs.pop(0) - if cur_pkg in visited or cur_pkg in ignore: + if cur_pkg in visited_pkgs or cur_pkg in ignore: continue for subsec in subsections: - version: str | None = None section = 'pkg.' + rel_type + '.' + subsec - visited.add(cur_pkg) - value = app.get_value(cur_pkg, section, flavour) - if not value: + visited_pkgs.add(cur_pkg) + deps_spec = app.get_value(cur_pkg, section, flavour) + if not deps_spec: continue - deps = value.split(',') - for spec in deps: - dep = re.split('([=><]+)', spec) - if syntax == VersionSyntax.names_only: - dep = dep[:1] - dep = list(map(str.strip, dep)) - dep_name = re.sub('-dev$|-devel$|-run$', '', dep[0]) - if dep_name in ignore or dep[0] in ignore: + for dep in Dependency.parse_deps_spec( + deps_spec, + lookup_version = app.get_version, + ): + dep_name = dep.base_name + if dep_name in ignore or dep.full_name in ignore: continue - if no_subpackages: - dep[0] = dep_name - for i, item in enumerate(dep): - dep[i] = item.strip() if subsec == 'jw': - if (recursive and dep_name not in visited + if (recursive and dep_name not in visited_pkgs and dep_name not in cur_pkgs): cur_pkgs.append(dep_name) - if hide_jw_pkg: + if hide_jw_pkg and dep_name == 'jw-pkg': continue - if len(dep) == 3: - if dont_expand_version_macros and dep_name in cur_pkgs: - version = dep[2] - else: - version = app.get_version(dep_name) - if dep[2] == 'VERSION': - if dont_strip_revision: - dep[2] = version - else: - dep[2] = version.split('-')[0] - elif dep[2] == 'VERSION-REVISION': - dep[2] = version - elif version_pattern.match(dep[2]): - # dep[2] = dep[2] - pass - else: - raise Exception('Unknown version specifier in ' + spec) - if len(dep) != 3 or not expand_semver_revision_range: - expanded_deps = [dep] - else: - assert version is not None - expanded_deps = [] - semver = re.split(r'[.-]', version) - if len(semver) != 4: - expanded_deps = [dep] - else: - release = int(semver[2]) - major_minor = f'{semver[0]}.{semver[1]}' - match dep[1]: - case '>' | '>=': - expanded_deps.append([dep[0], dep[1], dep[2]]) - expanded_deps.append( - [dep[0], '<', f'{major_minor}.{release + 1}'] - ) - case '<' | '<=': - expanded_deps.append([dep[0], dep[1], dep[2]]) - case '=': - expanded_deps.append( - [dep[0], '>=', f'{major_minor}.{release}'] - ) - expanded_deps.append( - [dep[0], '<', f'{major_minor}.{release + 1}'] - ) - case _: - raise NotImplementedError( - ( - 'Expanding SemVer range ' - f'"{dep[0]} {dep[1]} {dep[3]}" ' - 'is not yet implemented' - ) - ) - for expanded_dep in expanded_deps: - if hide_self and dep_name in seed_pkgs: - continue - match syntax: - case VersionSyntax.semver: - pass - case VersionSyntax.names_only: - pass - case VersionSyntax.debian: - if len(expanded_dep) == 3: - match expanded_dep[1]: - case '<': - expanded_dep[1] = '<<' - case '>': - expanded_dep[1] = '>>' - case _: - pass - dep_str = ' '.join(expanded_dep) - if quote: - dep_str = '"' + dep_str + '"' - if dep_str not in ret: - log(DEBUG, f'Appending dependency >{dep_str}<') - ret.append(dep_str) + expand_version_macros = subsec == 'jw' + if dont_expand_version_macros and dep_name in cur_pkgs: + expand_version_macros = False + if hide_self and dep_name in seed_pkgs: + continue + dep_str = dep.constraint_str( + untemplated = expand_version_macros, + include_revision = dont_strip_revision, + as_range = expand_semver_revision_range, + no_subpackages = no_subpackages, + syntax = syntax, + quote = '"' if quote else None, + ) + if dep_str in ret: + continue + log(DEBUG, f'Appending dependency >{dep_str}<') + ret.append(dep_str) return ret 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..2ef02c42 --- /dev/null +++ b/src/python/jw/pkg/lib/version/Dependency.py @@ -0,0 +1,163 @@ +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 + """A single package dependency: a package name and an optional + version boundary, parsed from a specification such as + 'foo-devel >= 1.2.3-4'. Rendering happens in constraint_str(). + """ + + 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]: + """The parsed version boundary, or the range it spans when + expanded is True and it pins a full version. + """ + 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: + """Render the dependency as a version constraint string. + + NAMES_ONLY renders the name alone. untemplated keeps the + VERSION, VERSION-REVISION and REVISION macros as written instead + of the resolved versions. include_revision = False drops the + revision of VERSION specs. as_range expands a boundary that + pins a full version into the range it spans. no_subpackages + renders the base name, quote wraps the result in the given + string. + """ + + 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]: + """Split a comma-separated specification into Dependency objects""" + 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..ee57b444 --- /dev/null +++ b/src/python/jw/pkg/lib/version/Version.py @@ -0,0 +1,217 @@ +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 + """A version spec: a literal such as '1.2.3-4', or one of the macros + VERSION, VERSION-REVISION and REVISION, which resolve against the + version of the project the name refers to, through the lookup + callback. + + Versions step through compatibility tiers: a different revision is + a binary-compatible change, a different micro is source-compatible, + a different minor carries no major incompatibilities but downstream + packages should expect trivial fixes, and a different major gives + no compatibility guarantees at all. + """ + + 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: + """Remove the -dev, -devel or -run subpackage suffix""" + 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: + """True if the spec pins a complete version: the + VERSION-REVISION macro, or a literal of the form + MAJOR.MINOR.MICRO-REVISION. Only full versions have the + exclusive upper bound (next_binary_incompatible) that range + expansion relies on. + """ + 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: + """Return the version id: the raw spec if untemplate is False, + else the resolved version. With include_revision = False a + VERSION spec yields the core version. + """ + 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: + """The next version within the binary tier: the same core with + the revision incremented. A different revision is a + binary-compatible change, so binaries built against the current + version keep working. Only the revision's leading digits count: + a suffixed revision such as '4blah' steps to '5', which sits + strictly above every variant of the current revision. A + revision without leading digits raises Version.Error. + """ + 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: + """The next version out of the binary tier: the micro + incremented, the revision dropped. A different micro is + source-compatible, so this is the first version binaries of the + current version are not guaranteed to run against. + """ + return Version( + self.__name, + f'{self.major}.{self.minor}.{self.micro + 1}', + self.__lookup_version, + ) + + @property + def next_source_incompatible(self) -> Version: + """The next version out of the source tier: the minor + incremented, micro and revision reset. A different minor carries + no major incompatibilities, but downstream packages should + expect to need trivial fixes. + """ + 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..4d9a12f0 --- /dev/null +++ b/src/python/jw/pkg/lib/version/base.py @@ -0,0 +1,45 @@ +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 + """Syntaxes for rendered version constraints""" + + DEBIAN = auto() + SEM_VER = auto() + NAMES_ONLY = auto() + +class Component(Flag): # export + """Version components that parts_str() can extract""" + + ID = auto() + MAJOR = auto() + MINOR = auto() + MICRO = auto() + REVISION = auto() + CORE = MAJOR | MINOR | MICRO + +class Boundary(NamedTuple): # export + """A comparison operator and the Version it bounds""" + + 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')