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

146 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 logging 

37import os 

38import re 

39from collections.abc import Generator, Iterable, Iterator, Mapping, Sequence 

40from typing import IO 

41 

42from .wildmatch import MalformedPattern 

43from .wildmatch import translate as translate_wildmatch 

44 

45logger = logging.getLogger(__name__) 

46 

47AttributeValue = bytes | bool | None 

48 

49 

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

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

52 

53 >>> _parse_attr(b'attr') 

54 (b'attr', True) 

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

56 (b'attr', False) 

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

58 (b'attr', None) 

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

60 (b'attr', b'text') 

61 """ 

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

63 return attr[1:], None 

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

65 return attr[1:], False 

66 if b"=" not in attr: 

67 return attr, True 

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

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

70 return name, value 

71 

72 

73def parse_git_attributes( 

74 f: IO[bytes], 

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

76 """Parse a Git attributes string. 

77 

78 Args: 

79 f: File-like object to read bytes from 

80 Returns: 

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

82 >>> from io import BytesIO 

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

84 ... 

85 ... # store signatures in Git 

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

87 ... 

88 ... # store .dsc verbatim 

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

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

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

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

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

94 """ 

95 for line in f: 

96 line = line.strip() 

97 

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

99 if not line: 

100 continue 

101 

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

103 # Comment 

104 continue 

105 

106 pattern, *attrs = line.split() 

107 

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

109 

110 

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

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

113 

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

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

116 

117 Raises: 

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

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

120 """ 

121 res = b"" 

122 

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

124 if b"/" not in pattern: 

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

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

127 # Leading / means root of repository 

128 pattern = pattern[1:] 

129 

130 return res + translate_wildmatch(pattern) 

131 

132 

133class Pattern: 

134 """A single gitattributes pattern.""" 

135 

136 def __init__(self, pattern: bytes): 

137 """Initialize GitAttributesPattern. 

138 

139 Args: 

140 pattern: Attribute pattern as bytes 

141 """ 

142 self.pattern = pattern 

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

144 self._compile() 

145 

146 def _compile(self) -> None: 

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

148 regex_pattern = _translate_pattern(self.pattern) 

149 # Add anchors 

150 regex_pattern = b"^" + regex_pattern + b"$" 

151 self._regex = re.compile(regex_pattern) 

152 

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

154 """Check if path matches this pattern. 

155 

156 Args: 

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

158 

159 Returns: 

160 True if path matches this pattern 

161 """ 

162 # Normalize path 

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

164 path = path[1:] 

165 

166 # Try to match 

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

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

169 

170 

171def match_path( 

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

173) -> dict[bytes, AttributeValue]: 

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

175 

176 Args: 

177 patterns: List of (Pattern, attributes) tuples 

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

179 

180 Returns: 

181 Dictionary of attributes that apply to this path 

182 """ 

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

184 

185 # Later patterns override earlier ones 

186 for pattern, attrs in patterns: 

187 if pattern.match(path): 

188 # Update attributes 

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

190 if value is None: 

191 # Unspecified - remove the attribute 

192 attributes.pop(name, None) 

193 else: 

194 attributes[name] = value 

195 

196 return attributes 

197 

198 

199def compile_gitattributes_patterns( 

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

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

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

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

204 

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

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

207 aborting the load. 

208 

209 Args: 

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

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

212 

213 Returns: 

214 List of (Pattern, attributes) tuples 

215 """ 

216 patterns = [] 

217 for pattern_bytes, attrs in entries: 

218 try: 

219 pattern = Pattern(pattern_bytes) 

220 except MalformedPattern: 

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

222 continue 

223 patterns.append((pattern, attrs)) 

224 return patterns 

225 

226 

