lib.version: Add module #91

Merged
Jan Lindemann merged 3 commits from jan/feature/20260909-lib-version-add-api-docstrings into master 2026-09-09 15:37:50 +02:00 AGit
10 changed files with 734 additions and 113 deletions

View file

@ -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:

View file

@ -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)),

View file

@ -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,

View file

@ -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:
expand_version_macros = subsec == 'jw'
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:
expand_version_macros = False
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:
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

View file

@ -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(',')
]

View file

@ -0,0 +1,4 @@
TOPDIR = ../../../../../..
include $(TOPDIR)/make/proj.mk
include $(JWBDIR)/make/py-mod.mk

View file

@ -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,
)

View file

@ -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

View file

@ -0,0 +1,7 @@
TOPDIR = ../../../../../../..
include $(TOPDIR)/make/proj.mk
include $(JWBDIR)/make/py-run.mk
all:
test: run

View file

@ -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')