jw-pkg/src/python/jw/pkg/lib/version/Dependency.py
Jan Lindemann 7d2217597b
lib.version.Dependency: Beautify exceptions
Add a dependent_package parameter to Dependency's constructor, and use it
in exceptions the class raises.

Signed-off-by: Jan Lindemann <jan@janware.com>
2026-09-10 08:11:01 +02:00

177 lines
5.9 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}"')
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(',')
]