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

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/>. 

16 

17from __future__ import annotations 

18 

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 

24 

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 

31 

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 

35 

36 InputData = list[str] | Callable[[], list[str]] 

37 Patterns = list[str | Pattern[str]] 

38 

39 

40logger = getLogger(__name__) 

41 

42 

43class Matcher: 

44 """Matcher.""" 

45 

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. 

55 

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 

74 

75 def match( 

76 self, 

77 input_data: InputData, 

78 ) -> list[Match]: 

79 """Filer. 

80 

81 Args: 

82 input_data: Either a list of string or an input function, called only when needed (and at most once). 

83 

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()) 

93 

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 

101 

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 

111 

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 

116 

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 

125 

126 def _parse_pattern(self, pattern: str) -> None | str | Pattern[str]: 

127 """Parse a pattern. 

128 

129 Args: 

130 pattern: The pattern as string. 

131 

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 

162 

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] 

169 

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")