Source code for portmodlib.atom

# Copyright 2019-2021 Portmod Authors
# Distributed under the terms of the GNU General Public License v3

import re
from collections import namedtuple
from typing import AbstractSet, Any, Dict, Optional, Set, TypeVar

"""
Module for handling package atoms

All atom classes defined in this module should be considered read-only.
Modification of an Atom object may have unexpected side-effects
"""

flag_re = r"[A-Za-z0-9][A-Za-z0-9+_-]*"
useflag_re = re.compile(r"^" + flag_re + r"$")
usedep_re = (
    r"(?P<prefix>[!-]?)(?P<flag>"
    + flag_re
    + r")(?P<default>(\(\+\)|\(\-\))?)(?P<suffix>[?=]?)"
)
_usedep_re = re.compile("^" + usedep_re + "$")

op_re = r"(?P<B>(!!))?(?P<OP>([<>]=?|[<>=~]))?"
cat_re = r"((?P<C>[A-Za-z0-9][A-Za-z0-9\-]*)/)?"
ver_re = r"(\d+)((\.\d+)*)([a-z]?)((_(pre|p|beta|alpha|rc)\d*)*)"
rev_re = r"(-(?P<PR>r[0-9]+))?"
repo_re = r"(::(?P<R>[A-Za-z0-9_][A-Za-z0-9_-]*(::installed)?))?"
_atom_re = re.compile(
    op_re
    + cat_re
    + r"(?P<P>(?P<PN>[A-Za-z0-9+_-]+?)(-(?P<PV>"
    + ver_re
    + r"))?)"
    + rev_re
    + repo_re
    + r"(\[(?P<USE>.*)\])?$"
)


class InvalidAtom(Exception):
    "Exception indicating an atom has invalid syntax"


class UnqualifiedAtom(Exception):
    """
    Exception indicating an atom, which was expected to be
    qualified with a category, has no category
    """

    def __init__(self, atom):
        self.atom = atom

    def __str__(self):
        return f"Atom {self.atom} was expected to have a category!"


T = TypeVar("T", bound="Atom")


