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