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

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

117 statements  

1""" 

2This module provides :class:`GitIgnoreSpecPattern` which implements Git's 

3`gitignore`_ patterns, and handles edge-cases where Git's behavior differs from 

4what's documented. Git allows including files from excluded directories which 

5appears to contradict the documentation. Git discards patterns with invalid 

6range notation. This is used by :class:`~pathspec.gitignore.GitIgnoreSpec` to 

7fully replicate Git's handling. 

8 

9.. _`gitignore`: https://git-scm.com/docs/gitignore 

10""" 

11 

12from typing import ( 

13 Optional) # Replaced by `X | None` in 3.10. 

14 

15from pathspec._typing import ( 

16 AnyStr, # Removed in 3.18. 

17 assert_unreachable, 

18 override) # Added in 3.12. 

19 

20from .base import ( 

21 GitIgnorePatternError, 

22 _BYTES_ENCODING, 

23 _GitIgnoreBasePattern, 

24 _RangeError) 

25 

26_DIR_MARK = 'ps_d' 

27""" 

28The regex group name for the directory marker. This is only used by 

29:class:`GitIgnoreSpec`. 

30""" 

31 

32_DIR_MARK_CG = f'(?P<{_DIR_MARK}>/)' 

33""" 

34This regular expression matches the directory marker. 

35""" 

36 

37_DIR_MARK_OPT = f'(?:{_DIR_MARK_CG}|$)' 

38""" 

39This regular expression matches the optional directory marker and sub-path. 

40""" 

41 

42_MATCH_ALL = f'^(?s:.+/)?[^/]+{_DIR_MARK_OPT}' 

43""" 

44This regular expression matches every path. It is the expansion of the patterns 

45"*" and "**" (i.e., "**/{any name}"), and it has to capture the directory marker 

46like any other pattern so that :class:`.GitIgnoreSpec` can tell a directory 

47match from a file match. 

48""" 

49 

50 

51class GitIgnoreSpecPattern(_GitIgnoreBasePattern): 

52 """ 

53 The :class:`GitIgnoreSpecPattern` class represents a compiled gitignore 

54 pattern with special handling for edge-cases to replicate Git's behavior. 

55 

56 This is registered under the deprecated name "gitwildmatch" for backward 

57 compatibility with v0.12. The registered name will be removed in a future 

58 version. 

59 """ 

60 

61 # Keep the dict-less class hierarchy. 

62 __slots__ = () 

63 

64 @staticmethod 

65 def __normalize_segments( 

66 is_dir_pattern: bool, 

67 pattern_segs: list[str], 

68 ) -> tuple[Optional[list[str]], Optional[str]]: 

69 """ 

70 Normalize the pattern segments to make processing easier. 

71 

72 *is_dir_pattern* (:class:`bool`) is whether the pattern is a directory 

73 pattern (i.e., ends with a slash '/'). 

74 

75 *pattern_segs* (:class:`list` of :class:`str`) contains the pattern 

76 segments. This may be modified in place. 

77 

78 Returns a :class:`tuple` containing either: 

79 

80 - The normalized segments (:class:`list` of :class:`str`; or :data:`None`). 

81 

82 - The regular expression override (:class:`str` or :data:`None`). 

83 """ 

84 if not pattern_segs[0]: 

85 # A pattern beginning with a slash ('/') should match relative to the root 

86 # directory. Remove the empty first segment to make the pattern relative 

87 # to root. 

88 del pattern_segs[0] 

89 

90 elif len(pattern_segs) == 1 or (len(pattern_segs) == 2 and not pattern_segs[1]): 

91 # A single segment pattern with or without a trailing slash ('/') will 

92 # match any descendant path. This is equivalent to "**/{pattern}". Prepend 

93 # a double-asterisk segment to make the pattern relative to root. 

94 if pattern_segs[0] != '**': 

95 pattern_segs.insert(0, '**') 

96 

97 else: 

98 # A pattern without a beginning slash ('/') but contains at least one 

99 # prepended directory (e.g., "dir/{pattern}") should match relative to the 

100 # root directory. No segment modification is needed. 

101 pass 

102 

103 if not pattern_segs: 

104 # After normalization, we end up with no pattern at all. This must be 

105 # because the pattern is invalid. 

106 raise ValueError("Pattern normalized to nothing.") 

107 

108 if not pattern_segs[-1]: 

109 # A pattern ending with a slash ('/') will match all descendant paths if 

110 # it is a directory but not if it is a regular file. This is equivalent to 