class Atom(str):
    CP: Optional[str]
    """
    The category, package name and version, excluding revision

    E.g. ``base/example-suite-1.0``
    """
    CPN: Optional[str]
    """
    The category and package name

    E.g. ``base/example-suite``
    """
    USE: Set[str] = set()
    """
    Use flags set on the atom.

    E.g. ``{minimal}`` in ``base/example-suite[minimal]``
    """
    P: str
    """
    The package name and version, excluding revision

    E.g. ``example-suite-1.0``
    """
    PN: str
    """
    The package name

    E.g. ``example-suite``
    """
    PF: str
    """
    The package name and version, including revision

    E.g. ``example-suite-1.0-r1``
    """
    PV: Optional[str]
    """
    The package version, not including revision

    E.g. ``1.0``
    """
    PR: Optional[str]
    """
    The package revision

    E.g. ``r1``
    """
    C: Optional[str]
    """
    The package category

    E.g. ``base``
    """
    R: Optional[str]
    """
    The package repository

    E.g. ``openmw`` in base/example-suite-1.0-r1::openmw
    """
    OP: Optional[str]
    """
    An operator.

    See PMS section `8.2.6.1 <https://projects.gentoo.org/pms/7/pms.html#x1-760008.2.6.1>`_,
    noting that Weak blocks are not currently supported by Portmod, and blocks don't show up
    in OP (they instead set the BLOCK field).

    E.g. ``openmw`` in base/example-suite-1.0-r1::openmw
    """
    BLOCK: bool
    """
    If true, this atom is a blocker, referring to a package which must not be installed.

    E.g. ``!!base/example-suite``
    """
    PVR: Optional[str]
    """
    The version and revision

    E.g. 1.0-r1
    """
    CPF: str
    """
    The category, package name and version, including revision

    E.g. ``base/example-suite-1.0-r1``
    """

    _CACHE: Dict[str, Any] = {}

    def __init__(self, atom: str):
        if atom in self._CACHE:
            self.__dict__ = self._CACHE[atom]
            return

        match = _atom_re.match(atom)
        if not match:
            raise InvalidAtom("Invalid atom %s. Cannot parse" % (atom))

        if match.group("P") and match.group("C"):
            self.CP = match.group("C") + "/" + match.group("P")
            self.CPN = match.group("C") + "/" + match.group("PN")
        else:
            self.CP = None
            self.CPN = None

        if match.group("USE"):
            self.USE = set(match.group("USE").split(","))
            for x in self.USE:
                m = _usedep_re.match(x)
                if not m:
                    raise InvalidAtom(
                        "Invalid use dependency {} in atom {}".format(atom, x)
                    )

        if match.group("PR"):
            self.PF = match.group("P") + "-" + match.group("PR")
        else:
            self.PF = match.group("P")

        self.P = match.group("P")
        self.PN = match.group("PN")
        self.PV = match.group("PV")
        self.PR = match.group("PR")
        self.C = match.group("C")
        self.R = match.group("R")
        self.OP = match.group("OP")
        self.BLOCK = match.group("B") is not None
        self.PVR = self.PV
        if self.PR:
            self.PVR += "-" + self.PR

        if self.C:
            self.CPF = self.C + "/" + self.PF
        else:
            self.CPF = self.PF

        if self.OP is not None and self.PV is None:
            raise InvalidAtom(
                "Atom %s has a comparison operator but no version!" % (atom)
            )

        self._CACHE[atom] = self.__dict__

    def evaluate_conditionals(self, use: AbstractSet[str]) -> "Atom":
        """
        Create an atom instance with any USE conditionals evaluated.
        @param use: The set of enabled USE flags
        @return: an atom instance with any USE conditionals evaluated
        """
        tokens = set()

        for x in self.USE:
            m = _usedep_re.match(x)

            if m is not None:
                operator = m.group("prefix") + m.group("suffix")
                flag = m.group("flag")
                default = m.group("default")
                if default is None:
                    default = ""

                if operator == "?":
                    if flag in use:
                        tokens.add(flag + default)
                elif operator == "=":
                    if flag in use:
                        tokens.add(flag + default)
                    else:
                        tokens.add("-" + flag + default)
                elif operator == "!=":
                    if flag in use:
                        tokens.add("-" + flag + default)
                    else:
                        tokens.add(flag + default)
                elif operator == "!?":
                    if flag not in use:
                        tokens.add("-" + flag + default)
                else:
                    tokens.add(x)
            else:
                raise Exception("Internal Error when processing atom conditionals")

        atom = Atom(self)
        atom.USE = tokens
        return atom

    def strip_use(self: T) -> T:
        """Returns the equivalent of this atom with the USE dependencies removed"""
        return self.__class__(re.sub(r"\[.*\]", "", str(self)))

    def use(self, *flags: str):
        """returns atom with use flag dependency"""
        return self.__class__(f'{self}[{",".join(flags)}]')

    def with_category(self, category: str) -> "QualifiedAtom":
        return QualifiedAtom(
            (self.BLOCK and "!!" or "")
            + (self.OP or "")
            + category
            + "/"
            + self.PF
            + (self.R or "")
        )


class QualifiedAtom(Atom):
    """Atoms that include categories"""

    CP: str
    CPN: str
    CPF: str
    C: str

    def __init__(self, atom: str):
        super().__init__(atom)

        if not self.C:
            raise UnqualifiedAtom(atom)


class VAtom(QualifiedAtom):
    """Atoms that include version information"""

    PV: str
    PVR: str

    def __init__(self, atom: str):
        super().__init__(atom)
        assert self.PV


class FQAtom(VAtom):
    """Atoms that include all possible non-optional fields"""

    R: str

    def __init__(self, atom: str):
        super().__init__(atom)
        assert self.R


VersionMatch = namedtuple(
    "VersionMatch", ["version", "numeric", "letter", "suffix", "revision"]
)


