From f689e4ba944a00a34209e1f3b276544901e17049 Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Tue, 15 Sep 2026 19:56:47 +0200 Subject: [PATCH 1/5] lib.version.Version.__resolved_id(): Resolve VERSION to core __resolved_id() returns the full version if only VERSION was specified, fix that. Also: raise Version.Error instead of a bare Exception. Tests written by AI. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.85.1 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/version/Version.py | 20 +++++++++++++------- test/unit/python/jw/pkg/lib/version/test.py | 16 ++++++++-------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/python/jw/pkg/lib/version/Version.py b/src/python/jw/pkg/lib/version/Version.py index ee57b444..0e204313 100644 --- a/src/python/jw/pkg/lib/version/Version.py +++ b/src/python/jw/pkg/lib/version/Version.py @@ -38,11 +38,17 @@ class Version: # export 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 + resolved = self.__lookup_version(self.base_name) + if self.__spec == 'VERSION-REVISION': + return resolved + parts = resolved.split('-') + match self.__spec: + case 'VERSION': + return parts[0] + case 'REVISION': + return parts[1] if len(parts) == 2 else '' + case _: + raise Version.Error(f'Invalid version specifier "{self.__spec}"') def __id(self, untemplate: bool, throw: bool = True) -> str: if not untemplate: @@ -145,8 +151,8 @@ class Version: # export 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. + else the resolved version. A VERSION spec yields the core + version. """ if not include_revision and self.__spec == 'VERSION': return self.core(untemplate) diff --git a/test/unit/python/jw/pkg/lib/version/test.py b/test/unit/python/jw/pkg/lib/version/test.py index 337219a6..74962bd1 100644 --- a/test/unit/python/jw/pkg/lib/version/test.py +++ b/test/unit/python/jw/pkg/lib/version/test.py @@ -62,7 +62,7 @@ except Version.Error: # The lookup resolves the macros d = Dependency('foo = VERSION', app.get_version) -assert d.constraint_str() == 'foo = 1.2.3-45' +assert d.constraint_str() == 'foo = 1.2.3' 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 @@ -89,7 +89,7 @@ except Dependency.Error: # 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' +assert d.constraint_str() == 'foo-devel = 1.2.3' # Unknown projects fail when untemplating try: Dependency('baz = VERSION', app.get_version).constraint_str() @@ -105,8 +105,8 @@ def mapper(project: str) -> str: 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 d.constraint_str() == 'foo = 1.2.3' +assert d.constraint_str() == 'foo = 1.2.3' assert calls == ['foo'] # as_range expands full boundaries into a revision range @@ -122,12 +122,12 @@ 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' +assert d.constraint_str(as_range = True) == 'foo = 1.2.3' d = Dependency('foo >= VERSION', app.get_version) -assert d.constraint_str(as_range = True) == 'foo >= 1.2.3-45' +assert d.constraint_str(as_range = True) == 'foo >= 1.2.3' # '<' and '<=' are not expanded d = Dependency('foo <= VERSION', app.get_version) -assert d.constraint_str(as_range = True) == 'foo <= 1.2.3-45' +assert d.constraint_str(as_range = True) == 'foo <= 1.2.3' # 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' @@ -213,7 +213,7 @@ 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[0].constraint_str() == 'foo = 1.2.3' assert deps[1].constraint_str() == 'bar' # Malformed specs are rejected when parsed From a5885283d031f864a58fc883e1f57a880d78a07d Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Tue, 15 Sep 2026 19:57:47 +0200 Subject: [PATCH 2/5] lib.version.Dependency.__version_boundaries(): Expand non-full specs Full version dependency specs ("= 1.2.3-4" or "= VERSION-REVISION") don't need expansion, they pin the wanted version with an = fine exactly. __version_boundary has that the wrong way around, fix that. Also fix the is_full and version_boundaries() docstrings, which still describe the old behavior. Tests written by AI. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.85.1 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/version/Dependency.py | 4 ++-- src/python/jw/pkg/lib/version/Version.py | 5 ++-- test/unit/python/jw/pkg/lib/version/test.py | 26 +++++++++------------ 3 files changed, 15 insertions(+), 20 deletions(-) diff --git a/src/python/jw/pkg/lib/version/Dependency.py b/src/python/jw/pkg/lib/version/Dependency.py index 01c710c8..3b85413c 100644 --- a/src/python/jw/pkg/lib/version/Dependency.py +++ b/src/python/jw/pkg/lib/version/Dependency.py @@ -70,7 +70,7 @@ class Dependency: # export specified = self.__parsed_spec[1] if specified is None: return [] - if not expanded or not specified.version.is_full: + if not expanded or specified.version.is_full: return (specified, ) ret: list[Boundary] = [] match specified.op: @@ -132,7 +132,7 @@ class Dependency: # export 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. + expanded is True and it does not pin a full version. """ return self.__version_boundaries(expanded) diff --git a/src/python/jw/pkg/lib/version/Version.py b/src/python/jw/pkg/lib/version/Version.py index 0e204313..8f81f666 100644 --- a/src/python/jw/pkg/lib/version/Version.py +++ b/src/python/jw/pkg/lib/version/Version.py @@ -141,9 +141,8 @@ class Version: # export 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. + MAJOR.MINOR.MICRO-REVISION. Range expansion leaves full + versions exact and expands the non-full specs. """ if self.__spec == 'VERSION-REVISION': return True diff --git a/test/unit/python/jw/pkg/lib/version/test.py b/test/unit/python/jw/pkg/lib/version/test.py index 74962bd1..5f03f9de 100644 --- a/test/unit/python/jw/pkg/lib/version/test.py +++ b/test/unit/python/jw/pkg/lib/version/test.py @@ -109,32 +109,28 @@ assert d.constraint_str() == 'foo = 1.2.3' assert d.constraint_str() == 'foo = 1.2.3' assert calls == ['foo'] -# as_range expands full boundaries into a revision range +# as_range expands a non-full boundary into the range it spans d = Dependency('foo = VERSION-REVISION', app.get_version) -assert d.constraint_str(as_range = True) == 'foo >= 1.2.3-45 foo < 1.2.4' +assert d.constraint_str(as_range = True) == 'foo = 1.2.3-45' d = Dependency('foo >= VERSION-REVISION', app.get_version) -assert d.constraint_str(as_range = True) == 'foo >= 1.2.3-45 foo < 1.2.4' +assert d.constraint_str(as_range = True) == 'foo >= 1.2.3-45' 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 +assert d.constraint_str(as_range = True) == 'foo > 1.0.0-259' +# A raw render keeps the macro as written 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 +assert constraint == 'foo = VERSION-REVISION' +# A full version stays exact; a non-full one expands d = Dependency('foo = VERSION', app.get_version) -assert d.constraint_str(as_range = True) == 'foo = 1.2.3' +assert d.constraint_str(as_range = True) == 'foo >= 1.2.3 foo < 1.2.4' d = Dependency('foo >= VERSION', app.get_version) -assert d.constraint_str(as_range = True) == 'foo >= 1.2.3' +assert d.constraint_str(as_range = True) == 'foo >= 1.2.3 foo < 1.2.4' # '<' and '<=' are not expanded d = Dependency('foo <= VERSION', app.get_version) assert d.constraint_str(as_range = True) == 'foo <= 1.2.3' -# 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' +# The bound steps a micro, so a tripartite core expands 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' +assert d.constraint_str(as_range = True) == 'foo >= 1.2.3 foo < 1.2.4' # Default syntax is SEM_VER d = Dependency('foo < 2.0') assert d.constraint_str(untemplated = False) == 'foo < 2.0' From c23a2e4ab2b97de4ea29726549ae3a7a220f7fae Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Tue, 15 Sep 2026 17:24:39 +0200 Subject: [PATCH 3/5] lib.version.Dependency.constraint_str(): Delimit by comma A multi-boundary constraint is rendered as 'foo >= 1.2.3-45 foo < 1.2.4', which is invalid: the RPM spec template writes the Requires: line from it verbatim, and RPM entries are comma-separated, so the second clause is swallowed into one bad entry. Debian's format_depends() normalizes runs of whitespace to commas, so it tolerates the space join, but the comma is the only delimiter that is correct for both. Join the boundary clauses with ', ' instead of a space. Also: - Drop the stray trailing comma in the version_boundaries() call. - Fix the untemplated docstring, which describes the opposite of the code: untemplated = True (the default) resolves the macros, untemplated = False keeps them as written. Tests written by AI. Signed-off-by: Jan Lindemann Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.85.1 --- src/python/jw/pkg/lib/version/Dependency.py | 10 +++++----- test/unit/python/jw/pkg/lib/version/test.py | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/python/jw/pkg/lib/version/Dependency.py b/src/python/jw/pkg/lib/version/Dependency.py index 3b85413c..b9fb9f49 100644 --- a/src/python/jw/pkg/lib/version/Dependency.py +++ b/src/python/jw/pkg/lib/version/Dependency.py @@ -147,9 +147,9 @@ class Dependency: # export ) -> 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 + NAMES_ONLY renders the name alone. With untemplated = False the + VERSION, VERSION-REVISION and REVISION macros are kept as + written instead of resolved. 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 @@ -161,12 +161,12 @@ class Dependency: # export if syntax is Syntax.NAMES_ONLY: return name ret: list[str] = [] - for boundary in self.version_boundaries(expanded = as_range, ): + 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 ', '.join(ret) return name return __str() if quote is None else f'{quote}{__str()}{quote}' diff --git a/test/unit/python/jw/pkg/lib/version/test.py b/test/unit/python/jw/pkg/lib/version/test.py index 5f03f9de..2c87c198 100644 --- a/test/unit/python/jw/pkg/lib/version/test.py +++ b/test/unit/python/jw/pkg/lib/version/test.py @@ -122,15 +122,15 @@ constraint = d.constraint_str(untemplated = False, as_range = True) assert constraint == 'foo = VERSION-REVISION' # A full version stays exact; a non-full one expands d = Dependency('foo = VERSION', app.get_version) -assert d.constraint_str(as_range = True) == 'foo >= 1.2.3 foo < 1.2.4' +assert d.constraint_str(as_range = True) == 'foo >= 1.2.3, foo < 1.2.4' d = Dependency('foo >= VERSION', app.get_version) -assert d.constraint_str(as_range = True) == 'foo >= 1.2.3 foo < 1.2.4' +assert d.constraint_str(as_range = True) == 'foo >= 1.2.3, foo < 1.2.4' # '<' and '<=' are not expanded d = Dependency('foo <= VERSION', app.get_version) assert d.constraint_str(as_range = True) == 'foo <= 1.2.3' # The bound steps a micro, so a tripartite core expands d = Dependency('foo = 1.2.3', app.get_version) -assert d.constraint_str(as_range = True) == 'foo >= 1.2.3 foo < 1.2.4' +assert d.constraint_str(as_range = True) == 'foo >= 1.2.3, foo < 1.2.4' # Default syntax is SEM_VER d = Dependency('foo < 2.0') assert d.constraint_str(untemplated = False) == 'foo < 2.0' From 83b5151f228b15a9e2f9ea9bb42ad0b7065013ae Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Tue, 15 Sep 2026 19:57:08 +0200 Subject: [PATCH 4/5] lib.version.Version.next(): Step the last part Range expansion needs a bound that steps the last existing part of a version, add that. next() increments the last part, whatever it is: next of '1' is '2', of '1.0' is '1.1', of '1.2.3' is '1.2.4', of '1.2.3-45' is '1.2.3-46'. Tests written by AI. Signed-off-by: Jan Lindemann Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.85.1 --- src/python/jw/pkg/lib/version/Version.py | 25 +++++++++++++++++++++ test/unit/python/jw/pkg/lib/version/test.py | 23 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/python/jw/pkg/lib/version/Version.py b/src/python/jw/pkg/lib/version/Version.py index 8f81f666..aee96cc3 100644 --- a/src/python/jw/pkg/lib/version/Version.py +++ b/src/python/jw/pkg/lib/version/Version.py @@ -220,3 +220,28 @@ class Version: # export f'{self.major}.{self.minor + 1}.0', self.__lookup_version, ) + + @property + def next(self) -> Version: + """The next version in the series: the last part + incremented. next of '1' is '2', of '1.0' is '1.1', of + '1.2.3' is '1.2.4', of '1.2.3-45' is '1.2.3-46'. The + separators of the spec are kept. Like in + next_binary_compatible(), only the last part's leading + digits count: a suffixed part such as '4blah' steps to + '5'. A part without leading digits raises Version.Error. + """ + untemplated = self.__id(untemplate = True, throw = True) + parts = re.split('([.-])', untemplated) + m = re.match('[0-9]+', parts[-1]) + if not m: + raise Version.Error( + f'Cannot step version "{self.__spec}": the last ' + f'part "{parts[-1]}" has no leading digits' + ) + parts[-1] = str(int(m.group()) + 1) + return Version( + self.__name, + ''.join(parts), + self.__lookup_version, + ) diff --git a/test/unit/python/jw/pkg/lib/version/test.py b/test/unit/python/jw/pkg/lib/version/test.py index 2c87c198..b3525225 100644 --- a/test/unit/python/jw/pkg/lib/version/test.py +++ b/test/unit/python/jw/pkg/lib/version/test.py @@ -193,6 +193,29 @@ for spec in ['1.2.3-rc1', '1.2.3']: except Version.Error: pass +# next steps the last existing part, whatever it is +for spec, expected in [ + ('1', '2'), + ('1.0', '1.1'), + ('1.2.3', '1.2.4'), + ('1.2.3-45', '1.2.3-46'), + ('1.2.3-4blah', '1.2.3-5'), + ('1.2.3.4', '1.2.3.5'), + ('1.2.3rc1', '1.2.4'), + ('VERSION', '1.2.4'), +]: + v = Version('foo', spec, app.get_version) + assert str(v.next) == expected, spec + +# A last part without leading digits cannot be stepped +for spec in ['1.0-rc1', '1.2.alpha']: + v = Version('foo', spec) + try: + v.next + 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' From 3f00f4a416bff46b1c4228bed286eba66bc07227 Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Tue, 15 Sep 2026 19:58:24 +0200 Subject: [PATCH 5/5] lib.version.Dependency.__version_boundaries(): Use Version.next() Use Version.next() in __version_boundaries, which steps the last existing part, so '= 1.0' spans '>= 1.0, < 1.1'. Tests written by AI. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.85.1 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/version/Dependency.py | 16 +++++++++------- test/unit/python/jw/pkg/lib/version/test.py | 16 ++++++++++++---- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/python/jw/pkg/lib/version/Dependency.py b/src/python/jw/pkg/lib/version/Dependency.py index b9fb9f49..f09b4e30 100644 --- a/src/python/jw/pkg/lib/version/Dependency.py +++ b/src/python/jw/pkg/lib/version/Dependency.py @@ -74,13 +74,14 @@ class Dependency: # export return (specified, ) ret: list[Boundary] = [] match specified.op: - case '>' | '>=': - ret.append(specified) - ret.append(Boundary('<', specified.version.next_binary_incompatible)) case '=': + try: + next_version = specified.version.next + except Version.Error: + return (specified, ) ret.append(Boundary('>=', specified.version)) - ret.append(Boundary('<', specified.version.next_binary_incompatible)) - case '<' | '<=': + ret.append(Boundary('<', next_version)) + case '>' | '>=' | '<' | '<=': ret.append(specified) case _: self.__raise( @@ -150,8 +151,9 @@ class Dependency: # export NAMES_ONLY renders the name alone. With untemplated = False the VERSION, VERSION-REVISION and REVISION macros are kept as written instead of resolved. 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 + revision of VERSION specs. as_range expands a = boundary that + does not pin a full version into the range it spans; other + operators pass through unchanged. no_subpackages renders the base name, quote wraps the result in the given string. """ diff --git a/test/unit/python/jw/pkg/lib/version/test.py b/test/unit/python/jw/pkg/lib/version/test.py index b3525225..72a62ba2 100644 --- a/test/unit/python/jw/pkg/lib/version/test.py +++ b/test/unit/python/jw/pkg/lib/version/test.py @@ -109,7 +109,7 @@ assert d.constraint_str() == 'foo = 1.2.3' assert d.constraint_str() == 'foo = 1.2.3' assert calls == ['foo'] -# as_range expands a non-full boundary into the range it spans +# as_range expands a non-full = boundary into the range it spans d = Dependency('foo = VERSION-REVISION', app.get_version) assert d.constraint_str(as_range = True) == 'foo = 1.2.3-45' d = Dependency('foo >= VERSION-REVISION', app.get_version) @@ -123,14 +123,22 @@ assert constraint == 'foo = VERSION-REVISION' # A full version stays exact; a non-full one expands d = Dependency('foo = VERSION', app.get_version) assert d.constraint_str(as_range = True) == 'foo >= 1.2.3, foo < 1.2.4' +# Operators other than = pass through unchanged d = Dependency('foo >= VERSION', app.get_version) -assert d.constraint_str(as_range = True) == 'foo >= 1.2.3, foo < 1.2.4' -# '<' and '<=' are not expanded +assert d.constraint_str(as_range = True) == 'foo >= 1.2.3' d = Dependency('foo <= VERSION', app.get_version) assert d.constraint_str(as_range = True) == 'foo <= 1.2.3' -# The bound steps a micro, so a tripartite core expands +# The bound steps the last part, whatever it is +d = Dependency('foo = 1.0', app.get_version) +assert d.constraint_str(as_range = True) == 'foo >= 1.0, foo < 1.1' d = Dependency('foo = 1.2.3', app.get_version) assert d.constraint_str(as_range = True) == 'foo >= 1.2.3, foo < 1.2.4' +d = Dependency('foo = 1.2.3rc1', app.get_version) +assert d.constraint_str(as_range = True) == 'foo >= 1.2.3rc1, foo < 1.2.4' +# A last part without leading digits cannot be stepped, so the pin +# stays exact +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'