-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy path_ignore.py
More file actions
283 lines (214 loc) · 8.46 KB
/
Copy path_ignore.py
File metadata and controls
283 lines (214 loc) · 8.46 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
from __future__ import annotations
import os
from typing import Iterable, Optional, Sequence
from pathlib import Path, PurePosixPath
from dataclasses import dataclass
__all__ = [
"IgnorePattern",
"read_ignorefile",
"compile_ignore",
"path_match",
"is_ignored",
]
@dataclass(frozen=True)
class IgnorePattern:
"""Single parsed ignore pattern.
Follows Docker-style .dockerignore semantics, supports other ignore use cases following same approach.
Details:
- ``pattern``: The normalized pattern text with leading/trailing ``/`` removed.
Always uses POSIX ``'/'`` separators.
- ``negated``: True if this is a negation pattern starting with ``!``.
- ``directory_only``: True if the original pattern ended with ``/`` and should
apply only to directories and their descendants.
- ``anchored``: True if the pattern contains a path separator and should be
matched relative to the root path rather than at any depth.
"""
pattern: str
negated: bool
directory_only: bool
anchored: bool
def _normalize_pattern_line(raw: bytes, *, is_first_line: bool) -> Optional[str]:
"""Normalize a single ignorefile line, mirroring moby's ignorefile.ReadAll.
Behavior is based on:
https://github.com/moby/patternmatcher/blob/main/ignorefile/ignorefile.go
"""
# Strip UTF-8 BOM from the first line if present
if is_first_line and raw.startswith(b"\xef\xbb\xbf"):
raw = raw[len(b"\xef\xbb\xbf") :]
# Decode as UTF-8; we are strict here to surface bad encodings
text = raw.decode("utf-8", errors="strict")
text = text.rstrip("\r\n")
# Lines starting with '#' are comments and are ignored before processing,
# i.e. we do *not* treat leading spaces as part of the comment detection.
if text.startswith("#"):
return None
# Trim leading and trailing whitespace
pattern = text.strip()
if not pattern:
return None
# Normalize absolute paths to paths relative to the context (taking care of '!' prefix)
invert = pattern[0] == "!"
if invert:
pattern = pattern[1:].strip()
if pattern:
# filepath.Clean equivalent
pattern = os.path.normpath(pattern)
# filepath.ToSlash equivalent
pattern = pattern.replace(os.sep, "/")
# Leading forward-slashes are removed so "/some/path" and "some/path"
# are considered equivalent.
if len(pattern) > 1 and pattern[0] == "/":
pattern = pattern[1:]
if invert:
pattern = "!" + pattern
return pattern
def read_ignorefile(path: Optional[Path]) -> list[str]:
"""Read an ignore file and return a list of normalized pattern strings.
This mirrors the behavior of moby's ``ignorefile.ReadAll``:
- UTF-8 BOM on the first line is stripped.
- Lines starting with ``#`` are treated as comments and skipped.
- Remaining lines are trimmed, optionally negated with ``!``, cleaned,
have path separators normalized to ``/``, and leading ``/`` removed.
"""
if path is None:
return []
if not path.exists():
return []
patterns: list[str] = []
with path.open("rb") as f:
first = True
for raw in f:
normalized = _normalize_pattern_line(raw, is_first_line=first)
first = False
if normalized is None:
continue
patterns.append(normalized)
return patterns
def compile_ignore(patterns: Sequence[str]) -> list[IgnorePattern]:
"""Compile raw pattern strings into :class:`IgnorePattern` objects."""
compiled: list[IgnorePattern] = []
for raw in patterns:
if not raw:
continue
negated = raw[0] == "!"
pattern_text = raw[1:] if negated else raw
if not pattern_text:
# Bare "!" is ignored, matching Docker / moby behavior.
continue
directory_only = pattern_text.endswith("/")
if directory_only:
pattern_text = pattern_text.rstrip("/")
if not pattern_text:
continue
# Treat patterns containing a path separator as anchored to the root
anchored = "/" in pattern_text
compiled.append(
IgnorePattern(
pattern=PurePosixPath(pattern_text).as_posix(),
negated=negated,
directory_only=directory_only,
anchored=anchored,
)
)
return compiled
def _segment_match(pattern_segment: str, path_segment: str) -> bool:
"""Match a single path segment against a glob pattern segment.
Supports:
- ``*``: any sequence of characters except ``/``.
- ``?``: any single character except ``/``.
- ``[]``: character classes, excluding ``/``.
"""
import re
escaped = ""
i = 0
while i < len(pattern_segment):
ch = pattern_segment[i]
if ch == "*":
escaped += "[^/]*"
elif ch == "?":
escaped += "[^/]"
elif ch == "[":
# Copy character class as-is until closing ']'.
j = i + 1
while j < len(pattern_segment) and pattern_segment[j] != "]":
j += 1
if j < len(pattern_segment):
escaped += pattern_segment[i : j + 1]
i = j
else:
# Unterminated '['; treat it literally.
escaped += re.escape(ch)
else:
escaped += re.escape(ch)
i += 1
regex = re.compile(rf"^{escaped}$")
return regex.match(path_segment) is not None
def _match_parts_recursive(pattern_parts: list[str], path_parts: list[str]) -> bool:
"""Recursive helper implementing ``**`` segment semantics."""
if not pattern_parts:
return not path_parts
if pattern_parts[0] == "**":
# '**' matches zero or more segments.
for i in range(len(path_parts) + 1):
if _match_parts_recursive(pattern_parts[1:], path_parts[i:]):
return True
return False
if not path_parts:
return False
if not _segment_match(pattern_parts[0], path_parts[0]):
return False
return _match_parts_recursive(pattern_parts[1:], path_parts[1:])
def path_match(pattern: IgnorePattern, relpath: str, *, is_dir: bool) -> bool:
"""Return True if ``relpath`` matches a compiled ignore pattern."""
relpath_posix = PurePosixPath(relpath).as_posix()
path_parts = PurePosixPath(relpath_posix).parts
pattern_parts = PurePosixPath(pattern.pattern).parts
# Directory-only patterns never directly match files here; the effect on
# descendants is enforced by directory pruning in the traversal.
if pattern.directory_only and not is_dir:
return False
if pattern.anchored:
return _match_parts_recursive(list(pattern_parts), list(path_parts))
for start in range(len(path_parts)):
if _match_parts_recursive(list(pattern_parts), list(path_parts[start:])):
return True
return False
def is_ignored(relpath: str, *, is_dir: bool, patterns: Sequence[IgnorePattern]) -> bool:
"""Apply ignore patterns with 'last match wins' semantics.
Examples::
*.log
!important.log
excludes all ``.log`` files except ``important.log``. Patterns are applied
in order, and the last matching pattern determines inclusion.
"""
included = True # include by default
for pat in patterns:
if path_match(pat, relpath, is_dir=is_dir):
included = pat.negated
return not included
def iter_included_files(
root: Path,
*,
patterns: Sequence[IgnorePattern],
) -> Iterable[Path]:
"""Yield all files under ``root`` that are not ignored.
This performs directory pruning so that ignored directories are never
traversed, mirroring Docker's behavior for .dockerignore.
"""
if not root.is_dir():
raise ValueError(f"root must be a directory, got: {root}")
for dirpath, dirs, files in os.walk(root):
dir_path = Path(dirpath)
# Prune ignored directories
for name in list(dirs):
subdir = dir_path / name
rel_dir = subdir.relative_to(root).as_posix()
if is_ignored(rel_dir, is_dir=True, patterns=patterns):
dirs.remove(name)
# Yield non-ignored files
for name in files:
file_path = dir_path / name
rel_file = file_path.relative_to(root).as_posix()
if is_ignored(rel_file, is_dir=False, patterns=patterns):
continue
yield file_path