diff --git a/specfile/conditions.py b/specfile/conditions.py new file mode 100644 index 0000000..f174c18 --- /dev/null +++ b/specfile/conditions.py @@ -0,0 +1,137 @@ +# Copyright Contributors to the Packit project. +# SPDX-License-Identifier: MIT + +import re +from typing import TYPE_CHECKING, List, Optional, Tuple + +from specfile.exceptions import RPMException +from specfile.macros import Macros + +if TYPE_CHECKING: + from specfile.macro_definitions import MacroDefinitions + from specfile.specfile import Specfile + + +def resolve_expression( + keyword: str, expression: str, context: Optional["Specfile"] = None +) -> bool: + """ + Resolves a RPM expression. + + Args: + keyword: Condition keyword, e.g. `%if` or `%ifarch`. + expression: Expression string or a whitespace-delimited list + of arches/OSes in case keyword is a variant of `%ifarch`/`%ifos`. + context: `Specfile` instance that defines the context for macro expansions. + + Returns: + Resolved expression as a boolean value. + """ + + def expand(s): + if not context: + return Macros.expand(s) + result = context.expand(s, skip_parsing=getattr(expand, "skip_parsing", False)) + # parse only once + expand.skip_parsing = True + return result + + if keyword in ("%if", "%elif"): + try: + result = expand(f"%{{expr:{expression}}}") + except RPMException: + return False + try: + return int(result) != 0 + except ValueError: + return True + elif keyword.endswith("arch"): + target_cpu = expand("%{_target_cpu}") + match = any(t for t in expression.split() if t == target_cpu) + return not match if keyword == "%ifnarch" else match + elif keyword.endswith("os"): + target_os = expand("%{_target_os}") + match = any(t for t in expression.split() if t == target_os) + return not match if keyword == "%ifnos" else match + return False + + +def process_conditions( + lines: List[str], + macro_definitions: Optional["MacroDefinitions"] = None, + context: Optional["Specfile"] = None, +) -> List[Tuple[str, bool]]: + """ + Processes conditions in a spec file. Takes a list of lines and returns the same + list of lines extended with information about their validity. A line is considered + valid if it doesn't appear in a false branch of any condition. + + Args: + lines: List of lines in a spec file. + macro_definitions: Parsed macro definitions to be used to prevent parsing conditions + inside their bodies (and most likely failing). + context: `Specfile` instance that defines the context for macro expansions. + + Returns: + List of tuples in the form of (line, validity). + """ + excluded_lines = [] + for md in macro_definitions or []: + position = md.get_position(macro_definitions) + excluded_lines.append(range(position, position + len(md.body.splitlines()))) + condition_regex = re.compile( + r""" + ^ + \s* # optional preceding whitespace + (?P%((el)?if(n?(arch|os))?|endif|else)) # keyword + \s* + ( + \s+ + (?P.*?) # expression + (?P\s*|\\) # optional following whitespace + # or a backslash indicating + # that the expression continues + # on the next line + )? + $ + """, + re.VERBOSE, + ) + result = [] + branches = [True] + indexed_lines = list(enumerate(lines)) + while indexed_lines: + index, line = indexed_lines.pop(0) + # ignore conditions inside macro definition body + if any(index in r for r in excluded_lines): + result.append((line, branches[-1])) + continue + m = condition_regex.match(line) + if not m: + result.append((line, branches[-1])) + continue + keyword = m.group("kwd") + if keyword == "%endif": + result.append((line, branches[-2])) + branches.pop() + elif keyword.startswith("%el"): + result.append((line, branches[-2])) + branches[-1] = not branches[-1] + else: + result.append((line, branches[-1])) + expression = m.group("expr") + if expression: + if m.group("end") == "\\": + expression += "\\" + while expression.endswith("\\") and indexed_lines: + _, line = indexed_lines.pop(0) + result.append((line, branches[-1])) + expression = expression[:-1] + line + branch = ( + False if not branches[-1] else resolve_expression(keyword, expression) + ) + if keyword.startswith("%el"): + branches[-1] = branch + else: + branches.append(branch) + return result diff --git a/specfile/macro_definitions.py b/specfile/macro_definitions.py index 0e2f2b6..851ace5 100644 --- a/specfile/macro_definitions.py +++ b/specfile/macro_definitions.py @@ -4,11 +4,15 @@ import collections import copy import re -from typing import List, Optional, Tuple, Union, overload +from typing import TYPE_CHECKING, List, Optional, Tuple, Union, overload +from specfile.conditions import process_conditions from specfile.formatter import formatted from specfile.types import SupportsIndex +if TYPE_CHECKING: + from specfile.specfile import Specfile + class MacroDefinition: def __init__( @@ -17,12 +21,14 @@ def __init__( body: str, is_global: bool, whitespace: Tuple[str, str, str, str], + valid: bool = True, preceding_lines: Optional[List[str]] = None, ) -> None: self.name = name self.body = body self.is_global = is_global self._whitespace = whitespace + self.valid = valid self._preceding_lines = ( preceding_lines.copy() if preceding_lines is not None else [] ) @@ -42,7 +48,7 @@ def __eq__(self, other: object) -> bool: def __repr__(self) -> str: return ( f"MacroDefinition({self.name!r}, {self.body!r}, {self.is_global!r}, " - f"{self._whitespace!r}, {self._preceding_lines!r})" + f"{self._whitespace!r}, {self.valid!r}, {self._preceding_lines!r})" ) def __str__(self) -> str: @@ -189,7 +195,9 @@ def find(self, name: str) -> int: raise ValueError @classmethod - def parse(cls, lines: List[str]) -> "MacroDefinitions": + def _parse( + cls, lines: Union[List[str], List[Tuple[str, bool]]] + ) -> "MacroDefinitions": """ Parses given lines into macro defintions. @@ -200,6 +208,13 @@ def parse(cls, lines: List[str]) -> "MacroDefinitions": Constructed instance of `MacroDefinitions` class. """ + def pop(lines): + line = lines.pop(0) + if isinstance(line, str): + return line, True + else: + return line + def count_brackets(s): bc = pc = 0 chars = list(s) @@ -248,7 +263,7 @@ def count_brackets(s): buffer: List[str] = [] lines = lines.copy() while lines: - line = lines.pop(0) + line, valid = pop(lines) m = md_regex.match(line) if m: ws0, macro, ws1, name, ws2, body, ws3 = m.groups() @@ -257,7 +272,7 @@ def count_brackets(s): ws3 = "" bc, pc = count_brackets(body) while (bc > 0 or pc > 0 or body.endswith("\\")) and lines: - line = lines.pop(0) + line, _ = pop(lines) body += "\n" + line bc, pc = count_brackets(body) tokens = re.split(r"(\s+)$", body, maxsplit=1) @@ -268,7 +283,12 @@ def count_brackets(s): ws3 = ws + ws3 data.append( MacroDefinition( - name, body, macro == "%global", (ws0, ws1, ws2, ws3), buffer + name, + body, + macro == "%global", + (ws0, ws1, ws2, ws3), + valid, + buffer, ) ) buffer = [] @@ -276,6 +296,30 @@ def count_brackets(s): buffer.append(line) return cls(data, buffer) + @classmethod + def parse( + cls, + lines: List[str], + with_conditions: bool = False, + context: Optional["Specfile"] = None, + ) -> "MacroDefinitions": + """ + Parses given lines into macro defintions. + + Args: + lines: Lines to parse. + with_conditions: Whether to process conditions before parsing and populate + the `valid` attribute. + context: `Specfile` instance that defines the context for macro expansions. + + Returns: + Constructed instance of `MacroDefinitions` class. + """ + result = cls._parse(lines) + if not with_conditions: + return result + return cls._parse(process_conditions(lines, result, context)) + def get_raw_data(self) -> List[str]: result = [] for macro_definition in self.data: diff --git a/specfile/sources.py b/specfile/sources.py index b4a54f3..62986c2 100644 --- a/specfile/sources.py +++ b/specfile/sources.py @@ -458,6 +458,21 @@ def _get_initial_tag_setup(self, number: int = 0) -> Tuple[int, str, str]: suffix = f"{number:0{self._default_source_number_digits}}" return len(self._tags) if self._tags else 0, f"{self.prefix}{suffix}", ": " + def _get_tag_validity(self, reference: Optional[TagSource] = None) -> bool: + """ + Determines validity of a new source tag based on a reference tag, if specified, + or the last tag in the spec file. Defaults to True. + + Args: + reference: Optional reference tag source. + + Returns: + Whether the new source tag is valid or not. + """ + if reference is not None: + return reference._tag.valid + return self._tags[-1].valid if self._tags else True + def _deduplicate_tag_names(self, start: int = 0) -> None: """ Eliminates duplicate numbers in source tag names. @@ -505,9 +520,17 @@ def insert(self, i: int, location: str) -> None: number = source.number if isinstance(source, self.tag_class): name, separator = self._get_tag_format(cast(TagSource, source), number) + valid = self._get_tag_validity(cast(TagSource, source)) container.insert( index, - Tag(name, location, separator, Comments(), context=self._context), + Tag( + name, + location, + separator, + Comments(), + valid, + context=self._context, + ), ) self._deduplicate_tag_names(i) else: @@ -523,9 +546,12 @@ def insert(self, i: int, location: str) -> None: ) else: index, name, separator = self._get_initial_tag_setup() + valid = self._get_tag_validity() self._tags.insert( index, - Tag(name, location, separator, Comments(), context=self._context), + Tag( + name, location, separator, Comments(), valid, context=self._context + ), ) def insert_numbered(self, number: int, location: str) -> int: @@ -555,11 +581,14 @@ def insert_numbered(self, number: int, location: str) -> int: i += 1 index += 1 name, separator = self._get_tag_format(source, number) + valid = self._get_tag_validity(source) else: i = 0 index, name, separator = self._get_initial_tag_setup(number) + valid = self._get_tag_validity() self._tags.insert( - index, Tag(name, location, separator, Comments(), context=self._context) + index, + Tag(name, location, separator, Comments(), valid, context=self._context), ) self._deduplicate_tag_names(i) return i diff --git a/specfile/specfile.py b/specfile/specfile.py index fbb00d1..240f1dc 100644 --- a/specfile/specfile.py +++ b/specfile/specfile.py @@ -223,7 +223,9 @@ def macro_definitions(self) -> Generator[MacroDefinitions, None, None]: Macro definitions in the spec file as `MacroDefinitions` object. """ with self.lines() as lines: - macro_definitions = MacroDefinitions.parse(lines) + macro_definitions = MacroDefinitions.parse( + lines, with_conditions=True, context=self + ) try: yield macro_definitions finally: diff --git a/specfile/tags.py b/specfile/tags.py index cbfc97f..0b2ba27 100644 --- a/specfile/tags.py +++ b/specfile/tags.py @@ -17,8 +17,10 @@ overload, ) +from specfile.conditions import process_conditions from specfile.constants import TAG_NAMES, TAGS_WITH_ARG from specfile.formatter import formatted +from specfile.macro_definitions import MacroDefinitions from specfile.macros import Macros from specfile.sections import Section from specfile.types import SupportsIndex @@ -212,6 +214,7 @@ def __init__( value: str, separator: str, comments: Comments, + valid: bool = True, prefix: Optional[str] = None, suffix: Optional[str] = None, context: Optional["Specfile"] = None, @@ -227,6 +230,7 @@ def __init__( Separator between name and literal value (colon usually surrounded by some amount of whitespace). comments: List of comments associated with the tag. + valid: Whether the tag is not located in a false branch of a condition. prefix: Characters preceding the tag on a line. suffix: Characters following the tag on a line. context: `Specfile` instance that defines the context for macro expansions. @@ -243,6 +247,7 @@ def __init__( self.value = value self._separator = separator self.comments = comments.copy() + self.valid = valid self._prefix = prefix or "" self._suffix = suffix or "" self._context = context @@ -263,7 +268,7 @@ def __eq__(self, other: object) -> bool: def __repr__(self) -> str: return ( f"Tag({self.name!r}, {self.value!r}, {self._separator!r}, {self.comments!r}, " - f"{self._prefix!r}, {self._suffix!r}, {self._context!r})" + f"{self.valid!r}, {self._prefix!r}, {self._suffix!r}, {self._context!r})" ) def __deepcopy__(self, memo: Dict[int, Any]) -> "Tag": @@ -433,10 +438,14 @@ def __delattr__(self, name: str) -> None: def copy(self) -> "Tags": return copy.copy(self) - def find(self, name: str) -> int: + def get(self, name: str, position: Optional[int] = None) -> Tag: + return self.data[self.find(name, position)] + + def find(self, name: str, position: Optional[int] = None) -> int: for i, tag in enumerate(self.data): if tag.name.capitalize() == name.capitalize(): - return i + if position is None or tag.get_position(self) == position: + return i raise ValueError def insert(self, i: int, item: Tag) -> None: @@ -472,10 +481,12 @@ def regex_pattern(tag): name_regex = get_tag_name_regex(tag) return rf"^(?P{name_regex})(?P\s*:\s*)(?P.+)" + macro_definitions = MacroDefinitions.parse(list(section)) + lines = process_conditions(list(section), macro_definitions, context) tag_regexes = [re.compile(regex_pattern(t), re.IGNORECASE) for t in TAG_NAMES] data = [] buffer: List[str] = [] - for line in section: + for line, valid in lines: line, prefix, suffix = split_conditional_macro_expansion(line) # find out if there is a match for one of the tag regexes m = next((m for m in (r.match(line) for r in tag_regexes) if m), None) @@ -486,6 +497,7 @@ def regex_pattern(tag): m.group("v"), m.group("s"), Comments.parse(buffer), + valid, prefix, suffix, context, diff --git a/tests/unit/test_conditions.py b/tests/unit/test_conditions.py new file mode 100644 index 0000000..a26654e --- /dev/null +++ b/tests/unit/test_conditions.py @@ -0,0 +1,79 @@ +# Copyright Contributors to the Packit project. +# SPDX-License-Identifier: MIT + +import pytest +from flexmock import flexmock + +import specfile.conditions +from specfile.conditions import process_conditions + + +@pytest.mark.parametrize( + "lines, validity, resolve_func", + [ + ( + ["%ifarch %{power64}", "export ARCH=PPC64", "%endif"], + [True, True, True], + lambda kwd, exp: True, + ), + ( + ["%ifarch %{power64}", "export ARCH=PPC64", "%endif"], + [True, False, True], + lambda kwd, exp: False, + ), + ( + [ + "%if 0%{?fedora} > 38", + "Patch0: fedora.patch", + "%elif 0%{?rhel} > 8", + "Patch0: rhel.patch", + "%else", + "Patch0: unsupported.patch", + "%endif", + ], + [True, False, True, True, True, False, True], + lambda kwd, exp: "rhel" in exp, + ), + ( + [ + "%if %{with_gui}", + "BuildRequires: libX11-devel", + "%if 0%{?fedora}", + "Requires: desktop-file-utils", + "%endif", + "BuildRequires: libXext-devel", + "%else", + "%if %{with_curses}", + "BuildRequires: ncurses-devel", + "%endif", + "%global GUI 0", + "%endif", + ], + [ + True, + False, + False, + False, + False, + False, + True, + True, + False, + True, + True, + True, + ], + lambda kwd, exp: "fedora" in exp, + ), + ], +) +def test_process_conditions(lines, validity, resolve_func): + def resolve_expression(kwd, exp, *_, **__): + return resolve_func(kwd, exp) + + flexmock(specfile.conditions).should_receive("resolve_expression").replace_with( + resolve_expression + ) + processed_lines, processed_validity = zip(*process_conditions(lines)) + assert list(processed_lines) == lines + assert list(processed_validity) == validity diff --git a/tests/unit/test_macro_definitions.py b/tests/unit/test_macro_definitions.py index aa54cf3..4673ca5 100644 --- a/tests/unit/test_macro_definitions.py +++ b/tests/unit/test_macro_definitions.py @@ -105,6 +105,7 @@ def test_get_raw_data(): "Test spec file containing several \\\nmacro definitions in various formats (%?1)", False, ("", " ", " ", ""), + True, [ "", "Name: test", @@ -119,6 +120,7 @@ def test_get_raw_data(): "spawning across mutiple lines}", False, ("", " ", " ", ""), + True, [""], ), ] diff --git a/tests/unit/test_tags.py b/tests/unit/test_tags.py index 8beb740..66c7bff 100644 --- a/tests/unit/test_tags.py +++ b/tests/unit/test_tags.py @@ -109,6 +109,7 @@ def test_get_raw_section_data(): "diffutils", ": ", Comments([], [""]), + True, "%{?fedora:", "}", ),