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

17 from __future__ import annotations

18

19 from logging import getLogger

20 from re import IGNORECASE, Pattern

21 from re import compile as re_compile

22 from re import error as re_error

23 from typing import TYPE_CHECKING

24

25 try:

26 from semantic_version import NpmSpec

27 except ImportError:

28 # semantic_version < 2.7

29 from semantic_version import Spec as NpmSpec

30 from semantic_version import Version

31

32 if TYPE_CHECKING: 32 ↛ 33 line 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

40 logger = getLogger(__name__)

41

42

43 class 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 ↛ 66 line 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 ↛ 119 line 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 ↛ 119 line 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 ↛ 142 line 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")