Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/pathspec/patterns/gitignore/base.py: 81%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

110 statements  

1""" 

2This module provides common classes for the gitignore patterns. 

3""" 

4 

5import re 

6 

7from typing import ( 

8 Literal) 

9 

10from pathspec.pattern import ( 

11 RegexPattern) 

12from pathspec._typing import ( 

13 AnyStr, # Removed in 3.18. 

14 assert_unreachable) 

15 

16_BYTES_ENCODING = 'latin1' 

17""" 

18The encoding to use when parsing a byte string pattern. 

19""" 

20 

21_POSIX_CLASS_TO_REGEX = { 

22 # Git's wildmatch implements POSIX bracket character classes using its own 

23 # ASCII (locale-independent) ``is*`` functions, so each class maps to an 

24 # explicit ASCII set. These are NOT the Unicode-aware equivalents (``\w``, 

25 # ``\d``, ``\s``); using those would over-match non-ASCII characters that 

26 # git never matches. 

27 'alnum': '0-9A-Za-z', 

28 'alpha': 'A-Za-z', 

29 'blank': '\\t ', 

30 'cntrl': '\\x00-\\x1f\\x7f', 

31 'digit': '0-9', 

32 'graph': '\\x21-\\x7e', 

33 'lower': 'a-z', 

34 'print': '\\x20-\\x7e', 

35 'punct': '!-/:-@\\[-`{-~', 

36 'space': '\\t\\n\\r ', 

37 'upper': 'A-Z', 

38 'xdigit': '0-9A-Fa-f', 

39} 

40""" 

41Maps each POSIX bracket character class name to the ASCII regex range that 

42reproduces git's wildmatch behavior. 

43""" 

44 

45_POSIX_CLASS_REGEX = re.compile(r'\[:(\^?)([^:\]]*):\]') 

46""" 

47Matches a POSIX bracket character class token such as ``[:alpha:]`` inside a 

48bracket expression. Group 1 captures a leading caret (unsupported by git); 

49group 2 captures the class name. 

50""" 

51 

52 

53class _InvalidPosixClass(Exception): 

54 """ 

55 Raised internally when a bracket expression contains an unknown or negated 

56 POSIX character class name. Git treats such a pattern as malformed. 

57 """ 

58 pass 

59 

60 

61def _translate_posix_class(match: 're.Match') -> str: 

62 """ 

63 Translate a single POSIX character class token to its ASCII regex range. 

64 Raises :class:`_InvalidPosixClass` for a negated (``[:^name:]``) or unknown 

65 class name, matching git's treatment of it as a malformed pattern. 

66 """ 

67 negated, name = match.group(1), match.group(2) 

68 class_regex = _POSIX_CLASS_TO_REGEX.get(name) 

69 if negated or class_regex is None: 

70 raise _InvalidPosixClass() 

71 return class_regex 

72 

73 

74class _GitIgnoreBasePattern(RegexPattern): 

75 """ 

76 .. warning:: This class is not part of the public API. It is subject to 

77 change. 

78 

79 The :class:`_GitIgnoreBasePattern` class is the base implementation for a 

80 compiled gitignore pattern. 

81 """ 

82 

83 # Keep the dict-less class hierarchy. 

84 __slots__ = () 

85 

86 @staticmethod 

87 def escape(s: AnyStr) -> AnyStr: 

88 """ 

89 Escape special characters in the given string. 

90 

91 *s* (:class:`str` or :class:`bytes`) a filename or a string that you want to 

92 escape, usually before adding it to a ".gitignore". 

93 

94 Returns the escaped string (:class:`str` or :class:`bytes`). 

95 """ 

96 if isinstance(s, str): 

97 return_type = str 

98 string = s 

99 elif isinstance(s, bytes): 

100 return_type = bytes 

101 string = s.decode(_BYTES_ENCODING) 

102 else: 

103 raise TypeError(f"s:{s!r} is not a unicode or byte string.") 

104 

105 # Reference: https://git-scm.com/docs/gitignore#_pattern_format 

106 out_string = ''.join((f"\\{x}" if x in '\\[]!*#?' else x) for x in string) 

107 

108 # EDGE CASE: Git strips trailing spaces from a pattern unless they are 

109 # escaped with a backslash. Escape them so an escaped filename that ends 

110 # with a space still matches that file. 

111 stripped = out_string.rstrip(' ') 

112 trailing = len(out_string) - len(stripped) 

113 if trailing: 

114 out_string = stripped + '\\ ' * trailing 

115 

116 if return_type is bytes: 

117 out_bytes = out_string.encode(_BYTES_ENCODING) 

118 return out_bytes # type: ignore[return-value] 

119 else: 

120 return out_string # type: ignore[return-value] 

121 

122 @staticmethod 

123 def _translate_segment_glob( 

124 pattern: str, 

125 range_error: Literal['literal', 'raise'], 

126 ) -> str: 

127 """ 

128 Translates the glob pattern to a regular expression. This is used in the 

129 constructor to translate a path segment glob pattern to its corresponding 

130 regular expression. 

131 

132 *pattern* (:class:`str`) is the glob pattern. 

133 

134 *range_error* (:class:`int`) is how to handle invalid range notation in the 

135 pattern: 

136 

137 - :data:`"literal"`: Invalid notation will be treated as a literal string. 

138 

139 - :data:`"raise"`: Invalid notation will cause a :class:`_RangeError` to be 

140 raised. 

141 

142 Returns the regular expression (:class:`str`). 

143 """ 

144 # NOTE: This is derived from `fnmatch.translate()` and is similar to the 

