jw-pkg/test/unit/python/jw/pkg/lib/version/test.py
Jan Lindemann 8c1e37500b
All checks were successful
CI / Packaging - Kali Linux (pull_request) Successful in 4m24s
CI / Packaging - OpenSUSE Tumbleweed (pull_request) Successful in 4m34s
CI / Packaging test (pull_request) Successful in 0s
lib.version.Dependency: Reject unsupported spec operators
The spec split pattern ([=><]+) tokenizes only the =, > and < characters,
so a PEP 440 operator like ~= or != leaves its ~ or ! glued to the package
name: Dependency('pkg~=1.0') parses to base name 'pkg~' with operator '='.
App.__get_project_refs() carries the same pattern inline, so the same specs
corrupt the module name used for the -devel subpackage check. The split
also tolerates operator strings the language does not support, most visibly
==, which parses and renders but raises NotImplementedError only when
expansion is requested.

The spec language supports exactly =, <, <=, > and >=, and boundary
expansion implements all of them. Extend the split pattern with ~ and ! so
that foreign operators tokenize as operator strings, and reject every
operator outside the supported set in Dependency.__parsed_spec() with
Dependency.Error, naming the supported operators. Route
App.__get_project_refs() through Dependency for the name and module parts
instead of the second inline split, so the validation lives in one place.
The catch all in Dependency.__version_boundaries() stays as a backstop
against drift between the allow list and the expansion cases.

The tests drop == from the accepted operator loop, drop the now-unreachable
not-expandable case, and assert that ~=, !=, ~, ==, ===, << and >> are
rejected at parse time with a message naming the operator.

Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.85.1
Signed-off-by: Jan Lindemann <jan@janware.com>
2026-09-11 10:52:20 +02:00

257 lines
9.1 KiB
Python

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
# 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'
# 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
# Operators outside the supported set are rejected when parsed,
# not folded into the package name or deferred to expansion
for bad in [
'foo ~= 1.0',
'foo~=1.0',
'foo ~ 1.0',
'foo != 1.0',
'foo!=1.0',
'foo == 1.0',
'foo==1.0',
'foo === 1.0',
'foo << 1.0',
'foo >> 1.0',
]:
try:
Dependency(bad).full_name
assert False, f'Should have raised for {bad!r}'
except Dependency.Error as e:
assert 'unsupported operator' in str(e)
# str
assert str(Dependency('foo-devel >= 1.0')) == 'foo-devel >= 1.0'
assert str(Dependency('foo')) == 'foo'
print('All lib.version tests passed')