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

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

178 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 "compile_gitattributes_patterns", 

30 "match_path", 

31 "parse_git_attributes", 

32 "parse_gitattributes_file", 

33 "read_gitattributes", 

34] 

35 

36import errno 

37import logging 

38import os 

39import re 

40from collections.abc import Callable, Generator, Iterable, Iterator, Mapping, Sequence 

41from itertools import chain 

42from typing import IO 

43 

44from .file import open_nofollow_read 

45from .wildmatch import MalformedPattern 

46from .wildmatch import translate as translate_wildmatch 

47 

48logger = logging.getLogger(__name__) 

49 

50AttributeValue = bytes | bool | None 

51 

52 

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

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

55 

56 >>> _parse_attr(b'attr') 

57 (b'attr', True) 

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

59 (b'attr', False) 

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

61 (b'attr', None) 

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

63 (b'attr', b'text') 

64 """ 

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

66 return attr[1:], None 

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

68 return attr[1:], False 

69 if b"=" not in attr: 

70 return attr, True 

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

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

73 return name, value 

74 

75 

76def parse_git_attributes( 

77 f: IO[bytes], 

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

79 """Parse a Git attributes string. 

80 

81 Args: 

82 f: File-like object to read bytes from 

83 Returns: 

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

85 >>> from io import BytesIO 

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

87 ... 

88 ... # store signatures in Git 

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

90 ... 

91 ... # store .dsc verbatim 

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

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

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

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

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

97 """ 

98 for line in f: 

99 line = line.strip() 

100 

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

102 if not line: 

103 continue 

104 

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

106 # Comment 

107 continue 

108 

109 pattern, *attrs = line.split() 

110 

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

112 

113 

114def _translate_pattern(pattern: bytes, base: bytes = b"") -> bytes: 

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

116 

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

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

119 

120 Args: 

121 pattern: The pattern to translate 

122 base: Directory of the .gitattributes file the pattern comes from, 

123 relative to the repository root; empty for the root 

124 

125 Raises: 

126 MalformedPattern: if wildmatch() would refuse the pattern outright; 

127 see :func:`dulwich.wildmatch.translate`. 

128 """ 

129 res = re.escape(base + b"/") if base else b"" 

130 

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

132 if b"/" not in pattern: 

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

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

135 # Leading / means the directory of the .gitattributes file 

136 pattern = pattern[1:] 

137 

138 return res + translate_wildmatch(pattern) 

139 

140 

141class Pattern: 

142 """A single gitattributes pattern.""" 

143 

144 def __init__(self, pattern: bytes, base: bytes = b""): 

145 """Initialize GitAttributesPattern. 

146 

147 Args: 

148 pattern: Attribute pattern as bytes 

149 base: Directory of the .gitattributes file the pattern comes 

150 from, relative to the repository root; empty for the root 

151 """ 

152 self.pattern = pattern 

153 self.base = base 

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

155 self._compile() 

156 

157 def _compile(self) -> None: 

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

159 regex_pattern = _translate_pattern(self.pattern, self.base) 

160 # Add anchors 

161 regex_pattern = b"^" + regex_pattern + b"$" 

162 self._regex = re.compile(regex_pattern) 

163 

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

165 """Check if path matches this pattern. 

166 

167 Args: 

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

169 

170 Returns: 

171 True if path matches this pattern 

172 """ 

173 # Normalize path 

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

175 path = path[1:] 

176 

177 # Try to match 

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

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

180 

181 

182def match_path( 

183 patterns: Iterable[tuple[Pattern, Mapping[bytes, AttributeValue]]], path: bytes 

184) -> dict[bytes, AttributeValue]: 

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

186 

187 Args: 

188 patterns: List of (Pattern, attributes) tuples 

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

190 

191 Returns: 

192 Dictionary of attributes that apply to this path 

