Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/dulwich/attrs.py: 21%

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

172 statements  

1# attrs.py -- Git attributes for dulwich 

2# Copyright (C) 2019-2020 Collabora Ltd 

3# Copyright (C) 2019-2020 Andrej Shadura <andrew.shadura@collabora.co.uk> 

4# 

5# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later 

6# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU 

7# General Public License as published by the Free Software Foundation; version 2.0 

8# or (at your option) any later version. You can redistribute it and/or 

9# modify it under the terms of either of these two licenses. 

10# 

11# Unless required by applicable law or agreed to in writing, software 

12# distributed under the License is distributed on an "AS IS" BASIS, 

13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 

14# See the License for the specific language governing permissions and 

15# limitations under the License. 

16# 

17# You should have received a copy of the licenses; if not, see 

18# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License 

19# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache 

20# License, Version 2.0. 

21# 

22 

23"""Parse .gitattributes file.""" 

24 

25__all__ = [ 

26 "AttributeValue", 

27 "GitAttributes", 

28 "Pattern", 

29 "match_path", 

30 "parse_git_attributes", 

31 "parse_gitattributes_file", 

32 "read_gitattributes", 

33] 

34 

35import os 

36import re 

37from collections.abc import Generator, Iterator, Mapping, Sequence 

38from typing import IO 

39 

40AttributeValue = bytes | bool | None 

41 

42 

43def _parse_attr(attr: bytes) -> tuple[bytes, AttributeValue]: 

44 """Parse a git attribute into its value. 

45 

46 >>> _parse_attr(b'attr') 

47 (b'attr', True) 

48 >>> _parse_attr(b'-attr') 

49 (b'attr', False) 

50 >>> _parse_attr(b'!attr') 

51 (b'attr', None) 

52 >>> _parse_attr(b'attr=text') 

53 (b'attr', b'text') 

54 """ 

55 if attr.startswith(b"!"): 

56 return attr[1:], None 

57 if attr.startswith(b"-"): 

58 return attr[1:], False 

59 if b"=" not in attr: 

60 return attr, True 

61 # Split only on first = to handle values with = in them 

62 name, _, value = attr.partition(b"=") 

63 return name, value 

64 

65 

66def parse_git_attributes( 

67 f: IO[bytes], 

68) -> Generator[tuple[bytes, Mapping[bytes, AttributeValue]], None, None]: 

69 """Parse a Git attributes string. 

70 

71 Args: 

72 f: File-like object to read bytes from 

73 Returns: 

74 List of patterns and corresponding patterns in the order or them being encountered 

75 >>> from io import BytesIO 

76 >>> list(parse_git_attributes(BytesIO(b'''*.tar.* filter=lfs diff=lfs merge=lfs -text 

77 ... 

78 ... # store signatures in Git 

79 ... *.tar.*.asc -filter -diff merge=binary -text 

80 ... 

81 ... # store .dsc verbatim 

82 ... *.dsc -filter !diff merge=binary !text 

83 ... '''))) #doctest: +NORMALIZE_WHITESPACE 

84 [(b'*.tar.*', {'filter': 'lfs', 'diff': 'lfs', 'merge': 'lfs', 'text': False}), 

85 (b'*.tar.*.asc', {'filter': False, 'diff': False, 'merge': 'binary', 'text': False}), 

86 (b'*.dsc', {'filter': False, 'diff': None, 'merge': 'binary', 'text': None})] 

87 """ 

88 for line in f: 

89 line = line.strip() 

90 

91 # Ignore blank lines, they're used for readability. 

92 if not line: 

93 continue 

94 

95 if line.startswith(b"#"): 

96 # Comment 

97 continue 

98 

99 pattern, *attrs = line.split() 

100 

101 yield (pattern, {k: v for k, v in (_parse_attr(a) for a in attrs)}) 

102 

103 

104def _translate_pattern(pattern: bytes) -> bytes: 

105 """Translate a gitattributes pattern to a regular expression. 

106 

107 Similar to gitignore patterns, but simpler as gitattributes doesn't support 

108 all the same features (e.g., no directory-only patterns with trailing /). 

109 """ 

110 res = b"" 

111 i = 0 

112 n = len(pattern) 

113 

114 # If pattern doesn't contain /, it can match at any level 

115 if b"/" not in pattern: 

116 res = b"(?:.*/)??" 

117 elif pattern.startswith(b"/"): 

118 # Leading / means root of repository 

119 pattern = pattern[1:] 

120 n = len(pattern) 

121 

122 while i < n: 

123 c = pattern[i : i + 1] 

124 i += 1 

125 

126 if c == b"*": 

127 # Consume the whole run of '*' so consecutive stars collapse to 

128 # a single wildcard. Emitting one quantifier per star produces 

129 # adjacent unbounded quantifiers (e.g. '.*.*.*') that backtrack 

130 # catastrophically on non-matching input (ReDoS); collapsing is 

131 # also semantically correct since repeated '*' are redundant. 

132 double = i < n and pattern[i : i + 1] == b"*" 