227def parse_gitattributes_file( 

228 filename: str | bytes, 

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

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

231 

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

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

234 

235 Args: 

236 filename: Path to the .gitattributes file 

237 

238 Returns: 

239 List of (Pattern, attributes) tuples 

240 """ 

241 if isinstance(filename, str): 

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

243 

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

245 return compile_gitattributes_patterns(parse_git_attributes(f), filename) 

246 

247 

248def read_gitattributes( 

249 path: str | bytes, 

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

251 """Read .gitattributes from a directory. 

252 

253 Args: 

254 path: Directory path to check for .gitattributes 

255 

256 Returns: 

257 List of (Pattern, attributes) tuples 

258 """ 

259 if isinstance(path, bytes): 

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

261 

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

263 if os.path.exists(gitattributes_path): 

264 return parse_gitattributes_file(gitattributes_path) 

265 

266 return [] 

267 

268 

269class GitAttributes: 

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

271 

272 def __init__( 

273 self, 

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

275 ): 

276 """Initialize GitAttributes. 

277 

278 Args: 

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

280 """ 

281 self._patterns = patterns or [] 

282 

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

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

285 

286 Args: 

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

288 

289 Returns: 

290 Dictionary of attributes that apply to this path 

291 """ 

292 return match_path(self._patterns, path) 

293 

294 def add_patterns( 

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

296 ) -> None: 

297 """Add patterns to the collection. 

298 

299 Args: 

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

301 """ 

302 self._patterns.extend(patterns) 

303 

304 def __len__(self) -> int: 

305 """Return the number of patterns.""" 

306 return len(self._patterns) 

307 

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

309 """Iterate over patterns.""" 

310 return iter(self._patterns) 

311 

312 @classmethod 

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

314 """Create GitAttributes from a gitattributes file. 

315 

316 Args: 

317 filename: Path to the .gitattributes file 

318 

319 Returns: 

320 New GitAttributes instance 

321 """ 

322 patterns = parse_gitattributes_file(filename) 

323 return cls(patterns) 

324 

325 @classmethod 

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

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

328 

329 Args: 

330 path: Directory path to check for .gitattributes 

331 

332 Returns: 

333 New GitAttributes instance 

334 """ 

335 patterns = read_gitattributes(path) 

336 return cls(patterns) 

337 

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

339 """Set an attribute for a pattern. 

340 

341 Args: 

342 pattern: The file pattern 

343 name: Attribute name 

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

345 """ 

346 # Find existing pattern 

347 pattern_obj = None 

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

349 pattern_index = -1 

350 

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

352 if p.pattern == pattern: 

353 pattern_obj = p 

354 # Convert to mutable dict 

355 attrs_dict = dict(attrs) 

356 pattern_index = i 

357 break 

358 

359 if pattern_obj is None: 

360 # Create new pattern 

361 pattern_obj = Pattern(pattern) 

362 attrs_dict = {name: value} 

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

364 else: 

365 # Update the existing pattern in the list 

366 assert pattern_index >= 0 

367 assert attrs_dict is not None 

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

369 

370 # Update the attribute 

371 if attrs_dict is None: 

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

373 attrs_dict[name] = value 

374 

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

376 """Remove all attributes for a pattern. 

377 

378 Args: 

379 pattern: The file pattern to remove 

380 """ 

381 self._patterns = [ 

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

383 ] 

384 

385 def to_bytes(self) -> bytes: 

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

387 

388 Returns: 

389 Bytes representation of the gitattributes file 

390 """ 

391 lines = [] 

392 for pattern_obj, attrs in self._patterns: 

393 pattern = pattern_obj.pattern 

394 attr_strs = [] 

395 

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

397 if value is True: 

398 attr_strs.append(name) 

399 elif value is False: 

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

401 elif value is None: 

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

403 else: 

404 # value is bytes 

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

406 

407 if attr_strs: 

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

409 lines.append(line) 

410 

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

412 

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

414 """Write GitAttributes to a file. 

415 

416 Args: 

417 filename: Path to write the .gitattributes file 

418 """ 

419 if isinstance(filename, str): 

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

421 

422 content = self.to_bytes() 

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

424 f.write(content)