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

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

156 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 Generator, Iterable, Iterator, Mapping, Sequence 

41from typing import IO 

42 

43from .file import open_nofollow_read 

44from .wildmatch import MalformedPattern 

45from .wildmatch import translate as translate_wildmatch 

46 

47logger = logging.getLogger(__name__) 

48 

49AttributeValue = bytes | bool | None 

50 

51 

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

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

54 

55 >>> _parse_attr(b'attr') 

56 (b'attr', True) 

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

58 (b'attr', False) 

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

60 (b'attr', None) 

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

62 (b'attr', b'text') 

63 """ 

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

65 return attr[1:], None 

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

67 return attr[1:], False 

68 if b"=" not in attr: 

69 return attr, True 

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

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

72 return name, value 

73 

74 

75def parse_git_attributes( 

76 f: IO[bytes], 

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

78 """Parse a Git attributes string. 

79 

80 Args: 

81 f: File-like object to read bytes from 

82 Returns: 

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

84 >>> from io import BytesIO 

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

86 ... 

87 ... # store signatures in Git 

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

89 ... 

90 ... # store .dsc verbatim 

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

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

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

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

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

96 """ 

97 for line in f: 

98 line = line.strip() 

99 

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

101 if not line: 

102 continue 

103 

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

105 # Comment 

106 continue 

107 

108 pattern, *attrs = line.split() 

109 

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

111 

112 

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

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

115 

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

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

118 

119 Raises: 

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

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

122 """ 

123 res = b"" 

124 

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

126 if b"/" not in pattern: 

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

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

129 # Leading / means root of repository 

130 pattern = pattern[1:] 

131 

132 return res + translate_wildmatch(pattern) 

133 

134 

135class Pattern: 

136 """A single gitattributes pattern.""" 

137 

138 def __init__(self, pattern: bytes): 

139 """Initialize GitAttributesPattern. 

140 

141 Args: 

142 pattern: Attribute pattern as bytes 

143 """ 

144 self.pattern = pattern 

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

146 self._compile() 

147 

148 def _compile(self) -> None: 

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

150 regex_pattern = _translate_pattern(self.pattern) 

151 # Add anchors 

152 regex_pattern = b"^" + regex_pattern + b"$" 

153 self._regex = re.compile(regex_pattern) 

154 

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

156 """Check if path matches this pattern. 

157 

158 Args: 

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

160 

161 Returns: 

162 True if path matches this pattern 

163 """ 

164 # Normalize path 

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

166 path = path[1:] 

167 

168 # Try to match 

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

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

171 

172 

173def match_path( 

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

175) -> dict[bytes, AttributeValue]: 

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

177 

178 Args: 

179 patterns: List of (Pattern, attributes) tuples 

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

181 

182 Returns: 

183 Dictionary of attributes that apply to this path 

184 """ 

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

186 

187 # Later patterns override earlier ones 

188 for pattern, attrs in patterns: 

189 if pattern.match(path): 

190 # Update attributes 

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

192 if value is None: 

193 # Unspecified - remove the attribute 

194 attributes.pop(name, None) 

195 else: 

196 attributes[name] = value 

197 

198 return attributes 

199 

200 

201def compile_gitattributes_patterns( 

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

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

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

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

206 

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

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

209 aborting the load. 

210 

211 Args: 

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

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

214 

215 Returns: 

216 List of (Pattern, attributes) tuples 

217 """ 

218 patterns = [] 

219 for pattern_bytes, attrs in entries: 

220 try: 

221 pattern = Pattern(pattern_bytes) 

222 except MalformedPattern: 

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

224 continue 

225 patterns.append((pattern, attrs)) 

226 return patterns 

227 

228 

