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 errno
37import logging
38import os
39import re
40from collections.abc import Generator, Iterable, Iterator, Mapping, Sequence
41from typing import IO
43from .file import open_nofollow_read
44from .wildmatch import MalformedPattern
45from .wildmatch import translate as translate_wildmatch
47logger = logging.getLogger(__name__)
49AttributeValue = bytes | bool | None
52def _parse_attr(attr: bytes) -> tuple[bytes, AttributeValue]:
53 """Parse a git attribute into its value.
55 >>> _parse_attr(b'attr')
56 (b'attr', True)
57 >>> _parse_attr(b'-attr')
58 (b'attr', False)
59 >>> _parse_attr(b'!attr')
60 (b'attr', None)
61 >>> _parse_attr(b'attr=text')
62 (b'attr', b'text')
63 """
64 if attr.startswith(b"!"):
65 return attr[1:], None
66 if attr.startswith(b"-"):
67 return attr[1:], False
68 if b"=" not in attr:
69 return attr, True
70 # Split only on first = to handle values with = in them
71 name, _, value = attr.partition(b"=")
72 return name, value
75def parse_git_attributes(
76 f: IO[bytes],
77) -> Generator[tuple[bytes, Mapping[bytes, AttributeValue]], None, None]:
78 """Parse a Git attributes string.
80 Args:
81 f: File-like object to read bytes from
82 Returns:
83 List of patterns and corresponding patterns in the order or them being encountered
84 >>> from io import BytesIO
85 >>> list(parse_git_attributes(BytesIO(b'''*.tar.* filter=lfs diff=lfs merge=lfs -text
86 ...
87 ... # store signatures in Git
88 ... *.tar.*.asc -filter -diff merge=binary -text
89 ...
90 ... # store .dsc verbatim
91 ... *.dsc -filter !diff merge=binary !text
92 ... '''))) #doctest: +NORMALIZE_WHITESPACE
93 [(b'*.tar.*', {'filter': 'lfs', 'diff': 'lfs', 'merge': 'lfs', 'text': False}),
94 (b'*.tar.*.asc', {'filter': False, 'diff': False, 'merge': 'binary', 'text': False}),
95 (b'*.dsc', {'filter': False, 'diff': None, 'merge': 'binary', 'text': None})]
96 """
97 for line in f:
98 line = line.strip()
100 # Ignore blank lines, they're used for readability.
101 if not line:
102 continue
104 if line.startswith(b"#"):
105 # Comment
106 continue
108 pattern, *attrs = line.split()
110 yield (pattern, {k: v for k, v in (_parse_attr(a) for a in attrs)})
113def _translate_pattern(pattern: bytes) -> bytes:
114 """Translate a gitattributes pattern to a regular expression.
116 Similar to gitignore patterns, but simpler as gitattributes doesn't support
117 all the same features (e.g., no directory-only patterns with trailing /).
119 Raises:
120 MalformedPattern: if wildmatch() would refuse the pattern outright;
121 see :func:`dulwich.wildmatch.translate`.
122 """
123 res = b""
125 # If pattern doesn't contain /, it can match at any level
126 if b"/" not in pattern:
127 res = b"(?:.*/)??"
128 elif pattern.startswith(b"/"):
129 # Leading / means root of repository
130 pattern = pattern[1:]
132 return res + translate_wildmatch(pattern)
135class Pattern:
136 """A single gitattributes pattern."""
138 def __init__(self, pattern: bytes):
139 """Initialize GitAttributesPattern.
141 Args:
142 pattern: Attribute pattern as bytes
143 """
144 self.pattern = pattern
145 self._regex: re.Pattern[bytes] | None = None
146 self._compile()
148 def _compile(self) -> None:
149 """Compile the pattern to a regular expression."""
150 regex_pattern = _translate_pattern(self.pattern)
151 # Add anchors
152 regex_pattern = b"^" + regex_pattern + b"$"
153 self._regex = re.compile(regex_pattern)
155 def match(self, path: bytes) -> bool:
156 """Check if path matches this pattern.
158 Args:
159 path: Path to check (relative to repository root, using / separators)
161 Returns:
162 True if path matches this pattern
163 """
164 # Normalize path
165 if path.startswith(b"/"):
166 path = path[1:]
168 # Try to match
169 assert self._regex is not None # Always set by _compile()
170 return bool(self._regex.match(path))
173def match_path(
174 patterns: Sequence[tuple[Pattern, Mapping[bytes, AttributeValue]]], path: bytes
175) -> dict[bytes, AttributeValue]:
176 """Get attributes for a path by matching against patterns.
178 Args:
179 patterns: List of (Pattern, attributes) tuples
180 path: Path to match (relative to repository root)
182 Returns:
183 Dictionary of attributes that apply to this path
184 """
185 attributes: dict[bytes, AttributeValue] = {}
187 # Later patterns override earlier ones
188 for pattern, attrs in patterns:
189 if pattern.match(path):
190 # Update attributes
191 for name, value in attrs.items():
192 if value is None:
193 # Unspecified - remove the attribute
194 attributes.pop(name, None)
195 else:
196 attributes[name] = value
198 return attributes
201def compile_gitattributes_patterns(
202 entries: Iterable[tuple[bytes, Mapping[bytes, AttributeValue]]],
203 source: str | bytes = b"<attributes>",
204) -> list[tuple[Pattern, Mapping[bytes, AttributeValue]]]:
205 """Compile parsed gitattributes entries, skipping malformed patterns.
207 Git's wildmatch() treats a malformed pattern as matching nothing rather
208 than as a broken file, so one bad line is logged and dropped instead of
209 aborting the load.
211 Args:
212 entries: (pattern, attributes) pairs, as from parse_git_attributes
213 source: Where the entries came from, used in the warning
215 Returns:
216 List of (Pattern, attributes) tuples
217 """
218 patterns = []
219 for pattern_bytes, attrs in entries:
220 try:
221 pattern = Pattern(pattern_bytes)
222 except MalformedPattern:
223 logger.warning("Ignoring malformed pattern %r in %r", pattern_bytes, source)
224 continue
225 patterns.append((pattern, attrs))
226 return patterns
229def parse_gitattributes_file(
230 filename: str | bytes,
231) -> list[tuple[Pattern, Mapping[bytes, AttributeValue]]]:
232 """Parse a gitattributes file and return compiled patterns.
234 A malformed pattern is logged and skipped rather than raised, so one bad
235 line doesn't stop the rest of the file from loading.
237 Args:
238 filename: Path to the .gitattributes file
240 Returns:
241 List of (Pattern, attributes) tuples
242 """
243 if isinstance(filename, str):
244 filename = filename.encode("utf-8")
246 with open(filename, "rb") as f:
247 return compile_gitattributes_patterns(parse_git_attributes(f), filename)
250def read_gitattributes(
251 path: str | bytes,
252) -> list[tuple[Pattern, Mapping[bytes, AttributeValue]]]:
253 """Read .gitattributes from a directory.
255 A ``.gitattributes`` that is a symlink is skipped with a warning rather
256 than followed, as git does.
258 Args:
259 path: Directory path to check for .gitattributes
261 Returns:
262 List of (Pattern, attributes) tuples
263 """
264 if isinstance(path, bytes):
265 path = path.decode("utf-8")
267 gitattributes_path = os.path.join(path, ".gitattributes")
268 try:
269 f = open_nofollow_read(gitattributes_path)
270 except FileNotFoundError:
271 return []
272 except OSError as e:
273 if e.errno not in (errno.ELOOP, errno.EMLINK):
274 raise
275 logger.warning("Ignoring %s: it is a symbolic link", gitattributes_path)
276 return []
277 with f:
278 return compile_gitattributes_patterns(
279 parse_git_attributes(f), gitattributes_path.encode("utf-8")
280 )
283class GitAttributes:
284 """A collection of gitattributes patterns that can match paths."""
286 def __init__(
287 self,
288 patterns: list[tuple[Pattern, Mapping[bytes, AttributeValue]]] | None = None,
289 ):
290 """Initialize GitAttributes.
292 Args:
293 patterns: Optional list of (Pattern, attributes) tuples
294 """
295 self._patterns = patterns or []
297 def match_path(self, path: bytes) -> dict[bytes, AttributeValue]:
298 """Get attributes for a path by matching against patterns.
300 Args:
301 path: Path to match (relative to repository root)
303 Returns:
304 Dictionary of attributes that apply to this path
305 """
306 return match_path(self._patterns, path)
308 def add_patterns(
309 self, patterns: Sequence[tuple[Pattern, Mapping[bytes, AttributeValue]]]
310 ) -> None:
311 """Add patterns to the collection.
313 Args:
314 patterns: List of (Pattern, attributes) tuples to add
315 """
316 self._patterns.extend(patterns)
318 def __len__(self) -> int:
319 """Return the number of patterns."""
320 return len(self._patterns)
322 def __iter__(self) -> Iterator[tuple["Pattern", Mapping[bytes, AttributeValue]]]:
323 """Iterate over patterns."""
324 return iter(self._patterns)
326 @classmethod
327 def from_file(cls, filename: str | bytes) -> "GitAttributes":
328 """Create GitAttributes from a gitattributes file.
330 Args:
331 filename: Path to the .gitattributes file
333 Returns:
334 New GitAttributes instance
335 """
336 patterns = parse_gitattributes_file(filename)
337 return cls(patterns)
339 @classmethod
340 def from_path(cls, path: str | bytes) -> "GitAttributes":
341 """Create GitAttributes from .gitattributes in a directory.
343 Args:
344 path: Directory path to check for .gitattributes
346 Returns:
347 New GitAttributes instance
348 """
349 patterns = read_gitattributes(path)
350 return cls(patterns)
352 def set_attribute(self, pattern: bytes, name: bytes, value: AttributeValue) -> None:
353 """Set an attribute for a pattern.
355 Args:
356 pattern: The file pattern
357 name: Attribute name
358 value: Attribute value (bytes, True, False, or None)
359 """
360 # Find existing pattern
361 pattern_obj = None
362 attrs_dict: dict[bytes, AttributeValue] | None = None
363 pattern_index = -1
365 for i, (p, attrs) in enumerate(self._patterns):
366 if p.pattern == pattern:
367 pattern_obj = p
368 # Convert to mutable dict
369 attrs_dict = dict(attrs)
370 pattern_index = i
371 break
373 if pattern_obj is None:
374 # Create new pattern
375 pattern_obj = Pattern(pattern)
376 attrs_dict = {name: value}
377 self._patterns.append((pattern_obj, attrs_dict))
378 else:
379 # Update the existing pattern in the list
380 assert pattern_index >= 0
381 assert attrs_dict is not None
382 self._patterns[pattern_index] = (pattern_obj, attrs_dict)
384 # Update the attribute
385 if attrs_dict is None:
386 raise AssertionError("attrs_dict should not be None at this point")
387 attrs_dict[name] = value
389 def remove_pattern(self, pattern: bytes) -> None:
390 """Remove all attributes for a pattern.
392 Args:
393 pattern: The file pattern to remove
394 """
395 self._patterns = [
396 (p, attrs) for p, attrs in self._patterns if p.pattern != pattern
397 ]
399 def to_bytes(self) -> bytes:
400 """Convert GitAttributes to bytes format suitable for writing to file.
402 Returns:
403 Bytes representation of the gitattributes file
404 """
405 lines = []
406 for pattern_obj, attrs in self._patterns:
407 pattern = pattern_obj.pattern
408 attr_strs = []
410 for name, value in sorted(attrs.items()):
411 if value is True:
412 attr_strs.append(name)
413 elif value is False:
414 attr_strs.append(b"-" + name)
415 elif value is None:
416 attr_strs.append(b"!" + name)
417 else:
418 # value is bytes
419 attr_strs.append(name + b"=" + value)
421 if attr_strs:
422 line = pattern + b" " + b" ".join(attr_strs)
423 lines.append(line)
425 return b"\n".join(lines) + b"\n" if lines else b""
427 def write_to_file(self, filename: str | bytes) -> None:
428 """Write GitAttributes to a file.
430 Args:
431 filename: Path to write the .gitattributes file
432 """
433 if isinstance(filename, str):
434 filename = filename.encode("utf-8")
436 content = self.to_bytes()
437 with open(filename, "wb") as f:
438 f.write(content)