111 # "{pattern}/**". Set the empty last segment to a double-asterisk to 

112 # include all descendants. 

113 pattern_segs[-1] = '**' 

114 

115 # EDGE CASE: Collapse duplicate double-asterisk sequences (i.e., '**/**'). 

116 # Iterate over the segments in reverse order and remove the duplicate double 

117 # asterisks as we go. 

118 for i in range(len(pattern_segs) - 1, 0, -1): 

119 prev = pattern_segs[i-1] 

120 seg = pattern_segs[i] 

121 if prev == '**' and seg == '**': 

122 del pattern_segs[i] 

123 

124 seg_count = len(pattern_segs) 

125 if seg_count == 1 and pattern_segs[0] == '**': 

126 if is_dir_pattern: 

127 # The pattern "**/" will be normalized to "**", but it should match 

128 # everything except for files in the root. Special case this pattern. 

129 return (None, _DIR_MARK_CG) 

130 else: 

131 # The pattern "**" will match every path. Special case this pattern. 

132 return (None, _MATCH_ALL) 

133 

134 elif ( 

135 seg_count == 2 

136 and pattern_segs[0] == '**' 

137 and pattern_segs[1] == '*' 

138 ): 

139 # The pattern "*" will be normalized to "**/*" and will match every 

140 # path. Special case this pattern for efficiency. 

141 return (None, _MATCH_ALL) 

142 

143 elif ( 

144 seg_count == 3 

145 and pattern_segs[0] == '**' 

146 and pattern_segs[1] == '*' 

147 and pattern_segs[2] == '**' 

148 ): 

149 # The pattern "*/" will be normalized to "**/*/**" which will match every 

150 # file not in the root directory. Special case this pattern for 

151 # efficiency. 

152 if is_dir_pattern: 

153 return (None, _DIR_MARK_CG) 

154 else: 

155 return (None, '/') 

156 

157 # No regular expression override, return modified pattern segments. 

158 return (pattern_segs, None) 

159 

160 @override 

161 @classmethod 

162 def pattern_to_regex( 

163 cls, 

164 pattern: AnyStr, 

165 ) -> tuple[Optional[AnyStr], Optional[bool]]: 

166 """ 

167 Convert the pattern into a regular expression. 

168 

169 *pattern* (:class:`str` or :class:`bytes`) is the pattern to convert into a 

170 regular expression. 

171 

172 Returns a :class:`tuple` containing: 

173 

174 - *pattern* (:class:`str`, :class:`bytes` or :data:`None`) is the 

175 uncompiled regular expression. 

176 

177 - *include* (:class:`bool` or :data:`None`) is whether matched files 

178 should be included (:data:`True`), excluded (:data:`False`), or is a 

179 null-operation (:data:`None`). 

180 """ 

181 if isinstance(pattern, str): 

182 pattern_str = pattern 

183 return_type = str 

184 elif isinstance(pattern, bytes): 

185 pattern_str = pattern.decode(_BYTES_ENCODING) 

186 return_type = bytes 

187 else: 

188 raise TypeError(f"{pattern=!r} is not a unicode or byte string.") 

189 

190 original_pattern = pattern_str 

191 del pattern 

192 

193 if pattern_str.endswith('\\ '): 

194 # EDGE CASE: Spaces can be escaped with backslash. If a pattern that ends 

195 # with a backslash is followed by a space, do not strip from the left. 

196 pass 

197 else: 

198 # EDGE CASE: Leading spaces should be kept (only trailing spaces should be 

199 # removed). Git does not remove leading spaces. 

200 pattern_str = pattern_str.rstrip() 

201 

202 regex: Optional[str] 

203 include: Optional[bool] 

204 

205 if not pattern_str: 

206 # A blank pattern is a null-operation (neither includes nor excludes 

207 # files). 

208 return (None, None) 

209 

210 elif pattern_str.startswith('#'): 

211 # A pattern starting with a hash ('#') serves as a comment (neither 

212 # includes nor excludes files). Escape the hash with a backslash to match 

213 # a literal hash (i.e., '\#'). 

214 return (None, None) 

215 

216 elif pattern_str == '/': 

217 # EDGE CASE: According to `git check-ignore` (v2.4.1), a single '/' does 

218 # not match any file. 

219 return (None, None) 

220 

221 if pattern_str.startswith('!'): 

222 # A pattern starting with an exclamation mark ('!') negates the pattern 

223 # (exclude instead of include). Escape the exclamation mark with a 

224 # backslash to match a literal exclamation mark (i.e., '\!'). 