193 """ 

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

195 

196 # Later patterns override earlier ones 

197 for pattern, attrs in patterns: 

198 if pattern.match(path): 

199 # Update attributes 

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

201 if value is None: 

202 # Unspecified - remove the attribute 

203 attributes.pop(name, None) 

204 else: 

205 attributes[name] = value 

206 

207 return attributes 

208 

209 

210def compile_gitattributes_patterns( 

211 entries: Iterable[tuple[bytes, Mapping[bytes, AttributeValue]]], 

212 source: str | bytes = b"<attributes>", 

213 base: bytes = b"", 

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

215 """Compile parsed gitattributes entries, skipping malformed patterns. 

216 

217 Git's wildmatch() treats a malformed pattern as matching nothing rather 

218 than as a broken file, so one bad line is logged and dropped instead of 

219 aborting the load. 

220 

221 Args: 

222 entries: (pattern, attributes) pairs, as from parse_git_attributes 

223 source: Where the entries came from, used in the warning 

224 base: Directory the patterns are relative to, relative to the 

225 repository root; empty for the root 

226 

227 Returns: 

228 List of (Pattern, attributes) tuples 

229 """ 

230 patterns = [] 

231 for pattern_bytes, attrs in entries: 

232 try: 

233 pattern = Pattern(pattern_bytes, base) 

234 except MalformedPattern: 

235 logger.warning("Ignoring malformed pattern %r in %r", pattern_bytes, source) 

236 continue 

237 patterns.append((pattern, attrs)) 

238 return patterns 

239 

240 

241def parse_gitattributes_file( 

242 filename: str | bytes, 

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

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

245 

246 A malformed pattern is logged and skipped rather than raised, so one bad 

247 line doesn't stop the rest of the file from loading. 

248 

249 Args: 

250 filename: Path to the .gitattributes file 

251 

252 Returns: 

253 List of (Pattern, attributes) tuples 

254 """ 

255 if isinstance(filename, str): 

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

257 

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

259 return compile_gitattributes_patterns(parse_git_attributes(f), filename) 

260 

261 

