All checks were successful
CI / Packaging - Kali Linux (pull_request) Successful in 4m27s
CI / Packaging - OpenSUSE Tumbleweed (pull_request) Successful in 4m54s
CI / Packaging test (pull_request) Successful in 0s
CI / Packaging - Kali Linux (push) Successful in 4m0s
CI / Packaging - OpenSUSE Tumbleweed (push) Successful in 4m17s
CI / Packaging test (push) Successful in 0s
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>
188 lines
6.5 KiB
Python
188 lines
6.5 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
from functools import cached_property
|
|
from typing import TYPE_CHECKING, override
|
|
|
|
from ..log import get_caller_pos
|
|
from .base import Boundary, Lookup, Syntax
|
|
from .Version import Version
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Sequence
|
|
from typing import ClassVar, Never
|
|
|
|
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
|
|
|
|
__SPLIT_RE: ClassVar[re.Pattern[str]] = re.compile('([~=><!]+)')
|
|
|
|
@property
|
|
def __target_prefix(self) -> str:
|
|
if self.__dependent_package is None:
|
|
return ''
|
|
return f' Package "{self.__dependent_package}"'
|
|
|
|
def __raise(self, msg: str, cls: type[Exception] = Error) -> Never:
|
|
mod, file, line = get_caller_pos()
|
|
raise cls(f'[{file}:{line}]{self.__target_prefix}: {msg}')
|
|
|
|
@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]:
|
|
self.__raise(f'Invalid dependency spec "{self.__spec}"')
|
|
return parts[0], None
|
|
case 3:
|
|
if not parts[0] or not parts[2]:
|
|
self.__raise(f'Invalid dependency spec "{self.__spec}"')
|
|
if parts[1] not in ('=', '<', '<=', '>', '>='):
|
|
self.__raise(
|
|
f'Spec "{self.__spec}": unsupported operator '
|
|
f'"{parts[1]}", supported operators are =, <, '
|
|
'<=, > and >='
|
|
)
|
|
if parts[2] == 'REVISION':
|
|
self.__raise(
|
|
f'Spec "{self.__spec}": a bare REVISION renders as a '
|
|
'bare number, which is likely not what the user intended'
|
|
)
|
|
return parts[0], Boundary(
|
|
op=parts[1],
|
|
version=Version(parts[0], parts[2], self.__lookup_version),
|
|
)
|
|
case _:
|
|
self.__raise(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 _:
|
|
self.__raise(
|
|
(
|
|
'Expanding version boundary '
|
|
f'"{self.full_name} {specified.op} {specified.version}" '
|
|
'is not yet implemented'
|
|
),
|
|
cls = NotImplementedError,
|
|
)
|
|
return ret
|
|
|
|
# -- Public API
|
|
|
|
def __init__(
|
|
self,
|
|
spec: str,
|
|
lookup_version: Lookup | None = None,
|
|
dependent_package: str | None = None,
|
|
) -> None:
|
|
self.__spec = spec
|
|
self.__lookup_version = lookup_version
|
|
self.__dependent_package = dependent_package
|
|
|
|
@override
|
|
def __str__(self) -> str:
|
|
return self.__spec
|
|
|
|
@property
|
|
def dependent_package(self) -> str | None:
|
|
return self.__dependent_package
|
|
|
|
@cached_property
|
|
def current_version(self) -> str:
|
|
if not self.__lookup_version:
|
|
self.__raise(
|
|
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]:
|
|
"""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(
|
|
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:
|
|
"""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
|
|
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,
|
|
dependent_package: str | None = None,
|
|
) -> Sequence[Dependency]:
|
|
"""Split a comma-separated specification into Dependency objects"""
|
|
return [
|
|
Dependency(
|
|
spec = spec.strip(),
|
|
lookup_version = lookup_version,
|
|
dependent_package = dependent_package,
|
|
) for spec in spec.split(',')
|
|
]
|