Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/dulwich/attrs.py: 31%
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 errno
37import logging
38import os
39import re
40from collections.abc import Callable, Generator, Iterable, Iterator, Mapping, Sequence
41from itertools import chain
42from typing import IO
44from .file import open_nofollow_read
45from .wildmatch import MalformedPattern
46from .wildmatch import translate as translate_wildmatch
48logger = logging.getLogger(__name__)
50AttributeValue = bytes | bool | None
53def _parse_attr(attr: bytes) -> tuple[bytes, AttributeValue]:
54 """Parse a git attribute into its value.
56 >>> _parse_attr(b'attr')
57 (b'attr', True)
58 >>> _parse_attr(b'-attr')
59 (b'attr', False)
60 >>> _parse_attr(b'!attr')
61 (b'attr', None)
62 >>> _parse_attr(b'attr=text')
63 (b'attr', b'text')
64 """
65 if attr.startswith(b"!"):
66 return attr[1:], None
67 if attr.startswith(b"-"):
68 return attr[1:], False
69 if b"=" not in attr:
70 return attr, True
71 # Split only on first = to handle values with = in them
72 name, _, value = attr.partition(b"=")
73 return name, value
76def parse_git_attributes(
77 f: IO[bytes],
78) -> Generator[tuple[bytes, Mapping[bytes, AttributeValue]], None, None]:
79 """Parse a Git attributes string.
81 Args:
82 f: File-like object to read bytes from
83 Returns:
84 List of patterns and corresponding patterns in the order or them being encountered
85 >>> from io import BytesIO
86 >>> list(parse_git_attributes(BytesIO(b'''*.tar.* filter=lfs diff=lfs merge=lfs -text
87 ...
88 ... # store signatures in Git
89 ... *.tar.*.asc -filter -diff merge=binary -text
90 ...
91 ... # store .dsc verbatim
92 ... *.dsc -filter !diff merge=binary !text
93 ... '''))) #doctest: +NORMALIZE_WHITESPACE
94 [(b'*.tar.*', {'filter': 'lfs', 'diff': 'lfs', 'merge': 'lfs', 'text': False}),
95 (b'*.tar.*.asc', {'filter': False, 'diff': False, 'merge': 'binary', 'text': False}),
96 (b'*.dsc', {'filter': False, 'diff': None, 'merge': 'binary', 'text': None})]
97 """
98 for line in f:
99 line = line.strip()
101 # Ignore blank lines, they're used for readability.
102 if not line:
103 continue
105 if line.startswith(b"#"):
106 # Comment
107 continue
109 pattern, *attrs = line.split()
111 yield (pattern, {k: v for k, v in (_parse_attr(a) for a in attrs)})
114def _translate_pattern(pattern: bytes, base: bytes = b"") -> bytes:
115 """Translate a gitattributes pattern to a regular expression.
117 Similar to gitignore patterns, but simpler as gitattributes doesn't support
118 all the same features (e.g., no directory-only patterns with trailing /).
120 Args:
121 pattern: The pattern to translate
122 base: Directory of the .gitattributes file the pattern comes from,
123 relative to the repository root; empty for the root
125 Raises:
126 MalformedPattern: if wildmatch() would refuse the pattern outright;
127 see :func:`dulwich.wildmatch.translate`.
128 """
129 res = re.escape(base + b"/") if base else b""
131 # If pattern doesn't contain /, it can match at any level
132 if b"/" not in pattern:
133 res += b"(?:.*/)??"
134 elif pattern.startswith(b"/"):
135 # Leading / means the directory of the .gitattributes file
136 pattern = pattern[1:]
138 return res + translate_wildmatch(pattern)
141class Pattern:
142 """A single gitattributes pattern."""
144 def __init__(self, pattern: bytes, base: bytes = b""):
145 """Initialize GitAttributesPattern.
147 Args:
148 pattern: Attribute pattern as bytes
149 base: Directory of the .gitattributes file the pattern comes
150 from, relative to the repository root; empty for the root
151 """
152 self.pattern = pattern
153 self.base = base
154 self._regex: re.Pattern[bytes] | None = None
155 self._compile()
157 def _compile(self) -> None:
158 """Compile the pattern to a regular expression."""
159 regex_pattern = _translate_pattern(self.pattern, self.base)
160 # Add anchors
161 regex_pattern = b"^" + regex_pattern + b"$"
162 self._regex = re.compile(regex_pattern)
164 def match(self, path: bytes) -> bool:
165 """Check if path matches this pattern.
167 Args:
168 path: Path to check (relative to repository root, using / separators)
170 Returns:
171 True if path matches this pattern
172 """
173 # Normalize path
174 if path.startswith(b"/"):
175 path = path[1:]
177 # Try to match
178 assert self._regex is not None # Always set by _compile()
179 return bool(self._regex.match(path))
182def match_path(
183 patterns: Iterable[tuple[Pattern, Mapping[bytes, AttributeValue]]], path: bytes
184) -> dict[bytes, AttributeValue]:
185 """Get attributes for a path by matching against patterns.
187 Args:
188 patterns: List of (Pattern, attributes) tuples
189 path: Path to match (relative to repository root)
191 Returns:
192 Dictionary of attributes that apply to this path
193 """
194 attributes: dict[bytes, AttributeValue] = {}
196 # Later patterns override earlier ones
197 for pattern, attrs in patterns:
198 if pattern.match(path):
199 # Update attributes
200 for name, value in attrs.items():
201 if value is None:
202 # Unspecified - remove the attribute
203 attributes.pop(name, None)
204 else:
205 attributes[name] = value
207 return attributes
210def compile_gitattributes_patterns(
211 entries: Iterable[tuple[bytes, Mapping[bytes, AttributeValue]]],
212 source: str | bytes = b"<attributes>",
213 base: bytes = b"",
214) -> list[tuple[Pattern, Mapping[bytes, AttributeValue]]]:
215 """Compile parsed gitattributes entries, skipping malformed patterns.
217 Git's wildmatch() treats a malformed pattern as matching nothing rather
218 than as a broken file, so one bad line is logged and dropped instead of
219 aborting the load.
221 Args:
222 entries: (pattern, attributes) pairs, as from parse_git_attributes
223 source: Where the entries came from, used in the warning
224 base: Directory the patterns are relative to, relative to the
225 repository root; empty for the root
227 Returns:
228 List of (Pattern, attributes) tuples
229 """
230 patterns = []
231 for pattern_bytes, attrs in entries:
232 try:
233 pattern = Pattern(pattern_bytes, base)
234 except MalformedPattern:
235 logger.warning("Ignoring malformed pattern %r in %r", pattern_bytes, source)
236 continue
237 patterns.append((pattern, attrs))
238 return patterns
241def parse_gitattributes_file(
242 filename: str | bytes,
243) -> list[tuple[Pattern, Mapping[bytes, AttributeValue]]]:
244 """Parse a gitattributes file and return compiled patterns.
246 A malformed pattern is logged and skipped rather than raised, so one bad
247 line doesn't stop the rest of the file from loading.
249 Args:
250 filename: Path to the .gitattributes file
252 Returns:
253 List of (Pattern, attributes) tuples
254 """
255 if isinstance(filename, str):
256 filename = filename.encode("utf-8")
258 with open(filename, "rb") as f:
259 return compile_gitattributes_patterns(parse_git_attributes(f), filename)
262def read_gitattributes(
263 path: str | bytes,
264) -> list[tuple[Pattern, Mapping[bytes, AttributeValue]]]:
265 """Read .gitattributes from a directory.
267 A ``.gitattributes`` that is a symlink is skipped with a warning rather
268 than followed, as git does.
270 Args:
271 path: Directory path to check for .gitattributes
273 Returns:
274 List of (Pattern, attributes) tuples
275 """
276 if isinstance(path, bytes):
277 path = path.decode("utf-8")
279 gitattributes_path = os.path.join(path, ".gitattributes")
280 try:
281 f = open_nofollow_read(gitattributes_path)
282 except FileNotFoundError:
283 return []
284 except OSError as e:
285 if e.errno not in (errno.ELOOP, errno.EMLINK):
286 raise
287 logger.warning("Ignoring %s: it is a symbolic link", gitattributes_path)
288 return []
289 with f:
290 return compile_gitattributes_patterns(
291 parse_git_attributes(f), gitattributes_path.encode("utf-8")
292 )
295class GitAttributes:
296 """A collection of gitattributes patterns that can match paths.
298 Patterns are applied in the order git uses: ``patterns`` first, then
299 those of each directory from the top down to the path's own directory
300 (as returned by ``directory_loader``), then ``info_patterns``.
301 """
303 def __init__(
304 self,
305 patterns: list[tuple[Pattern, Mapping[bytes, AttributeValue]]] | None = None,
306 *,
307 directory_loader: Callable[
308 [bytes], list[tuple[Pattern, Mapping[bytes, AttributeValue]]]
309 ]
310 | None = None,
311 info_patterns: list[tuple[Pattern, Mapping[bytes, AttributeValue]]]
312 | None = None,
313 ):
314 """Initialize GitAttributes.
316 Args:
317 patterns: Optional list of (Pattern, attributes) tuples
318 directory_loader: Optional callable that returns the patterns
319 of the .gitattributes file in a subdirectory, given its path
320 relative to the repository root. Called at most once per
321 directory, when a path below it is first matched.
322 info_patterns: Optional list of (Pattern, attributes) tuples
323 that take precedence over all others, as from
324 ``$GIT_DIR/info/attributes``
325 """
326 self._patterns = patterns or []
327 self._directory_loader = directory_loader
328 self._directory_patterns: dict[
329 bytes, list[tuple[Pattern, Mapping[bytes, AttributeValue]]]
330 ] = {}
331 self._info_patterns = info_patterns or []
333 def _get_directory_patterns(
334 self, dirpath: bytes
335 ) -> list[tuple[Pattern, Mapping[bytes, AttributeValue]]]:
336 try:
337 return self._directory_patterns[dirpath]
338 except KeyError:
339 pass
340 assert self._directory_loader is not None
341 patterns = self._directory_loader(dirpath)
342 self._directory_patterns[dirpath] = patterns
343 return patterns
345 def match_path(self, path: bytes) -> dict[bytes, AttributeValue]:
346 """Get attributes for a path by matching against patterns.
348 Args:
349 path: Path to match (relative to repository root)
351 Returns:
352 Dictionary of attributes that apply to this path
353 """
354 directory_patterns: list[
355 list[tuple[Pattern, Mapping[bytes, AttributeValue]]]
356 ] = []
357 if self._directory_loader is not None:
358 parts = path.lstrip(b"/").split(b"/")[:-1]
359 for i in range(1, len(parts) + 1):
360 directory_patterns.append(
361 self._get_directory_patterns(b"/".join(parts[:i]))
362 )
363 return match_path(
364 chain(self._patterns, *directory_patterns, self._info_patterns), path
365 )
367 def add_patterns(
368 self, patterns: Sequence[tuple[Pattern, Mapping[bytes, AttributeValue]]]
369 ) -> None:
370 """Add patterns to the collection.
372 Args:
373 patterns: List of (Pattern, attributes) tuples to add
374 """
375 self._patterns.extend(patterns)
377 def __len__(self) -> int:
378 """Return the number of patterns.
380 Patterns from subdirectories that have not been loaded yet are
381 not counted.
382 """
383 return len(self._patterns) + len(self._info_patterns)
385 def __iter__(self) -> Iterator[tuple["Pattern", Mapping[bytes, AttributeValue]]]:
386 """Iterate over the top-level patterns and ``info_patterns``.
388 Patterns from subdirectories are not included.
389 """
390 yield from self._patterns
391 yield from self._info_patterns
393 @classmethod
394 def from_file(cls, filename: str | bytes) -> "GitAttributes":
395 """Create GitAttributes from a gitattributes file.
397 Args:
398 filename: Path to the .gitattributes file
400 Returns:
401 New GitAttributes instance
402 """
403 patterns = parse_gitattributes_file(filename)
404 return cls(patterns)
406 @classmethod
407 def from_path(cls, path: str | bytes) -> "GitAttributes":
408 """Create GitAttributes from .gitattributes in a directory.
410 Args:
411 path: Directory path to check for .gitattributes
413 Returns:
414 New GitAttributes instance
415 """
416 patterns = read_gitattributes(path)
417 return cls(patterns)
419 def set_attribute(self, pattern: bytes, name: bytes, value: AttributeValue) -> None:
420 """Set an attribute for a pattern.
422 Args:
423 pattern: The file pattern
424 name: Attribute name
425 value: Attribute value (bytes, True, False, or None)
426 """
427 # Find existing pattern
428 pattern_obj = None
429 attrs_dict: dict[bytes, AttributeValue] | None = None
430 pattern_index = -1
432 for i, (p, attrs) in enumerate(self._patterns):
433 if p.pattern == pattern:
434 pattern_obj = p
435 # Convert to mutable dict
436 attrs_dict = dict(attrs)
437 pattern_index = i
438 break
440 if pattern_obj is None:
441 # Create new pattern
442 pattern_obj = Pattern(pattern)
443 attrs_dict = {name: value}
444 self._patterns.append((pattern_obj, attrs_dict))
445 else:
446 # Update the existing pattern in the list
447 assert pattern_index >= 0
448 assert attrs_dict is not None
449 self._patterns[pattern_index] = (pattern_obj, attrs_dict)
451 # Update the attribute
452 if attrs_dict is None:
453 raise AssertionError("attrs_dict should not be None at this point")
454 attrs_dict[name] = value
456 def remove_pattern(self, pattern: bytes) -> None:
457 """Remove all attributes for a pattern.
459 Args:
460 pattern: The file pattern to remove
461 """
462 self._patterns = [
463 (p, attrs) for p, attrs in self._patterns if p.pattern != pattern
464 ]
466 def to_bytes(self) -> bytes:
467 """Convert GitAttributes to bytes format suitable for writing to file.
469 Returns:
470 Bytes representation of the gitattributes file
471 """
472 lines = []
473 for pattern_obj, attrs in self._patterns:
474 pattern = pattern_obj.pattern
475 attr_strs = []
477 for name, value in sorted(attrs.items()):
478 match value:
479 case True:
480 attr_strs.append(name)
481 case False:
482 attr_strs.append(b"-" + name)
483 case None:
484 attr_strs.append(b"!" + name)
485 case _:
486 # value is bytes
487 attr_strs.append(name + b"=" + value)
489 if attr_strs:
490 line = pattern + b" " + b" ".join(attr_strs)
491 lines.append(line)
493 return b"\n".join(lines) + b"\n" if lines else b""
495 def write_to_file(self, filename: str | bytes) -> None:
496 """Write GitAttributes to a file.
498 Args:
499 filename: Path to write the .gitattributes file
500 """
501 if isinstance(filename, str):
502 filename = filename.encode("utf-8")
504 content = self.to_bytes()
505 with open(filename, "wb") as f:
506 f.write(content)