262def read_gitattributes( 

263 path: str | bytes, 

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

265 """Read .gitattributes from a directory. 

266 

267 A ``.gitattributes`` that is a symlink is skipped with a warning rather 

268 than followed, as git does. 

269 

270 Args: 

271 path: Directory path to check for .gitattributes 

272 

273 Returns: 

274 List of (Pattern, attributes) tuples 

275 """ 

276 if isinstance(path, bytes): 

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

278 

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

280 try: 

281 f = open_nofollow_read(gitattributes_path) 

282 except FileNotFoundError: 

283 return [] 

284 except OSError as e: 

285 if e.errno not in (errno.ELOOP, errno.EMLINK): 

286 raise 

287 logger.warning("Ignoring %s: it is a symbolic link", gitattributes_path) 

288 return [] 

289 with f: 

290 return compile_gitattributes_patterns( 

291 parse_git_attributes(f), gitattributes_path.encode("utf-8") 

292 ) 

293 

294 

295class GitAttributes: 

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

297 

298 Patterns are applied in the order git uses: ``patterns`` first, then 

299 those of each directory from the top down to the path's own directory 

300 (as returned by ``directory_loader``), then ``info_patterns``. 

301 """ 

302 

303 def __init__( 

304 self, 

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

306 *, 

307 directory_loader: Callable[ 

308 [bytes], list[tuple[Pattern, Mapping[bytes, AttributeValue]]] 

309 ] 

310 | None = None, 

311 info_patterns: list[tuple[Pattern, Mapping[bytes, AttributeValue]]] 

312 | None = None, 

313 ): 

314 """Initialize GitAttributes. 

315 

316 Args: 

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

318 directory_loader: Optional callable that returns the patterns 

319 of the .gitattributes file in a subdirectory, given its path 

320 relative to the repository root. Called at most once per 

321 directory, when a path below it is first matched. 

322 info_patterns: Optional list of (Pattern, attributes) tuples 

323 that take precedence over all others, as from 

324 ``$GIT_DIR/info/attributes`` 

325 """ 

326 self._patterns = patterns or [] 

327 self._directory_loader = directory_loader 

328 self._directory_patterns: dict[ 

329 bytes, list[tuple[Pattern, Mapping[bytes, AttributeValue]]] 

330 ] = {} 

331 self._info_patterns = info_patterns or [] 

332 

333 def _get_directory_patterns( 

334 self, dirpath: bytes 

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

336 try: 

337 return self._directory_patterns[dirpath] 

338 except KeyError: 

339 pass 

340 assert self._directory_loader is not None 

341 patterns = self._directory_loader(dirpath) 

342 self._directory_patterns[dirpath] = patterns 

343 return patterns 

344 

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

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

347 

348 Args: 

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

350 

351 Returns: 

352 Dictionary of attributes that apply to this path 

353 """ 

354 directory_patterns: list[ 

355 list[tuple[Pattern, Mapping[bytes, AttributeValue]]] 

356 ] = [] 

357 if self._directory_loader is not None: 

358 parts = path.lstrip(b"/").split(b"/")[:-1] 

359 for i in range(1, len(parts) + 1): 

360 directory_patterns.append( 

361 self._get_directory_patterns(b"/".join(parts[:i])) 

362 ) 

363 return match_path( 

364 chain(self._patterns, *directory_patterns, self._info_patterns), path 

365 ) 

366 

367 def add_patterns( 

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

369 ) -> None: 

370 """Add patterns to the collection. 

371 

372 Args: 

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

374 """ 

375 self._patterns.extend(patterns) 

376 

377 def __len__(self) -> int: 

378 """Return the number of patterns. 

379 

380 Patterns from subdirectories that have not been loaded yet are 

381 not counted. 

382 """ 

383 return len(self._patterns) + len(self._info_patterns) 

384 

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

386 """Iterate over the top-level patterns and ``info_patterns``. 

387 

388 Patterns from subdirectories are not included. 

389 """ 

390 yield from self._patterns 

391 yield from self._info_patterns 

392 

393 @classmethod 

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

395 """Create GitAttributes from a gitattributes file. 

396 

397 Args: 

398 filename: Path to the .gitattributes file 

399 

400 Returns: 

401 New GitAttributes instance 

402 """ 

403 patterns = parse_gitattributes_file(filename) 

404 return cls(patterns) 

405 

406 @classmethod 

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

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

409 

410 Args: 

411 path: Directory path to check for .gitattributes 

412 

413 Returns: 

414 New GitAttributes instance 

415 """ 

416 patterns = read_gitattributes(path) 

417 return cls(patterns) 

418 

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

420 """Set an attribute for a pattern. 

421 

422 Args: 

423 pattern: The file pattern 

424 name: Attribute name 

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

426 """ 

427 # Find existing pattern 

428 pattern_obj = None 

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

430 pattern_index = -1 

431 

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

433 if p.pattern == pattern: 

434 pattern_obj = p 

435 # Convert to mutable dict 

436 attrs_dict = dict(attrs) 

437 pattern_index = i 

438 break 

439 

440 if pattern_obj is None: 

441 # Create new pattern 

442 pattern_obj = Pattern(pattern) 

443 attrs_dict = {name: value} 

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

445 else: 

446 # Update the existing pattern in the list 

447 assert pattern_index >= 0 

448 assert attrs_dict is not None 

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

450 

451 # Update the attribute 

452 if attrs_dict is None: 

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

454 attrs_dict[name] = value 

455 

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

457 """Remove all attributes for a pattern. 

458 

459 Args: 

460 pattern: The file pattern to remove 

461 """ 

462 self._patterns = [ 

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

464 ] 

465 

466 def to_bytes(self) -> bytes: 

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

468 

469 Returns: 

470 Bytes representation of the gitattributes file 

471 """ 

472 lines = [] 

473 for pattern_obj, attrs in self._patterns: 

474 pattern = pattern_obj.pattern 

475 attr_strs = [] 

476 

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

478 match value: 

479 case True: 

480 attr_strs.append(name) 

481 case False: 

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

483 case None: 

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

485 case _: 

486 # value is bytes 

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

488 

489 if attr_strs: 

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

491 lines.append(line) 

492 

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

494 

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

496 """Write GitAttributes to a file. 

497 

498 Args: 

499 filename: Path to write the .gitattributes file 

500 """ 

501 if isinstance(filename, str): 

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

503 

504 content = self.to_bytes() 

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

506 f.write(content)