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
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
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#
23"""Parse .gitattributes file."""
25__all__ = [
26 "AttributeValue",
27 "GitAttributes",
28 "Pattern",
29 "match_path",
30 "parse_git_attributes",
31 "parse_gitattributes_file",
32 "read_gitattributes",
33]
35import os
36import re
37from collections.abc import Generator, Iterator, Mapping, Sequence
38from typing import IO
40AttributeValue = bytes | bool | None
43def _parse_attr(attr: bytes) -> tuple[bytes, AttributeValue]:
44 """Parse a git attribute into its value.
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
66def parse_git_attributes(
67 f: IO[bytes],
68) -> Generator[tuple[bytes, Mapping[bytes, AttributeValue]], None, None]:
69 """Parse a Git attributes string.
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()
91 # Ignore blank lines, they're used for readability.
92 if not line:
93 continue
95 if line.startswith(b"#"):
96 # Comment
97 continue
99 pattern, *attrs = line.split()
101 yield (pattern, {k: v for k, v in (_parse_attr(a) for a in attrs)})
104def _translate_pattern(pattern: bytes) -> bytes:
105 """Translate a gitattributes pattern to a regular expression.
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)
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)
122 while i < n:
123 c = pattern[i : i + 1]
124 i += 1
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)
171 return res
174class Pattern:
175 """A single gitattributes pattern."""
177 def __init__(self, pattern: bytes):
178 """Initialize GitAttributesPattern.
180 Args:
181 pattern: Attribute pattern as bytes
182 """
183 self.pattern = pattern
184 self._regex: re.Pattern[bytes] | None = None
185 self._compile()
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)
194 def match(self, path: bytes) -> bool:
195 """Check if path matches this pattern.
197 Args:
198 path: Path to check (relative to repository root, using / separators)
200 Returns:
201 True if path matches this pattern
202 """
203 # Normalize path
204 if path.startswith(b"/"):
205 path = path[1:]
207 # Try to match
208 assert self._regex is not None # Always set by _compile()
209 return bool(self._regex.match(path))
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.
217 Args:
218 patterns: List of (Pattern, attributes) tuples
219 path: Path to match (relative to repository root)
221 Returns:
222 Dictionary of attributes that apply to this path
223 """
224 attributes: dict[bytes, AttributeValue] = {}
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
237 return attributes
240def parse_gitattributes_file(
241 filename: str | bytes,
242) -> list[tuple[Pattern, Mapping[bytes, AttributeValue]]]:
243 """Parse a gitattributes file and return compiled patterns.
245 Args:
246 filename: Path to the .gitattributes file
248 Returns:
249 List of (Pattern, attributes) tuples
250 """
251 patterns = []
253 if isinstance(filename, str):
254 filename = filename.encode("utf-8")
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))
261 return patterns
264def read_gitattributes(
265 path: str | bytes,
266) -> list[tuple[Pattern, Mapping[bytes, AttributeValue]]]:
267 """Read .gitattributes from a directory.
269 Args:
270 path: Directory path to check for .gitattributes
272 Returns:
273 List of (Pattern, attributes) tuples
274 """
275 if isinstance(path, bytes):
276 path = path.decode("utf-8")
278 gitattributes_path = os.path.join(path, ".gitattributes")
279 if os.path.exists(gitattributes_path):
280 return parse_gitattributes_file(gitattributes_path)
282 return []
285class GitAttributes:
286 """A collection of gitattributes patterns that can match paths."""
288 def __init__(
289 self,
290 patterns: list[tuple[Pattern, Mapping[bytes, AttributeValue]]] | None = None,
291 ):
292 """Initialize GitAttributes.
294 Args:
295 patterns: Optional list of (Pattern, attributes) tuples
296 """
297 self._patterns = patterns or []
299 def match_path(self, path: bytes) -> dict[bytes, AttributeValue]:
300 """Get attributes for a path by matching against patterns.
302 Args:
303 path: Path to match (relative to repository root)
305 Returns:
306 Dictionary of attributes that apply to this path
307 """
308 return match_path(self._patterns, path)
310 def add_patterns(
311 self, patterns: Sequence[tuple[Pattern, Mapping[bytes, AttributeValue]]]
312 ) -> None:
313 """Add patterns to the collection.
315 Args:
316 patterns: List of (Pattern, attributes) tuples to add
317 """
318 self._patterns.extend(patterns)
320 def __len__(self) -> int:
321 """Return the number of patterns."""
322 return len(self._patterns)
324 def __iter__(self) -> Iterator[tuple["Pattern", Mapping[bytes, AttributeValue]]]:
325 """Iterate over patterns."""
326 return iter(self._patterns)
328 @classmethod
329 def from_file(cls, filename: str | bytes) -> "GitAttributes":
330 """Create GitAttributes from a gitattributes file.
332 Args:
333 filename: Path to the .gitattributes file
335 Returns:
336 New GitAttributes instance
337 """
338 patterns = parse_gitattributes_file(filename)
339 return cls(patterns)
341 @classmethod
342 def from_path(cls, path: str | bytes) -> "GitAttributes":
343 """Create GitAttributes from .gitattributes in a directory.
345 Args:
346 path: Directory path to check for .gitattributes
348 Returns:
349 New GitAttributes instance
350 """
351 patterns = read_gitattributes(path)
352 return cls(patterns)
354 def set_attribute(self, pattern: bytes, name: bytes, value: AttributeValue) -> None:
355 """Set an attribute for a pattern.
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
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
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)
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
391 def remove_pattern(self, pattern: bytes) -> None:
392 """Remove all attributes for a pattern.
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 ]
401 def to_bytes(self) -> bytes:
402 """Convert GitAttributes to bytes format suitable for writing to file.
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 = []
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)
423 if attr_strs:
424 line = pattern + b" " + b" ".join(attr_strs)
425 lines.append(line)
427 return b"\n".join(lines) + b"\n" if lines else b""
429 def write_to_file(self, filename: str | bytes) -> None:
430 """Write GitAttributes to a file.
432 Args:
433 filename: Path to write the .gitattributes file
434 """
435 if isinstance(filename, str):
436 filename = filename.encode("utf-8")
438 content = self.to_bytes()
439 with open(filename, "wb") as f:
440 f.write(content)