diff --git a/src/python/jw/pkg/lib/version/Dependency.py b/src/python/jw/pkg/lib/version/Dependency.py index 01c710c8..f09b4e30 100644 --- a/src/python/jw/pkg/lib/version/Dependency.py +++ b/src/python/jw/pkg/lib/version/Dependency.py @@ -70,17 +70,18 @@ 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: - 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( @@ -132,7 +133,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) @@ -147,11 +148,12 @@ 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 - revision of VERSION specs. as_range expands a boundary that - pins a full version into the range it spans. no_subpackages + 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 + 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. """ @@ -161,12 +163,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/src/python/jw/pkg/lib/version/Version.py b/src/python/jw/pkg/lib/version/Version.py index ee57b444..aee96cc3 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: @@ -135,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 @@ -145,8 +150,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) @@ -215,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 337219a6..72a62ba2 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,34 +105,38 @@ 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 +# 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-45' +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-45' -# '<' 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-45' -# versions without a full major.minor.micro-revision are not expanded +assert d.constraint_str(as_range = True) == 'foo <= 1.2.3' +# 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' +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' +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 @@ -197,6 +201,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' @@ -213,7 +240,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