From ce00d2144eea6afd7d93627c09c77371de0c99d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Forr=C3=B3?= Date: Sun, 24 Apr 2022 18:49:47 +0200 Subject: [PATCH] Implement modification of %prep macros MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit introduces `MacroOptions` class that allows for modification of macro options while preserving whitespace and formatting. Signed-off-by: Nikola Forró --- README.md | 12 +- specfile/exceptions.py | 4 + specfile/macro_options.py | 532 ++++++++++++++++++++++ specfile/prep.py | 481 ++++++++++--------- specfile/specfile.py | 6 +- tests/constants.py | 1 + tests/data/spec_autopatch/patch0.patch | 18 + tests/data/spec_autopatch/patch1.patch | 19 + tests/data/spec_autopatch/patch2.patch | 20 + tests/data/spec_autopatch/patch3.patch | 20 + tests/data/spec_autopatch/patch4.patch | 20 + tests/data/spec_autopatch/patch5.patch | 20 + tests/data/spec_autopatch/patch6.patch | 20 + tests/data/spec_autopatch/test-0.1.tar.xz | Bin 0 -> 200 bytes tests/data/spec_autopatch/test.spec | 34 ++ tests/integration/conftest.py | 8 + tests/integration/test_specfile.py | 26 +- tests/unit/test_macro_options.py | 239 ++++++++++ tests/unit/test_prep.py | 151 +++--- 19 files changed, 1307 insertions(+), 324 deletions(-) create mode 100644 specfile/macro_options.py create mode 100644 tests/data/spec_autopatch/patch0.patch create mode 100644 tests/data/spec_autopatch/patch1.patch create mode 100644 tests/data/spec_autopatch/patch2.patch create mode 100644 tests/data/spec_autopatch/patch3.patch create mode 100644 tests/data/spec_autopatch/patch4.patch create mode 100644 tests/data/spec_autopatch/patch5.patch create mode 100644 tests/data/spec_autopatch/patch6.patch create mode 100644 tests/data/spec_autopatch/test-0.1.tar.xz create mode 100644 tests/data/spec_autopatch/test.spec create mode 100644 tests/unit/test_macro_options.py diff --git a/README.md b/README.md index 9e1b3d7..2f346ff 100644 --- a/README.md +++ b/README.md @@ -134,14 +134,16 @@ from specfile.prep import AutosetupMacro with specfile.prep() as prep: # name of the first macro print(prep.macros[0].name) - # options of the last macro - print(prep.macros[-1].options) # checking if %autosetup is being used - print('%autosetup' in prep.macros) - print(AutosetupMacro in prep.macros) + print('%autosetup' in prep) + print(AutosetupMacro in prep) + # changing macro options + prep.autosetup.options.n = '%{srcname}-%{version}' # adding a new %patch macro prep.add_patch_macro(28, p=1, b='.test') - # removing an existing %patch macro + # removing an existing %patch macro by name + del prep.patch0 + # this works for both '%patch0' and '%patch -P0' prep.remove_patch_macro(0) ``` diff --git a/specfile/exceptions.py b/specfile/exceptions.py index a89a9e1..cf66771 100644 --- a/specfile/exceptions.py +++ b/specfile/exceptions.py @@ -27,3 +27,7 @@ def __str__(self) -> str: class MacroRemovalException(SpecfileException): """Exception related to failed removal of RPM macros.""" + + +class MacroOptionsException(SpecfileException): + """Exception related to processing macro options.""" diff --git a/specfile/macro_options.py b/specfile/macro_options.py new file mode 100644 index 0000000..4402887 --- /dev/null +++ b/specfile/macro_options.py @@ -0,0 +1,532 @@ +# Copyright Contributors to the Packit project. +# SPDX-License-Identifier: MIT + +import collections +import re +import string +from enum import Enum, auto +from typing import Dict, Iterable, Iterator, List, Optional, Tuple, Union, overload + +from specfile.exceptions import MacroOptionsException + + +class TokenType(Enum): + DEFAULT = auto() + WHITESPACE = auto() + QUOTED = auto() + DOUBLE_QUOTED = auto() + + +class Token(collections.abc.Hashable): + """ + Class that represents a single token in an option string. + + Attributes: + type: Token type. + value: Token value. + """ + + def __init__(self, type: TokenType, value: str) -> None: + self.type = type + self.value = value + + def __repr__(self) -> str: + type = repr(self.type) + return f"Token({type}, '{self.value}')" + + def __str__(self) -> str: + if self.type == TokenType.WHITESPACE: + return self.value + elif self.type == TokenType.QUOTED: + # escape single quotes + value = self.value.replace("'", r"\'") + return f"'{value}'" + elif self.type == TokenType.DOUBLE_QUOTED: + # escape double quotes + value = self.value.replace('"', r"\"") + return f'"{value}"' + # escape quotes and whitespace + return re.sub(r"['\"\s]", r"\\\g<0>", self.value) + + def _key(self) -> tuple: + return self.type, self.value + + def __hash__(self) -> int: + return hash(self._key()) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Token): + return NotImplemented + return self._key() == other._key() + + +class Positionals(collections.abc.MutableSequence): + """Class that represents a sequence of positional arguments.""" + + def __init__(self, options: "MacroOptions") -> None: + """ + Constructs a `Positionals` object. + + Args: + options: MacroOptions instance this object is tied with. + + Returns: + Constructed instance of `Positionals` class. + """ + self._options = options + + def __repr__(self) -> str: + options = repr(self._options) + return f"Positionals({options})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, (Positionals, list)): + return NotImplemented + return list(self) == other + + def __len__(self) -> int: + return len(self._get_items()) + + @overload + def __getitem__(self, i: int) -> Union[int, str]: + pass + + @overload + def __getitem__(self, i: slice) -> List[Union[int, str]]: + pass + + def __getitem__(self, i): + items = self._get_items() + if isinstance(i, slice): + result = [] + for index in items[i]: + value = self._options._tokens[index].value + result.append(int(value) if value.isnumeric() else value) + return result + else: + value = self._options._tokens[items[i]].value + return int(value) if value.isnumeric() else value + + @overload + def __setitem__(self, i: int, item: Union[int, str]) -> None: + pass + + @overload + def __setitem__(self, i: slice, item: Iterable[Union[int, str]]) -> None: + pass + + def __setitem__(self, i, item): + items = self._get_items() + if isinstance(i, slice): + for i0, i1 in enumerate(range(len(items))[i]): + self._options._tokens[items[i1]].value = str(item[i0]) + else: + self._options._tokens[items[i]].value = str(item) + + def __delitem__(self, i: Union[int, slice]) -> None: + def delete(index): + tokens = self._options._tokens + if index == 0: + if len(tokens) > 1 and tokens[1].type == TokenType.WHITESPACE: + del tokens[1] + else: + if tokens[index - 1].type == TokenType.WHITESPACE: + index -= 1 + del tokens[index] + del tokens[index] + + items = self._get_items() + if isinstance(i, slice): + for index in reversed(items[i]): + delete(index) + else: + delete(items[i]) + + def _get_items(self) -> List[int]: + """ + Gets all positional arguments. + + Returns: + List of indices of tokens that are positional arguments. + """ + result = [] + i = 0 + while i < len(self._options._tokens): + if self._options._tokens[i].type == TokenType.WHITESPACE: + i += 1 + continue + # exclude options (starting with -) and their arguments (if any) + value = self._options._tokens[i].value + if value.startswith("-"): + i += 1 + if len(value) > 1: + if value[1] in self._options.optstring.replace(":", ""): + if self._options._requires_argument(value[1]): + if len(value) == 2: + if ( + i < len(self._options._tokens) + and self._options._tokens[i].type + == TokenType.WHITESPACE + ): + i += 1 + i += 1 + continue + result.append(i) + i += 1 + return result + + def insert(self, i: int, value: Union[int, str]) -> None: + """ + Inserts a new positional argument at a specified index. + + Args: + i: Requested index. + value: Value of the positional argument. + """ + items = self._get_items() + if i > len(items): + i = len(items) + if items and i < len(items): + index = items[i] + if index > 0: + if self._options._tokens[index - 1].type == TokenType.WHITESPACE: + index -= 1 + else: + index = len(self._options._tokens) + self._options._tokens.insert( + index, + Token( + TokenType.DOUBLE_QUOTED + if self._options._needs_quoting(value) + else TokenType.DEFAULT, + str(value), + ), + ) + if index > 0: + self._options._tokens.insert(index, Token(TokenType.WHITESPACE, " ")) + + +class MacroOptions(collections.abc.MutableMapping): + """ + Class that represents macro options. + + Attributes: + optstring: getopt-like option string containing recognized option characters. + Option characters are ASCII letters, upper or lower-case. + If such a character is followed by a colon, the option + requires an argument. + defaults: Dict specifying default arguments to options. + """ + + def __init__( + self, + tokens: List[Token], + optstring: Optional[str] = None, + defaults: Optional[Dict[str, Union[bool, int, str]]] = None, + ) -> None: + """ + Constructs a `MacroOptions` object. + + Args: + tokens: List of tokens in an option string. + optstring: String containing recognized option characters. + Option characters are ASCII letters, upper or lower-case. + If such a character is followed by a colon, the option + requires an argument. + defaults: Dict specifying default arguments to options. + + Returns: + Constructed instance of `MacroOptions` class. + """ + self._tokens = tokens.copy() + self.optstring = optstring or "" + self.defaults = defaults.copy() if defaults is not None else {} + + def __repr__(self) -> str: + tokens = repr(self._tokens) + defaults = repr(self.defaults) + return f"MacroOptions({tokens}, '{self.optstring}', {defaults})" + + def __str__(self) -> str: + return "".join(str(t) for t in self._tokens) + + def _valid_option(self, name: str) -> bool: + """ + Determines if a name represents a recognized option. + + Args: + name: Name of the option. + + Returns: + True if the option is recognized, otherwise False. + """ + try: + # use parent's __getattribute__() so this method can be called from __getattr__() + optstring = super().__getattribute__("optstring") + except AttributeError: + return False + return name in optstring.replace(":", "") + + def _requires_argument(self, option: str) -> bool: + """ + Determines if an option requires an argument. + + Args: + option: Name of the option. + + Returns: + True if the option requires an argument, otherwise False. + + Raises: + ValueError if the specified option is not valid. + """ + i = self.optstring.index(option) + 1 + return i < len(self.optstring) and self.optstring[i] == ":" + + def _find_option(self, name: str) -> Tuple[Optional[int], Optional[int]]: + """ + Searches for an option in tokens of an option string. + + Args: + name: Name of the option. + + Returns: + Tuple of indices where the first is the index of a token matching + the option and the second is the index of a token matching + its argument, or None if there is no match. + """ + option = f"-{name}" + for i, token in reversed(list(enumerate(self._tokens))): + if not token.value.startswith(option): + continue + if token.value != option: + return i, i + if not self._requires_argument(name): + return i, None + j = i + 1 + if j == len(self._tokens): + return i, None + if self._tokens[j].type == TokenType.WHITESPACE: + j += 1 + if j == len(self._tokens): + return i, None + if self._tokens[j].value.startswith("-"): + return i, None + return i, j + return None, None + + @staticmethod + def _needs_quoting(value): + # if there is a whitespace, enquote the value rather than escaping it + return any(ws in str(value) for ws in string.whitespace) + + def __getattr__(self, name: str) -> Union[bool, int, str]: + if not self._valid_option(name): + return super().__getattribute__(name) + i, j = self._find_option(name) + if i is None: + if self._requires_argument(name): + return self.defaults.get(name) + return False + value = ( + self._tokens[j].value + if j is not None and j > i + else self._tokens[i].value[2:] + ) + if not value: + return True + if value.isnumeric(): + return int(value) + return value + + def __setattr__(self, name: str, value: Union[bool, int, str]) -> None: + if not self._valid_option(name): + return super().__setattr__(name, value) + if self._requires_argument(name) and isinstance(value, bool): + raise MacroOptionsException(f"Option -{name} requires an argument.") + if ( + not self._requires_argument(name) + and not isinstance(value, bool) + and value is not None + ): + raise MacroOptionsException(f"Option -{name} is a flag.") + i, j = self._find_option(name) + if i is None and value is not None and value is not False: + if self._tokens: + self._tokens.append(Token(TokenType.WHITESPACE, " ")) + if value is True: + self._tokens.append(Token(TokenType.DEFAULT, f"-{name}")) + elif isinstance(value, int): + self._tokens.append(Token(TokenType.DEFAULT, f"-{name}{value}")) + else: + self._tokens.append(Token(TokenType.DEFAULT, f"-{name}")) + self._tokens.append(Token(TokenType.WHITESPACE, " ")) + self._tokens.append( + Token( + TokenType.DOUBLE_QUOTED + if self._needs_quoting(value) + else TokenType.DEFAULT, + value, + ) + ) + return + if value is None or value is False: + return delattr(self, name) + if j is not None and j > i: + if self._needs_quoting(value): + if self._tokens[j].type not in ( + TokenType.QUOTED, + TokenType.DOUBLE_QUOTED, + ): + self._tokens[j].type = TokenType.DOUBLE_QUOTED + else: + self._tokens[j].type = TokenType.DEFAULT + self._tokens[j].value = str(value) + else: + if self._needs_quoting(value): + self._tokens[i].value = self._tokens[i].value[:2] + self._tokens.insert(i + 1, Token(TokenType.DOUBLE_QUOTED, str(value))) + self._tokens.insert(i + 1, Token(TokenType.WHITESPACE, " ")) + else: + self._tokens[i].value = self._tokens[i].value[:2] + str(value) + + def __delattr__(self, name: str) -> None: + if not self._valid_option(name): + return super().__delattr__(name) + i, j = self._find_option(name) + if i is None: + return + if i == 0: + if ( + j is not None + and j + 1 < len(self._tokens) + and self._tokens[j + 1].type == TokenType.WHITESPACE + ): + j += 1 + elif self._tokens[i - 1].type == TokenType.WHITESPACE: + i -= 1 + if j is not None and j > i: + del self._tokens[i : j + 1] + else: + del self._tokens[i] + + def __len__(self) -> int: + return len( + { + t.value[1] + for t in self._tokens + if t.type != TokenType.WHITESPACE + and t.value.startswith("-") + and len(t.value) > 1 + and t.value[1] in self.optstring.replace(":", "") + } + ) + + def __getitem__(self, key: str) -> Union[bool, int, str]: + try: + return getattr(self, key) + except AttributeError: + raise KeyError(key) + + def __setitem__(self, key: str, item: Union[bool, int, str]) -> None: + try: + return setattr(self, key, item) + except AttributeError: + raise KeyError(key) + + def __delitem__(self, key: str) -> None: + try: + return delattr(self, key) + except AttributeError: + raise KeyError(key) + + def __iter__(self) -> Iterator[str]: + for option in self.optstring.replace(":", ""): + i, _ = self._find_option(option) + if i >= 0: + yield option + + @property + def positional(self) -> Positionals: + """Sequence of positional arguments.""" + return Positionals(self) + + @positional.setter + def positional(self, value: List[Union[int, str]]) -> None: + positionals = Positionals(self) + positionals.clear() + positionals.extend(value) + + @staticmethod + def tokenize(option_string: str) -> List[Token]: + """ + Tokenizes an option string. + + Follows the same rules as poptParseArgvString() that is used by RPM. + + Args: + option_string: Option string. + + Returns: + List of tokens. + + Raises: + MacroOptionsException if the option string is untokenizable. + """ + result = [] + token = "" + quote = None + inp = list(option_string) + while inp: + c = inp.pop(0) + if c == quote: + if token: + result.append( + Token( + TokenType.QUOTED + if quote == "'" + else TokenType.DOUBLE_QUOTED, + token, + ) + ) + token = "" + quote = None + continue + if quote: + if c == "\\": + if not inp: + raise MacroOptionsException("No escaped character") + c = inp.pop(0) + if c != quote: + token += "\\" + token += c + continue + if c.isspace(): + if token: + result.append(Token(TokenType.DEFAULT, token)) + token = "" + whitespace = c + while inp: + c = inp.pop(0) + if not c.isspace(): + break + whitespace += c + inp.insert(0, c) + result.append(Token(TokenType.WHITESPACE, whitespace)) + continue + if c in ('"', "'"): + if token: + result.append(Token(TokenType.DEFAULT, token)) + token = "" + quote = c + continue + if c == "\\": + if not inp: + raise MacroOptionsException("No escaped character") + c = inp.pop(0) + token += c + if quote: + raise MacroOptionsException("No closing quotation") + if token: + result.append(Token(TokenType.DEFAULT, token)) + return result diff --git a/specfile/prep.py b/specfile/prep.py index 04695fc..87f53d8 100644 --- a/specfile/prep.py +++ b/specfile/prep.py @@ -1,14 +1,14 @@ # Copyright Contributors to the Packit project. # SPDX-License-Identifier: MIT -import argparse import collections import re -import shlex -from abc import ABC, abstractmethod -from typing import List, Optional, overload +from abc import ABC +from typing import Any, Dict, List, Optional, Union, cast, overload +from specfile.macro_options import MacroOptions from specfile.sections import Section +from specfile.types import SupportsIndex class PrepMacro(ABC): @@ -17,319 +17,362 @@ class PrepMacro(ABC): Attributes: name: Literal name of the macro. + options: Options of the macro. """ CANONICAL_NAME: str + OPTSTRING: str + DEFAULTS: Dict[str, Union[bool, int, str]] - def __init__(self, name: str, line: int) -> None: + def __init__( + self, + name: str, + options: MacroOptions, + delimiter: str, + prefix: Optional[str] = None, + suffix: Optional[str] = None, + preceding_lines: Optional[List[str]] = None, + ) -> None: """ Constructs a `PrepMacro` object. Args: name: Literal name of the macro. - line: Line number in %prep section where the macro is located. + options: Options of the macro. + delimiter: Delimiter separating name and option string. + prefix: Characters preceding the macro on a line. + suffix: Characters following the macro on a line. + preceding_lines: Lines of the %prep section preceding the macro. Returns: Constructed instance of `PrepMacro` class. """ self.name = name - self._line = line - self._options = argparse.Namespace() - self._parser = argparse.ArgumentParser(add_help=False) - self._setup_parser() + self.options = options + self._delimiter = delimiter + self._prefix = prefix or "" + self._suffix = suffix or "" + self._preceding_lines = ( + preceding_lines.copy() if preceding_lines is not None else [] + ) def __repr__(self) -> str: + options = repr(self.options) + preceding_lines = repr(self._preceding_lines) # determine class name dynamically so that inherited classes # don't have to reimplement __repr__() - return f"{self.__class__.__name__}('{self.name}', {self._line})" - - @abstractmethod - def _setup_parser(self) -> None: - """Configures internal `ArgumentParser` for the options of the macro.""" - ... - - def _parse_options(self, optstr: str) -> None: - """ - Parses the given option string. - - Args: - optstr: String representing options of the macro. - """ - try: - self._parser.parse_known_args(shlex.split(optstr), self._options) - except SystemExit: - # ignore errors - pass + return ( + f"{self.__class__.__name__}('{self.name}', {options}, " + f"'{self._delimiter}', '{self._prefix}', '{self._suffix}', " + f"{preceding_lines})" + ) - @property - def options(self) -> argparse.Namespace: - """Options of the macro as `argparse.Namespace` instance.""" - return self._options + def get_raw_data(self) -> List[str]: + options = str(self.options) + return self._preceding_lines + [ + f"{self._prefix}{self.name}{self._delimiter}{options}{self._suffix}" + ] class SetupMacro(PrepMacro): """Class that represents a %setup macro.""" CANONICAL_NAME: str = "%setup" - - def _setup_parser(self) -> None: - """Configures internal `ArgumentParser` for the options of the macro.""" - self._parser.add_argument("-n", default="%{name}-%{version}") - self._parser.add_argument("-q", action="store_true") - self._parser.add_argument("-c", action="store_true") - self._parser.add_argument("-D", action="store_true") - self._parser.add_argument("-T", action="store_true") - self._parser.add_argument("-b", type=int) - self._parser.add_argument("-a", type=int) + OPTSTRING: str = "a:b:cDn:Tq" + DEFAULTS: Dict[str, Union[bool, int, str]] = { + "n": "%{name}-%{version}", + } class PatchMacro(PrepMacro): """Class that represents a %patch macro.""" CANONICAL_NAME: str = "%patch" - - def _setup_parser(self) -> None: - """Configures internal `ArgumentParser` for the options of the macro.""" - self._parser.add_argument("-P", type=int) - self._parser.add_argument("-p", type=int) - self._parser.add_argument("-b") - self._parser.add_argument("-E", action="store_true") + OPTSTRING: str = "P:p:REb:z:F:d:o:Z" + DEFAULTS: Dict[str, Union[bool, int, str]] = {} @property - def index(self) -> int: - """Numeric index of the %patch macro.""" + def number(self) -> int: + """Number of the %patch macro.""" if self.options.P is not None: - return self.options.P + return int(self.options.P) tokens = re.split(r"(\d+)", self.name, maxsplit=1) if len(tokens) > 1: return int(tokens[1]) return 0 + @number.setter + def number(self, value: int) -> None: + if self.options.P is not None: + self.options.P = value + return + self.name = f"{self.CANONICAL_NAME}{value}" + class AutosetupMacro(PrepMacro): """Class that represents an %autosetup macro.""" CANONICAL_NAME: str = "%autosetup" - - def _setup_parser(self) -> None: - """Configures internal `ArgumentParser` for the options of the macro.""" - self._parser.add_argument("-n", default="%{name}-%{version}") - self._parser.add_argument("-v", action="store_true") - self._parser.add_argument("-c", action="store_true") - self._parser.add_argument("-D", action="store_true") - self._parser.add_argument("-T", action="store_true") - self._parser.add_argument("-b", type=int) - self._parser.add_argument("-a", type=int) - self._parser.add_argument("-N", action="store_true") - self._parser.add_argument("-S", default="patch") - self._parser.add_argument("-p", type=int) + OPTSTRING: str = "a:b:cDn:TvNS:p:" + DEFAULTS: Dict[str, Union[bool, int, str]] = { + "n": "%{name}-%{version}", + "S": "patch", + } class AutopatchMacro(PrepMacro): """Class that represents an %autopatch macro.""" CANONICAL_NAME: str = "%autopatch" + OPTSTRING: str = "vp:m:M:" + DEFAULTS: Dict[str, Union[bool, int, str]] = {} - def _setup_parser(self) -> None: - """Configures internal `ArgumentParser` for the options of the macro.""" - self._parser.add_argument("-v", action="store_true") - self._parser.add_argument("-p", type=int) - self._parser.add_argument("-m", type=int) - self._parser.add_argument("-M", type=int) - self._parser.add_argument("indices", type=int, nargs="*") +class PrepMacros(collections.UserList): + """ + Class that represents a list of %prep macros. -class PrepMacros(collections.abc.Sequence): - """Class that represents a sequence of all %prep macros.""" + Attributes: + data: List of individual %prep macros. + """ - def __init__(self, section: Section) -> None: + def __init__( + self, + data: Optional[List[PrepMacro]] = None, + remainder: Optional[List[str]] = None, + ) -> None: """ Constructs a `PrepMacros` object. Args: - section: %prep section. + data: List of individual %prep macros. + remainder: Leftover lines in the section. Returns: Constructed instance of `PrepMacros` class. """ - self._section = section + super().__init__() + if data is not None: + self.data = data.copy() + self._remainder = remainder.copy() if remainder is not None else [] def __repr__(self) -> str: - section = repr(self._section) - return f"PrepMacros({section})" + data = repr(self.data) + remainder = repr(self._remainder) + return f"PrepMacros({data}, {remainder})" def __contains__(self, item: object) -> bool: if isinstance(item, type): - return any(isinstance(m, item) for m in self._get_items()) - elif isinstance(item, str): - return any(m.CANONICAL_NAME == item for m in self._get_items()) - return False - - def __len__(self) -> int: - return len(self._get_items()) - - def _get_items(self) -> List[PrepMacro]: - """ - Gets all supported %prep macros. - - Returns: - List of instances of subclasses of PrepMacro. - """ - comment_regex = re.compile(r"^\s*#.*$") - # match also macros enclosed in conditionalized macro expansion - # e.g.: %{?with_system_nss:%patch30 -p3 -b .nss_pkcs11_v3} - macro_regex = re.compile( - r"(?P%{!?\?\w+:)?.*?" - r"(?P%(setup|patch\d*|autopatch|autosetup))\s*" - r"(?P.*?)(?(c)}|$)" + return any(isinstance(m, item) for m in self.data) + return any( + m.name.startswith(item) if item == "%patch" else m.name == item + for m in self.data ) - result: List[PrepMacro] = [] - for i, line in enumerate(self._section): - if comment_regex.match(line): - continue - m = macro_regex.search(line) - if not m: - continue - name, options = m.group("m"), m.group("o") - macro: PrepMacro - if name.startswith(PatchMacro.CANONICAL_NAME): - macro = PatchMacro(name, i) - macro._parse_options(options) - # if %patch is indexed and has the -P option at the same time, - # it's two macros in one - if macro.options.P is not None and name != PatchMacro.CANONICAL_NAME: - macro.options.P = None - result.append(macro) - # add the second macro - macro = PatchMacro(PatchMacro.CANONICAL_NAME, i) - macro._parse_options(options) - result.append(macro) - else: - result.append(macro) - else: - macro = next( - iter( - cls(name, i) # type: ignore - for cls in PrepMacro.__subclasses__() - if cls.CANONICAL_NAME == name - ), - None, - ) - if not macro: - continue - macro._parse_options(options) - result.append(macro) - return result @overload - def __getitem__(self, i: int) -> PrepMacro: + def __getitem__(self, i: SupportsIndex) -> PrepMacro: pass @overload - def __getitem__(self, i: slice) -> List[PrepMacro]: + def __getitem__(self, i: slice) -> "PrepMacros": pass def __getitem__(self, i): - return self._get_items()[i] + if isinstance(i, slice): + return PrepMacros(self.data[i], self._remainder) + else: + return self.data[i] + + def __delitem__(self, i: Union[SupportsIndex, slice]) -> None: + def delete(index): + preceding_lines = self.data[index]._preceding_lines.copy() + del self.data[index] + if index < len(self.data): + self.data[index]._preceding_lines = ( + preceding_lines + self.data[index]._preceding_lines + ) + else: + self._remainder = preceding_lines + self._remainder + + if isinstance(i, slice): + for index in reversed(range(len(self.data))[i]): + delete(index) + else: + delete(i) + def __getattr__(self, name: str) -> PrepMacro: + if not self.valid_prep_macro(name): + return super().__getattribute__(name) + try: + return self.data[self.find(f"%{name}")] + except ValueError: + raise AttributeError(name) -class Prep: + def __delattr__(self, name: str) -> None: + if not self.valid_prep_macro(name): + return super().__delattr__(name) + try: + self.__delitem__(self.find(f"%{name}")) + except ValueError: + raise AttributeError(name) + + @staticmethod + def valid_prep_macro(name: str) -> bool: + return name in ("setup", "autosetup", "autopatch") or name.startswith("patch") + + def copy(self) -> "PrepMacros": + return PrepMacros(self.data, self._remainder) + + def find(self, name: str) -> int: + for i, macro in enumerate(self.data): + if macro.name == name: + return i + raise ValueError + + def get_raw_data(self) -> List[str]: + result = [] + for macro in self.data: + result.extend(macro.get_raw_data()) + result.extend(self._remainder) + return result + + +class Prep(collections.abc.Container): """ Class that represents a %prep section. Attributes: - macros: Sequence of individual %prep macros. - Recognized macros are %setup, %patch, %autosetup and %autopatch. + macros: List of individual %prep macros. """ - def __init__(self, section: Section) -> None: - """ - Constructs a `Prep` object. - - Args: - section: %prep section. - - Returns: - Constructed instance of `Prep` class. - """ - self._section = section - self.macros = PrepMacros(self._section) + def __init__(self, macros: PrepMacros) -> None: + self.macros = macros.copy() def __repr__(self) -> str: - section = repr(self._section) - return f"Prep({section})" + macros = repr(self.macros) + return f"Prep({macros})" - def add_patch_macro( - self, - index: int, - P: Optional[int] = None, - p: Optional[int] = None, - b: Optional[str] = None, - E: Optional[bool] = None, - ) -> None: - """ - Adds a new %patch macro with given index and options. + def __contains__(self, item: object) -> bool: + return item in self.macros - If there are existing %patch macros, the new macro is added before, - after or between them according to index. Otherwise it is added - to the very end of %prep section. + def __getattr__(self, name: str) -> PrepMacro: + if not self.macros.valid_prep_macro(name): + return super().__getattribute__(name) + return getattr(self.macros, name) - Beware that it is valid to specify non-zero index and the -P option - at the same time, but the resulting macro behaves as two %patch macros - (even if both indices are the same, in such case the patch is applied - twice - you most likely don't want that). + def __delattr__(self, name: str) -> None: + if not self.macros.valid_prep_macro(name): + return super().__delattr__(name) + return delattr(self.macros, name) - Also beware that there is no duplicity check, it is possible to add - multiple %patch macros with the same index. + def add_patch_macro(self, number: int, **kwargs: Any) -> None: + """ + Adds a new %patch macro with given number and options. Args: - index: Numeric index of the macro. - P: The -P option (patch index). + number: Macro number. + P: The -P option (patch number). p: The -p option (strip number). - b: The -b option (backup). + R: The -R option (reverse). E: The -E option (remove empty files). + b: The -b option (backup). + z: The -z option (suffix). + F: The -F option (fuzz factor). + d: The -d option (working directory). + o: The -o option (output file). + Z: The -Z option (set UTC times). """ - macro = f"%patch{index}" - if P is not None: - macro += f" -P{P}" - if p is not None: - macro += f" -p{p}" - if b is not None: - macro += f" -b {b}" - if E: - macro += " -E" - macros = [m for m in self.macros if isinstance(m, PatchMacro)] - if macros: - lines = [ - m._line - for m in sorted(macros, key=lambda m: m.index) - if m.index < index - ] - if lines: - self._section.insert(lines[-1] + 1, macro) - else: - self._section.insert(macros[0]._line, macro) - else: - self._section.append(macro) - - def remove_patch_macro(self, index: int) -> None: + options = MacroOptions([], PatchMacro.OPTSTRING, PatchMacro.DEFAULTS) + for k, v in kwargs.items(): + setattr(options, k, v) + macro = PatchMacro(PatchMacro.CANONICAL_NAME, options, " ") + macro.number = number + index, _ = min( + ((i, m) for i, m in enumerate(self.macros) if isinstance(m, PatchMacro)), + key=lambda im: abs(im[1].number - number), + default=(len(self.macros), None), + ) + if ( + index < len(self.macros) + and cast(PatchMacro, self.macros[index]).number <= number + ): + index += 1 + self.macros.insert(index, macro) + + def remove_patch_macro(self, number: int) -> None: """ - Removes a %patch macro. + Removes a %patch macro with given number. - If there are multiple %patch macros with the same index, - all instances are removed. + Args: + number: Macro number. + """ + index = next( + ( + i + for i, m in enumerate(self.macros) + if isinstance(m, PatchMacro) and m.number == number + ), + None, + ) + if index: + del self.macros[index] - Note that this method always removes the entire line, even if - for example the %patch macro is part of a conditionalized - macro expansion. + @staticmethod + def parse(section: Section) -> "Prep": + """ + Parses a section into a `Prep` object. Args: - index: Numeric index of the macro to remove. + section: %prep section. + + Returns: + Constructed instance of `Prep` class. """ - lines = [ - m._line - for m in self.macros - if isinstance(m, PatchMacro) and m.index == index - ] - for line in reversed(lines): - del self._section[line] + # match also macros enclosed in conditionalized macro expansion + # e.g.: %{?with_system_nss:%patch30 -p3 -b .nss_pkcs11_v3} + macro_regex = re.compile( + r"(?P%{!?\?\w+:)?.*?" + r"(?P%(setup|patch\d*|autopatch|autosetup))" + r"(?P\s*)" + r"(?P.*?)" + r"(?(c)}|$)" + ) + data = [] + buffer: List[str] = [] + for line in section: + m = macro_regex.search(line) + if m: + name, delimiter, option_string = ( + m.group("m"), + m.group("d"), + m.group("o"), + ) + prefix, suffix = line[: m.start("m")], line[m.end("o") :] + cls = next( + ( + cls + for cls in PrepMacro.__subclasses__() + if name.startswith(cls.CANONICAL_NAME) + ), + None, + ) + if not cls: + buffer.append(line) + continue + options = MacroOptions( + MacroOptions.tokenize(option_string), cls.OPTSTRING, cls.DEFAULTS + ) + data.append(cls(name, options, delimiter, prefix, suffix, buffer)) + buffer = [] + else: + buffer.append(line) + return Prep(PrepMacros(data, buffer)) + + def get_raw_section_data(self) -> List[str]: + """ + Reconstructs section data from `Prep` object. + + Returns: + List of lines forming the reconstructed section data. + """ + return self.macros.get_raw_data() diff --git a/specfile/specfile.py b/specfile/specfile.py index 50f25d5..3cc91fd 100644 --- a/specfile/specfile.py +++ b/specfile/specfile.py @@ -154,7 +154,11 @@ def prep(self) -> Iterator[Optional[Prep]]: except AttributeError: yield None else: - yield Prep(section) + prep = Prep.parse(section) + try: + yield prep + finally: + section.data = prep.get_raw_section_data() @contextlib.contextmanager def sources( diff --git a/tests/constants.py b/tests/constants.py index bafd826..e2629f5 100644 --- a/tests/constants.py +++ b/tests/constants.py @@ -9,6 +9,7 @@ SPEC_RPMAUTOSPEC = DATA_DIR / "spec_rpmautospec" SPEC_TRADITIONAL = DATA_DIR / "spec_traditional" SPEC_AUTOSETUP = DATA_DIR / "spec_autosetup" +SPEC_AUTOPATCH = DATA_DIR / "spec_autopatch" SPEC_PATCHLIST = DATA_DIR / "spec_patchlist" SPEC_MULTIPLE_SOURCES = DATA_DIR / "spec_multiple_sources" diff --git a/tests/data/spec_autopatch/patch0.patch b/tests/data/spec_autopatch/patch0.patch new file mode 100644 index 0000000..3a016f7 --- /dev/null +++ b/tests/data/spec_autopatch/patch0.patch @@ -0,0 +1,18 @@ +From e5e63915ae9cfb5a40bccbd53514f211b5c8e63b Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Nikola=20Forr=C3=B3?= +Date: Wed, 16 Mar 2022 10:29:59 +0100 +Subject: [PATCH 1/7] patch0 + +--- + test.txt | 1 + + 1 file changed, 1 insertion(+) + +diff --git a/test.txt b/test.txt +index 9daeafb..dec2cbe 100644 +--- a/test.txt ++++ b/test.txt +@@ -1 +1,2 @@ + test ++test +-- +2.35.1 diff --git a/tests/data/spec_autopatch/patch1.patch b/tests/data/spec_autopatch/patch1.patch new file mode 100644 index 0000000..3eb1641 --- /dev/null +++ b/tests/data/spec_autopatch/patch1.patch @@ -0,0 +1,19 @@ +From 10cd6fe9acec1233c3456871f26b122df901d160 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Nikola=20Forr=C3=B3?= +Date: Wed, 16 Mar 2022 10:30:15 +0100 +Subject: [PATCH 2/7] patch1 + +--- + test.txt | 1 + + 1 file changed, 1 insertion(+) + +diff --git a/test.txt b/test.txt +index dec2cbe..0867e73 100644 +--- a/test.txt ++++ b/test.txt +@@ -1,2 +1,3 @@ + test + test ++test +-- +2.35.1 diff --git a/tests/data/spec_autopatch/patch2.patch b/tests/data/spec_autopatch/patch2.patch new file mode 100644 index 0000000..4406da1 --- /dev/null +++ b/tests/data/spec_autopatch/patch2.patch @@ -0,0 +1,20 @@ +From 015100d135c721e4b1e34e6e71e218b43143a310 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Nikola=20Forr=C3=B3?= +Date: Wed, 16 Mar 2022 10:30:29 +0100 +Subject: [PATCH 3/7] patch2 + +--- + test.txt | 1 + + 1 file changed, 1 insertion(+) + +diff --git a/test.txt b/test.txt +index 0867e73..d0c7fbe 100644 +--- a/test.txt ++++ b/test.txt +@@ -1,3 +1,4 @@ + test + test + test ++test +-- +2.35.1 diff --git a/tests/data/spec_autopatch/patch3.patch b/tests/data/spec_autopatch/patch3.patch new file mode 100644 index 0000000..4a3a999 --- /dev/null +++ b/tests/data/spec_autopatch/patch3.patch @@ -0,0 +1,20 @@ +From fb8a4a90040105bfead30585a89cec0798390ca7 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Nikola=20Forr=C3=B3?= +Date: Mon, 25 Apr 2022 12:39:30 +0200 +Subject: [PATCH 4/7] patch3 + +--- + test.txt | 1 + + 1 file changed, 1 insertion(+) + +diff --git a/test.txt b/test.txt +index d0c7fbe..f762684 100644 +--- a/test.txt ++++ b/test.txt +@@ -2,3 +2,4 @@ test + test + test + test ++test +-- +2.35.1 diff --git a/tests/data/spec_autopatch/patch4.patch b/tests/data/spec_autopatch/patch4.patch new file mode 100644 index 0000000..5e59b40 --- /dev/null +++ b/tests/data/spec_autopatch/patch4.patch @@ -0,0 +1,20 @@ +From ce488bdb437dc7da535235db0e3b3bfd7b1a7da4 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Nikola=20Forr=C3=B3?= +Date: Mon, 25 Apr 2022 12:39:37 +0200 +Subject: [PATCH 5/7] patch4 + +--- + test.txt | 1 + + 1 file changed, 1 insertion(+) + +diff --git a/test.txt b/test.txt +index f762684..339c7a8 100644 +--- a/test.txt ++++ b/test.txt +@@ -3,3 +3,4 @@ test + test + test + test ++test +-- +2.35.1 diff --git a/tests/data/spec_autopatch/patch5.patch b/tests/data/spec_autopatch/patch5.patch new file mode 100644 index 0000000..2fd64e7 --- /dev/null +++ b/tests/data/spec_autopatch/patch5.patch @@ -0,0 +1,20 @@ +From 2b6f99fdaa15c486e09350916a486e92706c5ad2 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Nikola=20Forr=C3=B3?= +Date: Mon, 25 Apr 2022 12:39:42 +0200 +Subject: [PATCH 6/7] patch5 + +--- + test.txt | 1 + + 1 file changed, 1 insertion(+) + +diff --git a/test.txt b/test.txt +index 339c7a8..0883796 100644 +--- a/test.txt ++++ b/test.txt +@@ -4,3 +4,4 @@ test + test + test + test ++test +-- +2.35.1 diff --git a/tests/data/spec_autopatch/patch6.patch b/tests/data/spec_autopatch/patch6.patch new file mode 100644 index 0000000..3227eb3 --- /dev/null +++ b/tests/data/spec_autopatch/patch6.patch @@ -0,0 +1,20 @@ +From 9469faa96d3734e9dae635196763c67aa3eb5cbf Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Nikola=20Forr=C3=B3?= +Date: Mon, 25 Apr 2022 12:39:47 +0200 +Subject: [PATCH 7/7] patch6 + +--- + test.txt | 1 + + 1 file changed, 1 insertion(+) + +diff --git a/test.txt b/test.txt +index 0883796..d5b79ce 100644 +--- a/test.txt ++++ b/test.txt +@@ -5,3 +5,4 @@ test + test + test + test ++test +-- +2.35.1 diff --git a/tests/data/spec_autopatch/test-0.1.tar.xz b/tests/data/spec_autopatch/test-0.1.tar.xz new file mode 100644 index 0000000000000000000000000000000000000000..d6cd06f12418b341ffa4f8a8199e8d6c29d1e35c GIT binary patch literal 200 zcmV;(05|{rH+ooF000E$*0e?f03iVu0001VFXf})C;tG4T>v^6O3od8%~Fm&LU2N! zuy&4uL5UJz@Itd(Oxtq-K1lz=GdP6O(ULn@#lwG2mvIrDmlzBR?s4@24M`C>xSnC_ zH0vva z@jYfqH7tfQj(^f4^UJse00029k+3M>;}(Db0iywcPyhg?23X*+#Ao{g000001X)^` ChEm)B literal 0 HcmV?d00001 diff --git a/tests/data/spec_autopatch/test.spec b/tests/data/spec_autopatch/test.spec new file mode 100644 index 0000000..8692780 --- /dev/null +++ b/tests/data/spec_autopatch/test.spec @@ -0,0 +1,34 @@ +Name: test +Version: 0.1 +Release: 1%{?dist} +Summary: Test package + +License: MIT + +Source: %{name}-%{version}.tar.xz +Patch0: patch0.patch +Patch1: patch1.patch +Patch2: patch2.patch +Patch3: patch3.patch +Patch4: patch4.patch +Patch5: patch5.patch +Patch6: patch6.patch + + +%description +Test package + + +%prep +%autosetup -N +# apply the first 3 patches +%autopatch -p1 -M 2 +# apply patch 3 +%autopatch -p1 3 +# apply patches 4-6 +%autopatch -p1 -m 4 + + +%changelog +* Thu Jun 07 2018 Nikola Forró - 0.1-1 +- first version diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 1f58036..e4a264e 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -6,6 +6,7 @@ import pytest from tests.constants import ( + SPEC_AUTOPATCH, SPEC_AUTOSETUP, SPEC_MINIMAL, SPEC_MULTIPLE_SOURCES, @@ -44,6 +45,13 @@ def spec_autosetup(tmp_path): return destination / SPECFILE +@pytest.fixture(scope="function") +def spec_autopatch(tmp_path): + destination = tmp_path / "spec_autopatch" + shutil.copytree(SPEC_AUTOPATCH, destination) + return destination / SPECFILE + + @pytest.fixture(scope="function") def spec_patchlist(tmp_path): destination = tmp_path / "spec_patchlist" diff --git a/tests/integration/test_specfile.py b/tests/integration/test_specfile.py index 7e4dc6f..7e496fd 100644 --- a/tests/integration/test_specfile.py +++ b/tests/integration/test_specfile.py @@ -31,16 +31,21 @@ def test_prep_traditional(spec_traditional): assert AutosetupMacro not in prep.macros assert AutopatchMacro not in prep.macros assert isinstance(prep.macros[0], SetupMacro) + assert prep.macros[0] == prep.setup for i, m in enumerate(prep.macros[1:]): assert isinstance(m, PatchMacro) - assert m.index == i + assert m.number == i assert m.options.p == 1 prep.remove_patch_macro(0) assert len([m for m in prep.macros if isinstance(m, PatchMacro)]) == 2 prep.add_patch_macro(0, p=2, b=".test") assert len(prep.macros) == 4 + assert prep.patch0.options.p == 2 + assert prep.patch0.options.b == ".test" + prep.patch0.options.b = ".test2" + prep.patch0.options.E = True with spec.sections() as sections: - assert sections.prep[1] == "%patch0 -p2 -b .test" + assert sections.prep[1] == "%patch0 -p2 -b .test2 -E" def test_prep_autosetup(spec_autosetup): @@ -50,7 +55,22 @@ def test_prep_autosetup(spec_autosetup): assert AutosetupMacro in prep.macros assert SetupMacro not in prep.macros assert PatchMacro not in prep.macros - assert prep.macros[0].options.p == 1 + assert prep.autosetup.options.p == 1 + + +def test_prep_autopatch(spec_autopatch): + spec = Specfile(spec_autopatch) + with spec.prep() as prep: + assert len(prep.macros) == 4 + assert prep.macros[1].options.M == 2 + assert prep.macros[2].options.positional == [3] + assert prep.macros[3].options.m == 4 + del prep.macros[1] + del prep.macros[2] + prep.autopatch.options.positional = list(range(7)) + with spec.sections() as sections: + assert sections.prep[0] == "%autosetup -N" + assert sections.prep[3] == "%autopatch -p1 0 1 2 3 4 5 6" def test_sources(spec_minimal): diff --git a/tests/unit/test_macro_options.py b/tests/unit/test_macro_options.py new file mode 100644 index 0000000..e3f97e4 --- /dev/null +++ b/tests/unit/test_macro_options.py @@ -0,0 +1,239 @@ +# Copyright Contributors to the Packit project. +# SPDX-License-Identifier: MIT + +import pytest + +from specfile.macro_options import MacroOptions, Positionals, Token, TokenType + + +@pytest.mark.parametrize( + "optstring, tokens, result", + [ + ( + "vp:m:M:", + [ + Token(TokenType.DEFAULT, "28"), + Token(TokenType.WHITESPACE, " "), + Token(TokenType.DOUBLE_QUOTED, "test arg"), + ], + [0, 2], + ), + ( + "vp:m:M:", + [ + Token(TokenType.DEFAULT, "-p"), + Token(TokenType.WHITESPACE, " "), + Token(TokenType.DEFAULT, "1"), + Token(TokenType.WHITESPACE, " "), + Token(TokenType.DEFAULT, "-v"), + Token(TokenType.WHITESPACE, " "), + Token(TokenType.DEFAULT, "28"), + ], + [6], + ), + ( + "vp:m:M:", + [ + Token(TokenType.DEFAULT, "-m"), + Token(TokenType.WHITESPACE, " "), + Token(TokenType.DEFAULT, "10"), + Token(TokenType.WHITESPACE, " "), + Token(TokenType.DEFAULT, "test"), + Token(TokenType.WHITESPACE, " "), + Token(TokenType.DEFAULT, "-M"), + Token(TokenType.WHITESPACE, " "), + Token(TokenType.DEFAULT, "20"), + ], + [4], + ), + ], +) +def test_positionals_get_items(optstring, tokens, result): + options = MacroOptions(tokens, optstring) + assert Positionals(options)._get_items() == result + + +@pytest.mark.parametrize( + "optstring, tokens, index, value, tokens_index, token_type", + [ + ( + "vp:m:M:", + [ + Token(TokenType.DEFAULT, "-p1"), + Token(TokenType.WHITESPACE, " "), + Token(TokenType.DEFAULT, "test"), + Token(TokenType.WHITESPACE, " "), + Token(TokenType.DEFAULT, "28"), + ], + 0, + "test arg", + 2, + TokenType.DOUBLE_QUOTED, + ), + ( + "vp:m:M:", + [ + Token(TokenType.DEFAULT, "-p1"), + Token(TokenType.WHITESPACE, " "), + Token(TokenType.DEFAULT, "test"), + Token(TokenType.WHITESPACE, " "), + Token(TokenType.DEFAULT, "28"), + ], + 1, + 123, + 4, + TokenType.DEFAULT, + ), + ( + "vp:m:M:", + [], + 0, + "test", + 0, + TokenType.DEFAULT, + ), + ], +) +def test_positionals_insert(optstring, tokens, index, value, tokens_index, token_type): + options = MacroOptions(tokens, optstring) + positionals = Positionals(options) + positionals.insert(index, value) + assert options._tokens[tokens_index].type == token_type + assert options._tokens[tokens_index].value == str(value) + + +@pytest.mark.parametrize( + "optstring, option, valid", + [ + ("a:b:cDn:Tq", "a", True), + ("a:b:cDn:Tq", "q", True), + ("a:b:cDn:Tq", "v", False), + ], +) +def test_macro_options_valid_option(optstring, option, valid): + options = MacroOptions([], optstring) + assert options._valid_option(option) == valid + + +@pytest.mark.parametrize( + "optstring, option, requires_argument", + [ + ("a:b:cDn:Tq", "a", True), + ("a:b:cDn:Tq", "q", False), + ("a:b:cDn:Tq", "v", None), + ], +) +def test_macro_options_requires_argument(optstring, option, requires_argument): + options = MacroOptions([], optstring) + if option in optstring: + assert options._requires_argument(option) == requires_argument + else: + with pytest.raises(ValueError): + options._requires_argument(option) + + +@pytest.mark.parametrize( + "optstring, tokens, option, result", + [ + ( + "P:p:REb:z:F:d:o:Z", + [ + Token(TokenType.DEFAULT, "-p1"), + Token(TokenType.WHITESPACE, " "), + Token(TokenType.DEFAULT, "-b"), + Token(TokenType.WHITESPACE, " "), + Token(TokenType.DEFAULT, ".test"), + Token(TokenType.WHITESPACE, " "), + Token(TokenType.DEFAULT, "-E"), + ], + "p", + (0, 0), + ), + ( + "P:p:REb:z:F:d:o:Z", + [ + Token(TokenType.DEFAULT, "-p1"), + Token(TokenType.WHITESPACE, " "), + Token(TokenType.DEFAULT, "-b"), + Token(TokenType.WHITESPACE, " "), + Token(TokenType.DEFAULT, ".test"), + Token(TokenType.WHITESPACE, " "), + Token(TokenType.DEFAULT, "-E"), + ], + "b", + (2, 4), + ), + ( + "P:p:REb:z:F:d:o:Z", + [ + Token(TokenType.DEFAULT, "-p1"), + Token(TokenType.WHITESPACE, " "), + Token(TokenType.DEFAULT, "-b"), + Token(TokenType.WHITESPACE, " "), + Token(TokenType.DEFAULT, ".test"), + Token(TokenType.WHITESPACE, " "), + Token(TokenType.DEFAULT, "-E"), + ], + "E", + (6, None), + ), + ( + "P:p:REb:z:F:d:o:Z", + [ + Token(TokenType.DEFAULT, "-p1"), + Token(TokenType.WHITESPACE, " "), + Token(TokenType.DEFAULT, "-b"), + Token(TokenType.WHITESPACE, " "), + Token(TokenType.DEFAULT, ".test"), + Token(TokenType.WHITESPACE, " "), + Token(TokenType.DEFAULT, "-E"), + ], + "F", + (None, None), + ), + ], +) +def test_macro_options_find_option(optstring, tokens, option, result): + options = MacroOptions(tokens, optstring) + assert options._find_option(option) == result + + +@pytest.mark.parametrize( + "option_string, result", + [ + ( + "-p1 -b .test -E", + [ + Token(TokenType.DEFAULT, "-p1"), + Token(TokenType.WHITESPACE, " "), + Token(TokenType.DEFAULT, "-b"), + Token(TokenType.WHITESPACE, " "), + Token(TokenType.DEFAULT, ".test"), + Token(TokenType.WHITESPACE, " "), + Token(TokenType.DEFAULT, "-E"), + ], + ), + ( + "-p 28 -b .test\\ escape", + [ + Token(TokenType.DEFAULT, "-p"), + Token(TokenType.WHITESPACE, " "), + Token(TokenType.DEFAULT, "28"), + Token(TokenType.WHITESPACE, " "), + Token(TokenType.DEFAULT, "-b"), + Token(TokenType.WHITESPACE, " "), + Token(TokenType.DEFAULT, ".test escape"), + ], + ), + ( + '-b ".test \\"double quotes\\""', + [ + Token(TokenType.DEFAULT, "-b"), + Token(TokenType.WHITESPACE, " "), + Token(TokenType.DOUBLE_QUOTED, '.test "double quotes"'), + ], + ), + ], +) +def test_macro_options_tokenize(option_string, result): + assert MacroOptions.tokenize(option_string) == result diff --git a/tests/unit/test_prep.py b/tests/unit/test_prep.py index aa981ad..f91881d 100644 --- a/tests/unit/test_prep.py +++ b/tests/unit/test_prep.py @@ -1,89 +1,15 @@ # Copyright Contributors to the Packit project. # SPDX-License-Identifier: MIT -from argparse import Namespace as NS - import pytest -from specfile.prep import AutopatchMacro, AutosetupMacro, PatchMacro, Prep, SetupMacro +from specfile.macro_options import MacroOptions +from specfile.prep import PatchMacro, Prep, PrepMacros, SetupMacro from specfile.sections import Section @pytest.mark.parametrize( - "cls, optstr, options", - [ - ( - SetupMacro, - "-q -n %{srcname}-%{version}", - NS( - n="%{srcname}-%{version}", - q=True, - c=False, - D=False, - T=False, - b=None, - a=None, - ), - ), - ( - SetupMacro, - "-T -a1", - NS(n="%{name}-%{version}", q=False, c=False, D=False, T=True, b=None, a=1), - ), - (PatchMacro, "-p1 -b .patch1", NS(P=None, p=1, b=".patch1", E=False)), - (PatchMacro, "-P 28 -E", NS(P=28, p=None, b=None, E=True)), - ( - AutosetupMacro, - "-p1 -v", - NS( - n="%{name}-%{version}", - v=True, - c=False, - D=False, - T=False, - b=None, - a=None, - N=False, - S="patch", - p=1, - ), - ), - ( - AutosetupMacro, - "-Sgit -p 2", - NS( - n="%{name}-%{version}", - v=False, - c=False, - D=False, - T=False, - b=None, - a=None, - N=False, - S="git", - p=2, - ), - ), - ( - AutopatchMacro, - "-p1 -m 100 -M 199", - NS(v=False, p=1, m=100, M=199, indices=[]), - ), - ( - AutopatchMacro, - "-p2 -v 3 4 7", - NS(v=True, p=2, m=None, M=None, indices=[3, 4, 7]), - ), - ], -) -def test_prep_macro_parse_options(cls, optstr, options): - macro = cls(cls.CANONICAL_NAME, 0) - macro._parse_options(optstr) - assert macro.options == options - - -@pytest.mark.parametrize( - "name, optstr, index", + "name, options, number", [ ("%patch2", "-p1", 2), ("%patch0028", "-p2", 28), @@ -92,14 +18,27 @@ def test_prep_macro_parse_options(cls, optstr, options): ("%patch3", "-P5", 5), ], ) -def test_patch_macro_index(name, optstr, index): - macro = PatchMacro(name, 0) - macro._parse_options(optstr) - assert macro.index == index +def test_patch_macro_number(name, options, number): + macro = PatchMacro( + name, MacroOptions(MacroOptions.tokenize(options), PatchMacro.OPTSTRING), " " + ) + assert macro.number == number + + +def test_prep_macros_find(): + macros = PrepMacros( + [ + SetupMacro("%setup", MacroOptions([]), ""), + PatchMacro("%patch0", MacroOptions([]), ""), + ] + ) + assert macros.find("%patch0") == 1 + with pytest.raises(ValueError): + macros.find("%autosetup") @pytest.mark.parametrize( - "lines_before, index, options, lines_after", + "lines_before, number, options, lines_after", [ ( ["%setup -q"], @@ -110,8 +49,8 @@ def test_patch_macro_index(name, optstr, index): ( ["%setup -q", "%patch0 -p1"], 0, - dict(p=3), - ["%setup -q", "%patch0 -p3", "%patch0 -p1"], + dict(p=2), + ["%setup -q", "%patch0 -p1", "%patch0 -p2"], ), ( ["%setup -q", "%patch999 -p1"], @@ -126,26 +65,26 @@ def test_patch_macro_index(name, optstr, index): ["%setup -q", "%patch999 -p1", "%patch1001 -p1"], ), ( - ["%setup -q", "%{?!skip_first_patch:%patch0 -p1}", "%patch999 -p1"], + ["%setup -q", "%{!?skip_first_patch:%patch0 -p1}", "%patch999 -p1"], 28, dict(p=2, b=".patch28"), [ "%setup -q", - "%{?!skip_first_patch:%patch0 -p1}", + "%{!?skip_first_patch:%patch0 -p1}", "%patch28 -p2 -b .patch28", "%patch999 -p1", ], ), ], ) -def test_prep_add_patch_macro(lines_before, index, options, lines_after): - section = Section("prep", lines_before) - Prep(section).add_patch_macro(index, **options) - assert section == Section("prep", lines_after) +def test_prep_add_patch_macro(lines_before, number, options, lines_after): + prep = Prep.parse(Section("prep", lines_before)) + prep.add_patch_macro(number, **options) + assert prep.get_raw_section_data() == lines_after @pytest.mark.parametrize( - "lines_before, index, lines_after", + "lines_before, number, lines_after", [ ( ["%setup -q", "%patch0 -p1", "%patch1 -p1", "%patch2 -p1"], @@ -153,13 +92,33 @@ def test_prep_add_patch_macro(lines_before, index, options, lines_after): ["%setup -q", "%patch0 -p1", "%patch2 -p1"], ), ( - ["%setup -q", "%{?!skip_first_patch:%patch0 -p1}", "%patch1 -p1"], + ["%setup -q", "%{!?skip_first_patch:%patch0 -p1}", "%patch1 -p1"], 0, ["%setup -q", "%patch1 -p1"], ), ], ) -def test_prep_remove_patch_macro(lines_before, index, lines_after): - section = Section("prep", lines_before) - Prep(section).remove_patch_macro(index) - assert section == Section("prep", lines_after) +def test_prep_remove_patch_macro(lines_before, number, lines_after): + prep = Prep.parse(Section("prep", lines_before)) + prep.remove_patch_macro(number) + assert prep.get_raw_section_data() == lines_after + + +def test_prep_parse(): + prep = Prep.parse( + Section( + "prep", + [ + "%setup -q", + "# a comment", + "%patch0 -p1", + "%{!?skip_patch2:%patch2 -p2}", + "", + ], + ) + ) + assert prep.macros[0].name == "%setup" + assert prep.macros[0].options.q + assert prep.macros[1].name == "%patch0" + assert prep.macros[1].options.p == 1 + assert prep.patch2.options.p == 2