From 40a12571e24b01875ee137669a142b5cfee84c2f Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Wed, 9 Sep 2026 06:29:23 +0200 Subject: [PATCH 1/3] 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 --- src/python/jw/pkg/lib/version/Dependency.py | 145 +++++++++++ src/python/jw/pkg/lib/version/Makefile | 4 + src/python/jw/pkg/lib/version/Version.py | 177 +++++++++++++ src/python/jw/pkg/lib/version/base.py | 39 +++ test/unit/python/jw/pkg/lib/version/Makefile | 7 + test/unit/python/jw/pkg/lib/version/test.py | 252 +++++++++++++++++++ 6 files changed, 624 insertions(+) create mode 100644 src/python/jw/pkg/lib/version/Dependency.py create mode 100644 src/python/jw/pkg/lib/version/Makefile create mode 100644 src/python/jw/pkg/lib/version/Version.py create mode 100644 src/python/jw/pkg/lib/version/base.py create mode 100644 test/unit/python/jw/pkg/lib/version/Makefile create mode 100644 test/unit/python/jw/pkg/lib/version/test.py diff --git a/src/python/jw/pkg/lib/version/Dependency.py b/src/python/jw/pkg/lib/version/Dependency.py new file mode 100644 index 00000000..18ba9380 --- /dev/null +++ b/src/python/jw/pkg/lib/version/Dependency.py @@ -0,0 +1,145 @@ +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 + + 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]: + 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: + + 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]: + return [ + Dependency(spec = spec.strip(), lookup_version = lookup_version) + for spec in spec.split(',') + ] diff --git a/src/python/jw/pkg/lib/version/Makefile b/src/python/jw/pkg/lib/version/Makefile new file mode 100644 index 00000000..7a83c333 --- /dev/null +++ b/src/python/jw/pkg/lib/version/Makefile @@ -0,0 +1,4 @@ +TOPDIR = ../../../../../.. + +include $(TOPDIR)/make/proj.mk +include $(JWBDIR)/make/py-mod.mk diff --git a/src/python/jw/pkg/lib/version/Version.py b/src/python/jw/pkg/lib/version/Version.py new file mode 100644 index 00000000..4bf9af5d --- /dev/null +++ b/src/python/jw/pkg/lib/version/Version.py @@ -0,0 +1,177 @@ +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 + + 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: + 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: + 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: + 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: + 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: + return Version( + self.__name, + f'{self.major}.{self.minor}.{self.micro + 1}', + self.__lookup_version, + ) + + @property + def next_source_incompatible(self) -> Version: + return Version( + self.__name, + f'{self.major}.{self.minor + 1}.0', + self.__lookup_version, + ) diff --git a/src/python/jw/pkg/lib/version/base.py b/src/python/jw/pkg/lib/version/base.py new file mode 100644 index 00000000..cedbce01 --- /dev/null +++ b/src/python/jw/pkg/lib/version/base.py @@ -0,0 +1,39 @@ +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 + DEBIAN = auto() + SEM_VER = auto() + NAMES_ONLY = auto() + +class Component(Flag): # export + ID = auto() + MAJOR = auto() + MINOR = auto() + MICRO = auto() + REVISION = auto() + CORE = MAJOR | MINOR | MICRO + +class Boundary(NamedTuple): # export + 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 diff --git a/test/unit/python/jw/pkg/lib/version/Makefile b/test/unit/python/jw/pkg/lib/version/Makefile new file mode 100644 index 00000000..de693650 --- /dev/null +++ b/test/unit/python/jw/pkg/lib/version/Makefile @@ -0,0 +1,7 @@ +TOPDIR = ../../../../../../.. + +include $(TOPDIR)/make/proj.mk +include $(JWBDIR)/make/py-run.mk + +all: +test: run diff --git a/test/unit/python/jw/pkg/lib/version/test.py b/test/unit/python/jw/pkg/lib/version/test.py new file mode 100644 index 00000000..84be31d9 --- /dev/null +++ b/test/unit/python/jw/pkg/lib/version/test.py @@ -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') -- 2.55.0 From 4e5a4754af1b7cbe8d4cb0a894c4f2a4073578ee Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Wed, 9 Sep 2026 14:44:37 +0200 Subject: [PATCH 2/3] cmds.projects.lib.pkg_relations: Integrate lib.version - The pkg_relations() function is a horribly bad read. It contains a mind-bending amount of interwoven case distinctions that are not clearly reflected in the participating variable and function names. Much of it is version handling. That's intricate by nature, but much of it has now been implemented in the lib.version module, so moving the logic there makes the function a good deal more readable than before. That's most of what this commit does. - Use version syntax macros from lib.version instead of pkg_relations-defined macros, and fix the fallout in BaseCmdPkgRelations and CmdCreateFile. - Use lib.version also as a central place for dep string parsing from App. Signed-off-by: Jan Lindemann --- src/python/jw/pkg/App.py | 5 +- .../pkg/cmds/projects/BaseCmdPkgRelations.py | 8 +- .../jw/pkg/cmds/projects/CmdCreateFile.py | 5 +- .../jw/pkg/cmds/projects/lib/pkg_relations.py | 141 +++++------------- 4 files changed, 46 insertions(+), 113 deletions(-) diff --git a/src/python/jw/pkg/App.py b/src/python/jw/pkg/App.py index 07f23d2d..fe93c585 100644 --- a/src/python/jw/pkg/App.py +++ b/src/python/jw/pkg/App.py @@ -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: diff --git a/src/python/jw/pkg/cmds/projects/BaseCmdPkgRelations.py b/src/python/jw/pkg/cmds/projects/BaseCmdPkgRelations.py index 66004f96..98c42c27 100644 --- a/src/python/jw/pkg/cmds/projects/BaseCmdPkgRelations.py +++ b/src/python/jw/pkg/cmds/projects/BaseCmdPkgRelations.py @@ -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)), diff --git a/src/python/jw/pkg/cmds/projects/CmdCreateFile.py b/src/python/jw/pkg/cmds/projects/CmdCreateFile.py index b852ac80..dfe5b95f 100644 --- a/src/python/jw/pkg/cmds/projects/CmdCreateFile.py +++ b/src/python/jw/pkg/cmds/projects/CmdCreateFile.py @@ -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, diff --git a/src/python/jw/pkg/cmds/projects/lib/pkg_relations.py b/src/python/jw/pkg/cmds/projects/lib/pkg_relations.py index 2b470d79..9a14fbe9 100644 --- a/src/python/jw/pkg/cmds/projects/lib/pkg_relations.py +++ b/src/python/jw/pkg/cmds/projects/lib/pkg_relations.py @@ -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: - 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: - 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: - log(DEBUG, f'Appending dependency >{dep_str}<') - ret.append(dep_str) + expand_version_macros = subsec == 'jw' + if dont_expand_version_macros and dep_name in cur_pkgs: + expand_version_macros = False + if hide_self and dep_name in seed_pkgs: + continue + 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 -- 2.55.0 From a805972a3e7a6163d3733efd18e3806618cc361f Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Wed, 9 Sep 2026 13:52:30 +0200 Subject: [PATCH 3/3] lib.version: Add API docstrings The lib.version module carries no documentation: the Syntax, Component and Boundary types, the Version and Dependency classes and their public methods are bare, and the compatibility tiers the next_* stepping methods implement are not documented anywhere. Add docstrings: a line each for the base.py types, the compatibility tier table in the Version class, and a short description for every public method, with the three next_* methods citing the tier each one steps to. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/version/Dependency.py | 18 ++++++++++ src/python/jw/pkg/lib/version/Version.py | 40 +++++++++++++++++++++ src/python/jw/pkg/lib/version/base.py | 8 ++++- 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/python/jw/pkg/lib/version/Dependency.py b/src/python/jw/pkg/lib/version/Dependency.py index 18ba9380..2ef02c42 100644 --- a/src/python/jw/pkg/lib/version/Dependency.py +++ b/src/python/jw/pkg/lib/version/Dependency.py @@ -14,6 +14,10 @@ if TYPE_CHECKING: 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 @@ -106,6 +110,9 @@ class Dependency: # export 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( @@ -117,6 +124,16 @@ class Dependency: # export 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 @@ -139,6 +156,7 @@ class Dependency: # export 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(',') diff --git a/src/python/jw/pkg/lib/version/Version.py b/src/python/jw/pkg/lib/version/Version.py index 4bf9af5d..ee57b444 100644 --- a/src/python/jw/pkg/lib/version/Version.py +++ b/src/python/jw/pkg/lib/version/Version.py @@ -11,6 +11,17 @@ 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 @@ -106,6 +117,7 @@ class Version: # export @classmethod def strip_package_suffix(cls, name: str) -> str: + """Remove the -dev, -devel or -run subpackage suffix""" return cls.__SUFFIX_RE.sub('', name) @property @@ -121,11 +133,21 @@ class Version: # export @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) @@ -150,6 +172,14 @@ class Version: # export @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: @@ -162,6 +192,11 @@ class Version: # export @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}', @@ -170,6 +205,11 @@ class Version: # export @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', diff --git a/src/python/jw/pkg/lib/version/base.py b/src/python/jw/pkg/lib/version/base.py index cedbce01..4d9a12f0 100644 --- a/src/python/jw/pkg/lib/version/base.py +++ b/src/python/jw/pkg/lib/version/base.py @@ -8,11 +8,15 @@ 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() @@ -21,10 +25,12 @@ class Component(Flag): # export 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: -- 2.55.0