def suffix_gt(a_suffix: str, b_suffix: str) -> bool:
    """Returns true iff a_suffix > b_suffix"""
    suffixes = ["alpha", "beta", "pre", "rc", "p"]
    return suffixes.index(a_suffix) > suffixes.index(b_suffix)


[docs]def version_gt(version1: str, version2: str) -> bool: """ Version comparision function args: version1: A version string version2: Another version string returns: True if and only if version1 is greater than version2 """ vre = re.compile( r"(?P<NUMERIC>[\d\.]+)" r"(?P<LETTER>[a-z])?" r"(?P<SUFFIX>(_[a-z]+\d*)*)" r"(-r(?P<REV>\d+))?" ) match1 = vre.match(version1) match2 = vre.match(version2) assert match1 is not None assert match2 is not None v_match1 = VersionMatch( version=version1, numeric=match1.group("NUMERIC").split("."), letter=match1.group("LETTER") or "", suffix=match1.group("SUFFIX") or "", revision=int(match1.group("REV") or "0"), ) v_match2 = VersionMatch( version=version2, numeric=match2.group("NUMERIC").split("."), letter=match2.group("LETTER") or "", suffix=match2.group("SUFFIX") or "", revision=int(match2.group("REV") or "0"), ) # Compare numeric components for index, val in enumerate(v_match1.numeric): if index >= len(v_match2.numeric): return True if int(val) > int(v_match2.numeric[index]): return True if int(val) < int(v_match2.numeric[index]): return False if len(v_match2.numeric) > len(v_match1.numeric): return False # Compare letter components if v_match1.letter > v_match2.letter: return True if v_match1.letter < v_match2.letter: return False # Compare suffixes if v_match1.suffix: a_suffixes = v_match1.suffix.lstrip("_").split("_") else: a_suffixes = [] if v_match2.suffix: b_suffixes = v_match2.suffix.lstrip("_").split("_") else: b_suffixes = [] for a_s, b_s in zip(a_suffixes, b_suffixes): asm = re.match(r"(?P<S>[a-z]+)(?P<N>\d+)?", a_s) bsm = re.match(r"(?P<S>[a-z]+)(?P<N>\d+)?", b_s) assert asm assert bsm a_suffix = asm.group("S") b_suffix = bsm.group("S") a_suffix_num = int(asm.group("N") or "0") b_suffix_num = int(bsm.group("N") or "0") if a_suffix == b_suffix: if b_suffix_num > a_suffix_num: return False if a_suffix_num > b_suffix_num: return True elif suffix_gt(a_suffix, b_suffix): return True else: return False # More suffixes implies an earlier version, # except when the suffix is _p if len(a_suffixes) > len(b_suffixes): if a_suffixes[len(b_suffixes)].startswith("p"): return True return False if len(a_suffixes) < len(b_suffixes): if b_suffixes[len(a_suffixes)].startswith("p"): return False return True # Compare revisions if v_match1.revision > v_match2.revision: return True if v_match1.revision < v_match2.revision: return False # Equal return False
def atom_sat(specific: Atom, generic: Atom, *, ignore_name: bool = False) -> bool: """ Determines if a fully qualified atom (can only refer to a single package) satisfies a generic atom """ if not ignore_name: if specific.PN != generic.PN: # Mods must have the same name return False if generic.C and (generic.C != specific.C): # If para defines category, it must match return False if generic.R and (generic.R != specific.R): # If para defines repo, it must match return False if not generic.OP: # Simple atom, either one version or all versions will satisfy # Check if version is correct if generic.PV and (specific.PV != generic.PV): return False # Check if revision is correct if generic.PR and (specific.PR != generic.PR): return False elif generic.PV and specific.PV: assert generic.PVR and specific.PVR equal = specific.PVR == generic.PVR verequal = specific.PV == generic.PV lessthan = version_gt(generic.PVR, specific.PVR) greaterthan = version_gt(specific.PVR, generic.PVR) if generic.OP == "=": return equal if generic.OP == "~": return verequal if generic.OP == "<": return lessthan if generic.OP == "<=": return equal or lessthan if generic.OP == ">": return greaterthan if generic.OP == ">=": return equal or greaterthan return True