Coverage for src/gitlabracadabra/matchers.py: 89%
94 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-10 17:02 +0100
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-10 17:02 +0100
1#
2# Copyright (C) 2019-2025 Mathieu Parent <math.parent@gmail.com>
3#
4# This program is free software: you can redistribute it and/or modify
5# it under the terms of the GNU Lesser General Public License as published by
6# the Free Software Foundation, either version 3 of the License, or
7# (at your option) any later version.
8#
9# This program is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>.
17from __future__ import annotations
19from logging import getLogger
20from re import IGNORECASE, Pattern
21from re import compile as re_compile
22from re import error as re_error
23from typing import TYPE_CHECKING
25try:
26 from semantic_version import NpmSpec
27except ImportError:
28 # semantic_version < 2.7
29 from semantic_version import Spec as NpmSpec
30from semantic_version import Version
32if TYPE_CHECKING: 32 ↛ 33line 32 didn't jump to line 33 because the condition on line 32 was never true
33 from collections.abc import Callable
34 from re import Match
36 InputData = list[str] | Callable[[], list[str]]
37 Patterns = list[str | Pattern[str]]
40logger = getLogger(__name__)
43class Matcher:
44 """Matcher."""
46 def __init__(
47 self,
48 patterns: str | list[str],
49 semver: str | None,
50 limit: int | None = None,
51 *,
52 log_prefix: str = "",
53 ) -> None:
54 """Initialize a matcher.
56 Args:
57 patterns: A pattern or list of patterns.
58 semver: Semantic versioning.
59 limit: Keep at most n latest versions.
60 log_prefix: Log prefix.
61 """
62 self._log_prefix = log_prefix
63 if not isinstance(patterns, list):
64 patterns = [patterns]
65 self._patterns: Patterns = []
66 for pattern in patterns:
67 parsed = self._parse_pattern(pattern)
68 if parsed is not None: 68 ↛ 66line 68 didn't jump to line 66 because the condition on line 68 was always true
69 self._patterns.append(parsed)
70 self._semver = None
71 if semver:
72 self._semver = NpmSpec(semver)
73 self._limit = limit
75 def match(
76 self,
77 input_data: InputData,
78 ) -> list[Match]:
79 """Filer.
81 Args:
82 input_data: Either a list of string or an input function, called only when needed (and at most once).
84 Returns:
85 List of matches.
86 """
87 has_regex = any(isinstance(pattern, Pattern) for pattern in self._patterns)
88 if not isinstance(input_data, list) and has_regex:
89 input_data = input_data()
90 if isinstance(input_data, list):
91 return self._limiter(self._match_list(input_data))
92 return self._limiter(self._match_all())
94 def _match_list(self, input_data: list[str]) -> list[Match[str]]:
95 matched_items: list[Match[str]] = []
96 for current_item in input_data:
97 match = self._match_item(current_item)
98 if match:
99 matched_items.append(match)
100 return matched_items
102 def _match_item(self, current_item: str) -> Match[str] | None:
103 if current_item in self._patterns:
104 return re_compile("^.*$").match(current_item)
105 for pattern in self._patterns:
106 if isinstance(pattern, Pattern):
107 match = pattern.match(current_item)
108 if match and self._match_semver(current_item):
109 return match
110 return None
112 def _match_semver(self, current_item: str) -> bool:
113 if not self._semver:
114 return True
115 return self._safe_version(current_item) in self._semver
117 def _match_all(self) -> list[Match[str]]:
118 matched_items: list[Match[str]] = []
119 for pattern in self._patterns:
120 if not isinstance(pattern, Pattern): 120 ↛ 119line 120 didn't jump to line 119 because the condition on line 120 was always true
121 match = re_compile("^.*$").match(pattern)
122 if match: 122 ↛ 119line 122 didn't jump to line 119 because the condition on line 122 was always true
123 matched_items.append(match)
124 return matched_items
126 def _parse_pattern(self, pattern: str) -> None | str | Pattern[str]:
127 """Parse a pattern.
129 Args:
130 pattern: The pattern as string.
132 Returns:
133 A string for exact match or a pattern.
134 """
135 if pattern.startswith("/"):
136 flags_str = pattern.rsplit("/", 1).pop()
137 flags = 0
138 for flag in flags_str:
139 if flag == "i": 139 ↛ 142line 139 didn't jump to line 142 because the condition on line 139 was always true
140 flags |= IGNORECASE
141 else:
142 logger.warning(
143 "%sInvalid regular expression flag %s in %s. Flag ignored.",
144 self._log_prefix,
145 flag,
146 pattern,
147 )
148 try:
149 return re_compile(
150 "^{}$".format(pattern[1 : pattern.rindex("/")]),
151 flags,
152 )
153 except re_error as err:
154 logger.warning(
155 "%sInvalid regular expression %s: %s. Skipping pattern.",
156 self._log_prefix,
157 pattern,
158 str(err),
159 )
160 return None
161 return pattern
163 def _limiter(self, matches: list[Match[str]]) -> list[Match[str]]:
164 if self._limit is None:
165 return matches
166 indexed_matches = {self._safe_version(match[0]): match for match in matches}
167 sorted_matches = [match for (_, match) in sorted(indexed_matches.items(), reverse=True)]
168 return sorted_matches[: self._limit]
170 def _safe_version(self, version_str: str) -> Version | None:
171 if version_str.startswith("v"):
172 version_str = version_str[1:]
173 try:
174 return Version.coerce(version_str)
175 except ValueError as err:
176 logger.warning(
177 "%s%s for %s",
178 self._log_prefix,
179 str(err),
180 str(version_str),
181 )
182 return Version("0.0.0-0")