229def parse_gitattributes_file( 

230 filename: str | bytes, 

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

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

233 

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

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

236 

237 Args: 

238 filename: Path to the .gitattributes file 

239 

240 Returns: 

241 List of (Pattern, attributes) tuples 

242 """ 

243 if isinstance(filename, str): 

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

245 

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

247 return compile_gitattributes_patterns(parse_git_attributes(f), filename) 

248 

249 

250def read_gitattributes( 

251 path: str | bytes, 

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

253 """Read .gitattributes from a directory. 

254 

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

256 than followed, as git does. 

257 

258 Args: 

259 path: Directory path to check for .gitattributes 

260 

261 Returns: 

262 List of (Pattern, attributes) tuples 

263 """ 

264 if isinstance(path, bytes): 

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

266 

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

268 try: 

269 f = open_nofollow_read(gitattributes_path) 

270 except FileNotFoundError: 

271 return [] 

272 except OSError as e: 

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

274 raise 

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

276 return [] 

277 with f: 

278 return compile_gitattributes_patterns( 

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

280 ) 

281 

282 

283class GitAttributes: 

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

285 

286 def __init__( 

287 self, 

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

289 ): 

290 """Initialize GitAttributes. 

291 

292 Args: 

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

294 """ 

295 self._patterns = patterns or [] 

296 

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

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

299 

300 Args: 

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

302 

303 Returns: 

304 Dictionary of attributes that apply to this path 

305 """ 

306 return match_path(self._patterns, path) 

307 

308 def add_patterns( 

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

310 ) -> None: 

311 """Add patterns to the collection. 

312 

313 Args: 

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

315 """ 

316 self._patterns.extend(patterns) 

317 

318 def __len__(self) -> int: 

319 """Return the number of patterns.""" 

320 return len(self._patterns) 

321 

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

323 """Iterate over patterns.""" 

324 return iter(self._patterns) 

325 

326 @classmethod 

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

328 """Create GitAttributes from a gitattributes file. 

329 

330 Args: 

331 filename: Path to the .gitattributes file 

332 

333 Returns: 

334 New GitAttributes instance 

335 """ 

336 patterns = parse_gitattributes_file(filename) 

337 return cls(patterns) 

338 

339 @classmethod 

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

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

342 

343 Args: 

344 path: Directory path to check for .gitattributes 

345 

346 Returns: 

347 New GitAttributes instance 

348 """ 

349 patterns = read_gitattributes(path) 

350 return cls(patterns) 

351 

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

353 """Set an attribute for a pattern. 

354 

355 Args: 

356 pattern: The file pattern 

357 name: Attribute name 

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

359 """ 

360 # Find existing pattern 

361 pattern_obj = None 

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

363 pattern_index = -1 

364 

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

366 if p.pattern == pattern: 

367 pattern_obj = p 

368 # Convert to mutable dict 

369 attrs_dict = dict(attrs) 

370 pattern_index = i 

371 break 

372 

373 if pattern_obj is None: 

374 # Create new pattern 

375 pattern_obj = Pattern(pattern) 

376 attrs_dict = {name: value} 

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

378 else: 

379 # Update the existing pattern in the list 

380 assert pattern_index >= 0 

381 assert attrs_dict is not None 

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

383 

384 # Update the attribute 

385 if attrs_dict is None: 

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

387 attrs_dict[name] = value 

388 

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

390 """Remove all attributes for a pattern. 

391 

392 Args: 

393 pattern: The file pattern to remove 

394 """ 

395 self._patterns = [ 

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

397 ] 

398 

399 def to_bytes(self) -> bytes: 

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

401 

402 Returns: 

403 Bytes representation of the gitattributes file 

404 """ 

405 lines = [] 

406 for pattern_obj, attrs in self._patterns: 

407 pattern = pattern_obj.pattern 

408 attr_strs = [] 

409 

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

411 if value is True: 

412 attr_strs.append(name) 

413 elif value is False: 

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

415 elif value is None: 

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

417 else: 

418 # value is bytes 

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

420 

421 if attr_strs: 

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

423 lines.append(line) 

424 

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

426 

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

428 """Write GitAttributes to a file. 

429 

430 Args: 

431 filename: Path to write the .gitattributes file 

432 """ 

433 if isinstance(filename, str): 

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

435 

436 content = self.to_bytes() 

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

438 f.write(content)