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

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

171 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# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU 

6# General Public License as public by the Free Software Foundation; version 2.0 

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

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

9# 

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

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

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

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

14# limitations under the License. 

15# 

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

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

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

19# License, Version 2.0. 

20# 

21 

22"""Parse .gitattributes file.""" 

23 

24import os 

25import re 

26from collections.abc import Generator, Mapping 

27from typing import ( 

28 IO, 

29 Optional, 

30 Union, 

31) 

32 

33AttributeValue = Union[bytes, bool, None] 

34 

35 

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

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

38 

39 >>> _parse_attr(b'attr') 

40 (b'attr', True) 

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

42 (b'attr', False) 

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

44 (b'attr', None) 

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

46 (b'attr', b'text') 

47 """ 

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

49 return attr[1:], None 

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

51 return attr[1:], False 

52 if b"=" not in attr: 

53 return attr, True 

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

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

56 return name, value 

57 

58 

59def parse_git_attributes( 

60 f: IO[bytes], 

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

62 """Parse a Git attributes string. 

63 

64 Args: 

65 f: File-like object to read bytes from 

66 Returns: 

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

68 >>> from io import BytesIO 

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

70 ... 

71 ... # store signatures in Git 

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

73 ... 

74 ... # store .dsc verbatim 

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

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

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

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

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

80 """ 

81 for line in f: 

82 line = line.strip() 

83 

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

85 if not line: 

86 continue 

87 

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

89 # Comment 

90 continue 

91 

92 pattern, *attrs = line.split() 

93 

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

95 

96 

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

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

99 

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

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

102 """ 

103 res = b"" 

104 i = 0 

105 n = len(pattern) 

106 

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

108 if b"/" not in pattern: 

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

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

111 # Leading / means root of repository 

112 pattern = pattern[1:] 

113 n = len(pattern) 

114 

115 while i < n: 

116 c = pattern[i : i + 1] 

117 i += 1 

118 

119 if c == b"*": 

120 if i < n and pattern[i : i + 1] == b"*": 

121 # Double asterisk 

122 i += 1 

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

124 # **/ - match zero or more directories 

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

126 i += 1 

127 elif i == n: 

128 # ** at end - match everything 

129 res += b".*" 

130 else: 

131 # ** in middle 

132 res += b".*" 

133 else: 

134 # Single * - match any character except / 

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

136 elif c == b"?": 

137 res += b"[^/]" 

138 elif c == b"[": 

139 # Character class 

140 j = i 

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

142 j += 1 

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

144 j += 1 

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

146 j += 1 

147 if j >= n: 

148 res += b"\\[" 

149 else: 

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

151 i = j + 1 

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

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

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

155 stuff = b"\\" + stuff 

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

157 else: 

158 res += re.escape(c) 

159 

160 return res 

161 

162 

163class Pattern: 

164 """A single gitattributes pattern.""" 

165 

166 def __init__(self, pattern: bytes): 

167 self.pattern = pattern 

168 self._regex: Optional[re.Pattern[bytes]] = None 

169 self._compile() 

170 

171 def _compile(self): 

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

173 regex_pattern = _translate_pattern(self.pattern) 

174 # Add anchors 

175 regex_pattern = b"^" + regex_pattern + b"$" 

176 self._regex = re.compile(regex_pattern) 

177 

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

179 """Check if path matches this pattern. 

180 

181 Args: 

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

183 

184 Returns: 

185 True if path matches this pattern 

186 """ 

187 # Normalize path 

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

189 path = path[1:] 

190 

191 # Try to match 

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

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

194 

195 

196def match_path( 

197 patterns: list[tuple[Pattern, Mapping[bytes, AttributeValue]]], path: bytes 

198) -> dict[bytes, AttributeValue]: 

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

200 

201 Args: 

202 patterns: List of (Pattern, attributes) tuples 

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

204 

205 Returns: 

206 Dictionary of attributes that apply to this path 

207 """ 

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

209 

210 # Later patterns override earlier ones 

211 for pattern, attrs in patterns: 

212 if pattern.match(path): 

213 # Update attributes 

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

215 if value is None: 

216 # Unspecified - remove the attribute 

217 attributes.pop(name, None) 

218 else: 

219 attributes[name] = value 

220 

221 return attributes 

222 

223 

224def parse_gitattributes_file( 

225 filename: Union[str, bytes], 

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

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

228 

229 Args: 

230 filename: Path to the .gitattributes file 

231 

232 Returns: 

233 List of (Pattern, attributes) tuples 

234 """ 

235 patterns = [] 

236 

237 if isinstance(filename, str): 

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

239 

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

241 for pattern_bytes, attrs in parse_git_attributes(f): 

242 pattern = Pattern(pattern_bytes) 

243 patterns.append((pattern, attrs)) 

244 

245 return patterns 

246 

247 

248def read_gitattributes( 

249 path: Union[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: Optional[list[tuple[Pattern, Mapping[bytes, AttributeValue]]]] = 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: list[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): 

309 """Iterate over patterns.""" 

310 return iter(self._patterns) 

311 

312 @classmethod 

313 def from_file(cls, filename: Union[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: Union[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: Optional[dict[bytes, AttributeValue]] = 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: Union[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)