133 while i < n and pattern[i : i + 1] == b"*": 

134 i += 1 

135 if double: 

136 # Double asterisk - matches across directory separators 

137 if i < n and pattern[i : i + 1] == b"/": 

138 # **/ - match zero or more directories 

139 res += b"(?:.*/)??" 

140 i += 1 

141 else: 

142 # ** at end or in the middle 

143 res += b".*" 

144 else: 

145 # Single * - match any character except / 

146 res += b"[^/]*" 

147 elif c == b"?": 

148 res += b"[^/]" 

149 elif c == b"[": 

150 # Character class 

151 j = i 

152 if j < n and pattern[j : j + 1] == b"!": 

153 j += 1 

154 if j < n and pattern[j : j + 1] == b"]": 

155 j += 1 

156 while j < n and pattern[j : j + 1] != b"]": 

157 j += 1 

158 if j >= n: 

159 res += b"\\[" 

160 else: 

161 stuff = pattern[i:j].replace(b"\\", b"\\\\") 

162 i = j + 1 

163 if stuff.startswith(b"!"): 

164 stuff = b"^" + stuff[1:] 

165 elif stuff.startswith(b"^"): 

166 stuff = b"\\" + stuff 

167 res += b"[" + stuff + b"]" 

168 else: 

169 res += re.escape(c) 

170 

171 return res 

172 

173 

174class Pattern: 

175 """A single gitattributes pattern.""" 

176 

177 def __init__(self, pattern: bytes): 

178 """Initialize GitAttributesPattern. 

179 

180 Args: 

181 pattern: Attribute pattern as bytes 

182 """ 

183 self.pattern = pattern 

184 self._regex: re.Pattern[bytes] | None = None 

185 self._compile() 

186 

187 def _compile(self) -> None: 

188 """Compile the pattern to a regular expression.""" 

189 regex_pattern = _translate_pattern(self.pattern) 

190 # Add anchors 

191 regex_pattern = b"^" + regex_pattern + b"$" 

192 self._regex = re.compile(regex_pattern) 

193 

194 def match(self, path: bytes) -> bool: 

195 """Check if path matches this pattern. 

196 

197 Args: 

198 path: Path to check (relative to repository root, using / separators) 

199 

200 Returns: 

201 True if path matches this pattern 

202 """ 

203 # Normalize path 

204 if path.startswith(b"/"): 

205 path = path[1:] 

206 

207 # Try to match 

208 assert self._regex is not None # Always set by _compile() 

209 return bool(self._regex.match(path)) 

210 

211 

212def match_path( 

213 patterns: Sequence[tuple[Pattern, Mapping[bytes, AttributeValue]]], path: bytes 

214) -> dict[bytes, AttributeValue]: 

215 """Get attributes for a path by matching against patterns. 

216 

217 Args: 

218 patterns: List of (Pattern, attributes) tuples 

219 path: Path to match (relative to repository root) 

220 

221 Returns: 

222 Dictionary of attributes that apply to this path 

223 """ 

224 attributes: dict[bytes, AttributeValue] = {} 

225 

226 # Later patterns override earlier ones 

227 for pattern, attrs in patterns: 

228 if pattern.match(path): 

229 # Update attributes 

230 for name, value in attrs.items(): 

231 if value is None: 

232 # Unspecified - remove the attribute 

233 attributes.pop(name, None) 

234 else: 

235 attributes[name] = value 

236 

237 return attributes 

238 

239 

240def parse_gitattributes_file( 

241 filename: str | bytes, 

242) -> list[tuple[Pattern, Mapping[bytes, AttributeValue]]]: 

243 """Parse a gitattributes file and return compiled patterns. 

244 

245 Args: 

246 filename: Path to the .gitattributes file 

247 

248 Returns: 

249 List of (Pattern, attributes) tuples 

250 """ 

251 patterns = [] 

252 

253 if isinstance(filename, str): 

254 filename = filename.encode("utf-8") 

255 

256 with open(filename, "rb") as f: 

257 for pattern_bytes, attrs in parse_git_attributes(f): 

258 pattern = Pattern(pattern_bytes) 

259 patterns.append((pattern, attrs)) 

260 

261 return patterns 

262 

263 

264def read_gitattributes( 

265 path: str | bytes, 

266) -> list[tuple[Pattern, Mapping[bytes, AttributeValue]]]: 

267 """Read .gitattributes from a directory. 

268 

269 Args: 

270 path: Directory path to check for .gitattributes 

271 

272 Returns: 

273 List of (Pattern, attributes) tuples 

274 """ 

275 if isinstance(path, bytes): 

276 path = path.decode("utf-8") 

277 

278 gitattributes_path = os.path.join(path, ".gitattributes") 

279 if os.path.exists(gitattributes_path): 

280 return parse_gitattributes_file(gitattributes_path) 

281 

282 return [] 

283 

284 

285class GitAttributes: 

286 """A collection of gitattributes patterns that can match paths.""" 

287 

288 def __init__( 

289 self, 

290 patterns: list[tuple[Pattern, Mapping[bytes, AttributeValue]]] | None = None, 

291 ): 

