jw-pkg/test/unit/python/jw/pkg/lib/version/test.py

245 lines
8.8 KiB
Python
Raw Normal View History

lib.version: Add module to parse dependency specs pkg_relations() parses dependency specifications such as 'foo-devel >= 1.2.3-4' with inline regular expressions and mixes the parsing with version macro expansion and rendering in three different syntaxes. This commit adds the lib.version module that owns the parsing of a single specification: the base and full name and the version boundary with its operator. Rendering happens in constraint_str() in SEM_VER, DEBIAN or NAMES_ONLY syntax, with untemplated, include_revision, as_range, no_subpackages and quote options. The VERSION, VERSION-REVISION and REVISION macros are resolved through a version lookup callback, lib.version.Version.parse_deps_spec() splits comma-separated specification strings into Version objects, and Version breaks a version down into its major, minor, micro and revision parts. Unit tests cover parsing, macro resolution, rendering in all syntaxes and the range expansion. The module is designed to be integrated into the status quo and intentionally does not address a couple of further TODOs. Notably version.Syntax.SEM_VER is intended to replace pkg_relations.VersionSyntax.SemVer, but neither are really semantic versioning according to spec. They are close but more targeted toward RPM and Debian package versioning. And the naming and semantics differ from SemVer. Naming of the four components (major and minor version) is identical, semantic meaning aside, there's no disagreement between real SemVer, Debian, RPM and jw-pkg. The third and fourth component deviate: SemVer Debian RPM jw-pkg lib.version 3 PATCH RELEASE MICRO 4 PRERELEASE Revision Release REVISION REVISION TODOs: RELEASE over the rest of jw-pkg needs to be adjusted by a later commit. Syntax.SEM_VER should also be renamed, to Syntax.JW_PKG maybe, because, as said, it's not SemVer and will probably never be - SemVer doesn't provide the compatibility guarantees that the jw-pkg versioning scheme offers. To be documented. Signed-off-by: Jan Lindemann <jan@janware.com>
2026-09-09 06:29:23 +02:00
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'
# A bare REVISION is rejected on the grounds that is assumed that
# the user wanted VERSION instead
try:
Dependency('foo = REVISION', app.get_version).constraint_str()
assert False, 'Should have raised'
except Dependency.Error:
pass
lib.version: Add module to parse dependency specs pkg_relations() parses dependency specifications such as 'foo-devel >= 1.2.3-4' with inline regular expressions and mixes the parsing with version macro expansion and rendering in three different syntaxes. This commit adds the lib.version module that owns the parsing of a single specification: the base and full name and the version boundary with its operator. Rendering happens in constraint_str() in SEM_VER, DEBIAN or NAMES_ONLY syntax, with untemplated, include_revision, as_range, no_subpackages and quote options. The VERSION, VERSION-REVISION and REVISION macros are resolved through a version lookup callback, lib.version.Version.parse_deps_spec() splits comma-separated specification strings into Version objects, and Version breaks a version down into its major, minor, micro and revision parts. Unit tests cover parsing, macro resolution, rendering in all syntaxes and the range expansion. The module is designed to be integrated into the status quo and intentionally does not address a couple of further TODOs. Notably version.Syntax.SEM_VER is intended to replace pkg_relations.VersionSyntax.SemVer, but neither are really semantic versioning according to spec. They are close but more targeted toward RPM and Debian package versioning. And the naming and semantics differ from SemVer. Naming of the four components (major and minor version) is identical, semantic meaning aside, there's no disagreement between real SemVer, Debian, RPM and jw-pkg. The third and fourth component deviate: SemVer Debian RPM jw-pkg lib.version 3 PATCH RELEASE MICRO 4 PRERELEASE Revision Release REVISION REVISION TODOs: RELEASE over the rest of jw-pkg needs to be adjusted by a later commit. Syntax.SEM_VER should also be renamed, to Syntax.JW_PKG maybe, because, as said, it's not SemVer and will probably never be - SemVer doesn't provide the compatibility guarantees that the jw-pkg versioning scheme offers. To be documented. Signed-off-by: Jan Lindemann <jan@janware.com>
2026-09-09 06:29:23 +02:00
# 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')