145 # POSIX function `fnmatch()` with the `FNM_PATHNAME` flag set. 

146 

147 escape = False 

148 regex = '' 

149 i, end = 0, len(pattern) 

150 while i < end: 

151 # Get next character. 

152 char = pattern[i] 

153 i += 1 

154 

155 if escape: 

156 # Escape the character. 

157 escape = False 

158 regex += re.escape(char) 

159 

160 elif char == '\\': 

161 # Escape character, escape next character. 

162 escape = True 

163 

164 elif char == '*': 

165 # Multi-character wildcard. Match any string (except slashes), including 

166 # an empty string. 

167 regex += '[^/]*' 

168 

169 elif char == '?': 

170 # Single-character wildcard. Match any single character (except a 

171 # slash). 

172 regex += '[^/]' 

173 

174 elif char == '[': 

175 # Bracket expression (range notation) wildcard. Except for the beginning 

176 # exclamation mark, the whole bracket expression can be used directly as 

177 # regex, but we have to find where the expression ends. 

178 # - "[][!]" matches ']', '[' and '!'. 

179 # - "[]-]" matches ']' and '-'. 

180 # - "[!]a-]" matches any character except ']', 'a' and '-'. 

181 bracket_start = i - 1 

182 j = i 

183 

184 # Pass bracket expression negation. 

185 if j < end and (pattern[j] == '!' or pattern[j] == '^'): 

186 j += 1 

187 

188 # Pass first closing bracket if it is at the beginning of the 

189 # expression. 

190 if j < end and pattern[j] == ']': 

191 j += 1 

192 

193 # Find closing bracket. Stop once we reach the end or find it. 

194 while j < end and pattern[j] != ']': 

195 if pattern[j] == '[' and j + 1 < end and pattern[j + 1] == ':': 

196 # Skip over a POSIX character class token ("[:name:]") so its 

197 # internal closing bracket is not mistaken for the end of the 

198 # whole bracket expression. 

199 close = pattern.find(':]', j + 2) 

200 if close == -1: 

201 j = end 

202 break 

203 j = close + 2 

204 else: 

205 j += 1 

206 

207 if j < end: 

208 # Found end of bracket expression. Increment j to be one past the 

209 # closing bracket: 

210 # 

211 # [...] 

212 # ^ ^ 

213 # i j 

214 # 

215 j += 1 

216 expr = '[' 

217 

218 if pattern[i] == '!': 

219 # Bracket expression needs to be negated. 

220 expr += '^' 

221 i += 1 

222 elif pattern[i] == '^': 

223 # POSIX declares that the regex bracket expression negation "[^...]" 

224 # is undefined in a glob pattern. Python's `fnmatch.translate()` 

225 # escapes the caret ('^') as a literal. Git supports the using a 

226 # caret for negation. Maintain consistency with Git because that is 

227 # the expected behavior. 

228 expr += '^' 

229 i += 1 

230 

231 # Build regex bracket expression. Escape slashes so they are treated 

232 # as literal slashes by regex as defined by POSIX. 

233 body = pattern[i:j].replace('\\', '\\\\') 

234 

235 # Translate POSIX character classes (e.g. "[:alpha:]") into their 

236 # ASCII regex equivalents. Git's wildmatch supports these but 

237 # Python's `re` does not, so passing them through verbatim builds a 

238 # broken regex that silently mismatches (and warns about a nested 

239 # set). 

240 try: 

241 body = _POSIX_CLASS_REGEX.sub(_translate_posix_class, body) 

242 except _InvalidPosixClass: 

243 # Git treats an unknown or negated class name as a malformed 

244 # pattern that matches nothing. 

245 if range_error == 'raise': 

246 raise _RangeError(( 

247 f"Invalid character class found in pattern={pattern!r}." 

248 )) 

249 else: 

250 # Treat the whole bracket expression as a literal. 

251 regex += re.escape(pattern[bracket_start:j]) 

252 i = j 

253 continue 

254 

255 expr += body 

256 

257 if range_error == 'raise': 

258 try: 

259 re.compile(expr) 

260 except re.error as e: 

261 raise _RangeError(( 

262 f"Invalid range notation={pattern[i:j]!r} found in " 

263 f"pattern={pattern!r}." 

264 )) from e 

265 

266 # Add regex bracket expression to regex result. 

267 regex += expr 

268 

269 # Set i to one past the closing bracket. 

270 i = j 

271 

272 else: 

273 # Failed to find closing bracket. 

274 if range_error == 'literal': 

275 # Treat opening bracket as a bracket literal instead of as an 

276 # expression. 

277 regex += '\\[' 

278 elif range_error == 'raise': 

279 # Treat invalid range notation as an error. 

280 raise _RangeError(( 

281 f"Invalid range notation={pattern[i:j]!r} found in pattern=" 

282 f"{pattern!r}." 

283 )) 

284 else: 

285 assert_unreachable(f"{range_error=!r} is invalid.") 

286 

287 else: 

288 # Regular character, escape it for regex. 

289 regex += re.escape(char) 

290 

291 if escape: 

292 raise ValueError(( 

293 f"Escape character found with no next character to escape: {pattern!r}" 

294 )) # ValueError 

295 

296 return regex 

297 

298 

299class GitIgnorePatternError(ValueError): 

300 """ 

301 The :class:`GitIgnorePatternError` class indicates an invalid gitignore 

302 pattern. 

303 """ 

304 pass 

305 

306 

307class _RangeError(GitIgnorePatternError): 

308 """ 

309 The :class:`_RangeError` class indicates an invalid range notation was found 

310 in a gitignore pattern. 

311 """ 

312 pass