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
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 "compile_gitattributes_patterns",
30 "match_path",
31 "parse_git_attributes",
32 "parse_gitattributes_file",
33 "read_gitattributes",
34]
36import logging
37import os
38import re
39from collections.abc import Generator, Iterable, Iterator, Mapping, Sequence
40from typing import IO
42from .wildmatch import MalformedPattern
43from .wildmatch import translate as translate_wildmatch
45logger = logging.getLogger(__name__)
47AttributeValue = bytes | bool | None
50def _parse_attr(attr: bytes) -> tuple[bytes, AttributeValue]:
51 """Parse a git attribute into its value.
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
73def parse_git_attributes(
74 f: IO[bytes],
75) -> Generator[tuple[bytes, Mapping[bytes, AttributeValue]], None, None]:
76 """Parse a Git attributes string.
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()
98 # Ignore blank lines, they're used for readability.
99 if not line:
100 continue
102 if line.startswith(b"#"):
103 # Comment
104 continue
106 pattern, *attrs = line.split()
108 yield (pattern, {k: v for k, v in (_parse_attr(a) for a in attrs)})
111def _translate_pattern(pattern: bytes) -> bytes:
112 """Translate a gitattributes pattern to a regular expression.
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 /).
117 Raises:
118 MalformedPattern: if wildmatch() would refuse the pattern outright;
119 see :func:`dulwich.wildmatch.translate`.
120 """
121 res = b""
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:]
130 return res + translate_wildmatch(pattern)
133class Pattern:
134 """A single gitattributes pattern."""
136 def __init__(self, pattern: bytes):
137 """Initialize GitAttributesPattern.
139 Args:
140 pattern: Attribute pattern as bytes
141 """
142 self.pattern = pattern
143 self._regex: re.Pattern[bytes] | None = None
144 self._compile()
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)
153 def match(self, path: bytes) -> bool:
154 """Check if path matches this pattern.
156 Args:
157 path: Path to check (relative to repository root, using / separators)
159 Returns:
160 True if path matches this pattern
161 """
162 # Normalize path
163 if path.startswith(b"/"):
164 path = path[1:]
166 # Try to match
167 assert self._regex is not None # Always set by _compile()
168 return bool(self._regex.match(path))
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.
176 Args:
177 patterns: List of (Pattern, attributes) tuples
178 path: Path to match (relative to repository root)
180 Returns:
181 Dictionary of attributes that apply to this path
182 """
183 attributes: dict[bytes, AttributeValue] = {}
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
196 return attributes
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.
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.
209 Args:
210 entries: (pattern, attributes) pairs, as from parse_git_attributes
211 source: Where the entries came from, used in the warning
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
227def parse_gitattributes_file(
228 filename: str | bytes,
229) -> list[tuple[Pattern, Mapping[bytes, AttributeValue]]]:
230 """Parse a gitattributes file and return compiled patterns.
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.
235 Args:
236 filename: Path to the .gitattributes file
238 Returns:
239 List of (Pattern, attributes) tuples
240 """
241 if isinstance(filename, str):
242 filename = filename.encode("utf-8")
244 with open(filename, "rb") as f:
245 return compile_gitattributes_patterns(parse_git_attributes(f), filename)
248def read_gitattributes(
249 path: str | bytes,
250) -> list[tuple[Pattern, Mapping[bytes, AttributeValue]]]:
251 """Read .gitattributes from a directory.
253 Args:
254 path: Directory path to check for .gitattributes
256 Returns:
257 List of (Pattern, attributes) tuples
258 """
259 if isinstance(path, bytes):
260 path = path.decode("utf-8")
262 gitattributes_path = os.path.join(path, ".gitattributes")
263 if os.path.exists(gitattributes_path):
264 return parse_gitattributes_file(gitattributes_path)
266 return []
269class GitAttributes:
270 """A collection of gitattributes patterns that can match paths."""
272 def __init__(
273 self,
274 patterns: list[tuple[Pattern, Mapping[bytes, AttributeValue]]] | None = None,
275 ):
276 """Initialize GitAttributes.
278 Args:
279 patterns: Optional list of (Pattern, attributes) tuples
280 """
281 self._patterns = patterns or []
283 def match_path(self, path: bytes) -> dict[bytes, AttributeValue]:
284 """Get attributes for a path by matching against patterns.
286 Args:
287 path: Path to match (relative to repository root)
289 Returns:
290 Dictionary of attributes that apply to this path
291 """
292 return match_path(self._patterns, path)
294 def add_patterns(
295 self, patterns: Sequence[tuple[Pattern, Mapping[bytes, AttributeValue]]]
296 ) -> None:
297 """Add patterns to the collection.
299 Args:
300 patterns: List of (Pattern, attributes) tuples to add
301 """
302 self._patterns.extend(patterns)
304 def __len__(self) -> int:
305 """Return the number of patterns."""
306 return len(self._patterns)
308 def __iter__(self) -> Iterator[tuple["Pattern", Mapping[bytes, AttributeValue]]]:
309 """Iterate over patterns."""
310 return iter(self._patterns)
312 @classmethod
313 def from_file(cls, filename: str | bytes) -> "GitAttributes":
314 """Create GitAttributes from a gitattributes file.
316 Args:
317 filename: Path to the .gitattributes file
319 Returns:
320 New GitAttributes instance
321 """
322 patterns = parse_gitattributes_file(filename)
323 return cls(patterns)
325 @classmethod
326 def from_path(cls, path: str | bytes) -> "GitAttributes":
327 """Create GitAttributes from .gitattributes in a directory.
329 Args:
330 path: Directory path to check for .gitattributes
332 Returns:
333 New GitAttributes instance
334 """
335 patterns = read_gitattributes(path)
336 return cls(patterns)
338 def set_attribute(self, pattern: bytes, name: bytes, value: AttributeValue) -> None:
339 """Set an attribute for a pattern.
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
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
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)
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
375 def remove_pattern(self, pattern: bytes) -> None:
376 """Remove all attributes for a pattern.
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 ]
385 def to_bytes(self) -> bytes:
386 """Convert GitAttributes to bytes format suitable for writing to file.
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 = []
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)
407 if attr_strs:
408 line = pattern + b" " + b" ".join(attr_strs)
409 lines.append(line)
411 return b"\n".join(lines) + b"\n" if lines else b""
413 def write_to_file(self, filename: str | bytes) -> None:
414 """Write GitAttributes to a file.
416 Args:
417 filename: Path to write the .gitattributes file
418 """
419 if isinstance(filename, str):
420 filename = filename.encode("utf-8")
422 content = self.to_bytes()
423 with open(filename, "wb") as f:
424 f.write(content)