292 """Initialize GitAttributes. 

293 

294 Args: 

295 patterns: Optional list of (Pattern, attributes) tuples 

296 """ 

297 self._patterns = patterns or [] 

298 

299 def match_path(self, path: bytes) -> dict[bytes, AttributeValue]: 

300 """Get attributes for a path by matching against patterns. 

301 

302 Args: 

303 path: Path to match (relative to repository root) 

304 

305 Returns: 

306 Dictionary of attributes that apply to this path 

307 """ 

308 return match_path(self._patterns, path) 

309 

310 def add_patterns( 

311 self, patterns: Sequence[tuple[Pattern, Mapping[bytes, AttributeValue]]] 

312 ) -> None: 

313 """Add patterns to the collection. 

314 

315 Args: 

316 patterns: List of (Pattern, attributes) tuples to add 

317 """ 

318 self._patterns.extend(patterns) 

319 

320 def __len__(self) -> int: 

321 """Return the number of patterns.""" 

322 return len(self._patterns) 

323 

324 def __iter__(self) -> Iterator[tuple["Pattern", Mapping[bytes, AttributeValue]]]: 

325 """Iterate over patterns.""" 

326 return iter(self._patterns) 

327 

328 @classmethod 

329 def from_file(cls, filename: str | bytes) -> "GitAttributes": 

330 """Create GitAttributes from a gitattributes file. 

331 

332 Args: 

333 filename: Path to the .gitattributes file 

334 

335 Returns: 

336 New GitAttributes instance 

337 """ 

338 patterns = parse_gitattributes_file(filename) 

339 return cls(patterns) 

340 

341 @classmethod 

342 def from_path(cls, path: str | bytes) -> "GitAttributes": 

343 """Create GitAttributes from .gitattributes in a directory. 

344 

345 Args: 

346 path: Directory path to check for .gitattributes 

347 

348 Returns: 

349 New GitAttributes instance 

350 """ 

351 patterns = read_gitattributes(path) 

352 return cls(patterns) 

353 

354 def set_attribute(self, pattern: bytes, name: bytes, value: AttributeValue) -> None: 

355 """Set an attribute for a pattern. 

356 

357 Args: 

358 pattern: The file pattern 

359 name: Attribute name 

360 value: Attribute value (bytes, True, False, or None) 

361 """ 

362 # Find existing pattern 

363 pattern_obj = None 

364 attrs_dict: dict[bytes, AttributeValue] | None = None 

365 pattern_index = -1 

366 

367 for i, (p, attrs) in enumerate(self._patterns): 

368 if p.pattern == pattern: 

369 pattern_obj = p 

370 # Convert to mutable dict 

371 attrs_dict = dict(attrs) 

372 pattern_index = i 

373 break 

374 

375 if pattern_obj is None: 

376 # Create new pattern 

377 pattern_obj = Pattern(pattern) 

378 attrs_dict = {name: value} 

379 self._patterns.append((pattern_obj, attrs_dict)) 

380 else: 

381 # Update the existing pattern in the list 

382 assert pattern_index >= 0 

383 assert attrs_dict is not None 

384 self._patterns[pattern_index] = (pattern_obj, attrs_dict) 

385 

386 # Update the attribute 

387 if attrs_dict is None: 

388 raise AssertionError("attrs_dict should not be None at this point") 

389 attrs_dict[name] = value 

390 

391 def remove_pattern(self, pattern: bytes) -> None: 

392 """Remove all attributes for a pattern. 

393 

394 Args: 

395 pattern: The file pattern to remove 

396 """ 

397 self._patterns = [ 

398 (p, attrs) for p, attrs in self._patterns if p.pattern != pattern 

399 ] 

400 

401 def to_bytes(self) -> bytes: 

402 """Convert GitAttributes to bytes format suitable for writing to file. 

403 

404 Returns: 

405 Bytes representation of the gitattributes file 

406 """ 

407 lines = [] 

408 for pattern_obj, attrs in self._patterns: 

409 pattern = pattern_obj.pattern 

410 attr_strs = [] 

411 

412 for name, value in sorted(attrs.items()): 

413 if value is True: 

414 attr_strs.append(name) 

415 elif value is False: 

416 attr_strs.append(b"-" + name) 

417 elif value is None: 

418 attr_strs.append(b"!" + name) 

419 else: 

420 # value is bytes 

421 attr_strs.append(name + b"=" + value) 

422 

423 if attr_strs: 

424 line = pattern + b" " + b" ".join(attr_strs) 

425 lines.append(line) 

426 

427 return b"\n".join(lines) + b"\n" if lines else b"" 

428 

429 def write_to_file(self, filename: str | bytes) -> None: 

430 """Write GitAttributes to a file. 

431 

432 Args: 

433 filename: Path to write the .gitattributes file 

434 """ 

435 if isinstance(filename, str): 

436 filename = filename.encode("utf-8") 

437 

438 content = self.to_bytes() 

439 with open(filename, "wb") as f: 

440 f.write(content)