jw-pkg/src/python/jw/pkg/lib/version/Dependency.py
Jan Lindemann c9cbef15bb 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 15:37:50 +02:00

145 lines
4.7 KiB
Python

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(',')
]