225 include = False 

226 # Remove leading exclamation mark. 

227 pattern_str = pattern_str[1:] 

228 else: 

229 include = True 

230 

231 # Split pattern into segments. 

232 orig_segs = pattern_str.split('/') 

233 

234 # Check whether the pattern is specifically a directory pattern before 

235 # normalization. 

236 is_dir_pattern = not orig_segs[-1] 

237 

238 # Normalize pattern to make processing easier. 

239 try: 

240 pattern_segs, override_regex = cls.__normalize_segments( 

241 is_dir_pattern, orig_segs, 

242 ) 

243 except ValueError as e: 

244 raise GitIgnorePatternError(( 

245 f"Invalid git pattern: {original_pattern!r}" 

246 )) from e # GitIgnorePatternError 

247 

248 if override_regex is not None: 

249 # Use regex override. 

250 regex = override_regex 

251 

252 elif pattern_segs is not None: 

253 # Build regular expression from pattern. 

254 try: 

255 regex_parts = cls.__translate_segments(is_dir_pattern, pattern_segs) 

256 except _RangeError: 

257 # EDGE CASE: Git discards patterns with invalid range notation. 

258 return (None, None) 

259 except ValueError as e: 

260 raise GitIgnorePatternError(( 

261 f"Invalid git pattern: {original_pattern!r}" 

262 )) from e # GitIgnorePatternError 

263 

264 regex = ''.join(regex_parts) 

265 

266 else: 

267 assert_unreachable(( 

268 f"{override_regex=} and {pattern_segs=} cannot both be null." 

269 )) # assert_unreachable 

270 

271 # Encode regex if needed. 

272 out_regex: AnyStr 

273 if regex is not None and return_type is bytes: 

274 regex_bytes = regex.encode(_BYTES_ENCODING) 

275 out_regex = regex_bytes # type: ignore[assignment] 

276 else: 

277 out_regex = regex # type: ignore[assignment] 

278 

279 return (out_regex, include) 

280 

281 @classmethod 

282 def __translate_segments( 

283 cls, 

284 is_dir_pattern: bool, 

285 pattern_segs: list[str], 

286 ) -> list[str]: 

287 """ 

288 Translate the pattern segments to regular expressions. 

289 

290 *is_dir_pattern* (:class:`bool`) is whether the pattern is a directory 

291 pattern (i.e., ends with a slash '/'). 

292 

293 *pattern_segs* (:class:`list` of :class:`str`) contains the pattern 

294 segments. 

295 

296 Raises :class:`_RangeError` if invalid range notation is found. 

297 

298 Returns the regular expression parts (:class:`list` of :class:`str`). 

299 """ 

300 # Build regular expression from pattern. 

301 out_parts = [] 

302 need_slash = False 

303 end = len(pattern_segs) - 1 

304 for i, seg in enumerate(pattern_segs): 

305 if seg == '**': 

306 if i == 0: 

307 # A normalized pattern beginning with double-asterisks ('**') will 

308 # match any leading path segments. 

309 out_parts.append('^(?s:.+/)?') 

310 

311 elif i < end: 

312 # A pattern with inner double-asterisks ('**') will match multiple (or 

313 # zero) inner path segments. 

314 out_parts.append('(?s:/.+)?') 

315 need_slash = True 

316 

317 else: 

318 assert i == end, (i, end) 

319 # A normalized pattern ending with double-asterisks ('**') will match 

320 # nonempty trailing path segments, not the parent directory itself. 

321 if is_dir_pattern: 

322 out_parts.append(_DIR_MARK_CG) 

323 else: 

324 out_parts.append('/[^/]') 

325 

326 else: 

327 # Match path segment. 

328 if i == 0: 

329 # Anchor to root directory. 

330 out_parts.append('^') 

331 

332 if need_slash: 

333 out_parts.append('/') 

334 

335 if seg == '*': 

336 # Match whole path segment. 

337 out_parts.append('[^/]+') 

338 

339 else: 

340 # Match segment glob pattern. 

341 # - EDGE CASE: Git discards patterns with invalid range notation. 

342 out_parts.append(cls._translate_segment_glob(seg, 'raise')) 

343 

344 if i == end: 

345 # A pattern ending without a slash ('/') will match a file or a 

346 # directory (with paths underneath it). E.g., "foo" matches "foo", 

347 # "foo/bar", "foo/bar/baz", etc. 

348 out_parts.append(_DIR_MARK_OPT) 

349 

350 need_slash = True 

351 

352 return out_parts