pre-commit-bigfix 2.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,271 @@
1
+ """Helpers shared by the BES hooks in this package.
2
+
3
+ Each of these was once copied into several hooks and kept identical by hand.
4
+ They live here so a fix to one is a fix to all: reading a file as LF text while
5
+ remembering its line endings, writing it back the same way, finding the BES
6
+ files under a folder when no paths are given, and printing the
7
+ `path:line: [CODE] message` report the hooks share.
8
+
9
+ SchemaGuard also lives here. It is what keeps an auto-fix from turning a
10
+ schema-valid file into one that fails BES.xsd validation.
11
+ """
12
+
13
+ import array
14
+ import bisect
15
+ import functools
16
+ import os
17
+ import re
18
+ import sys
19
+
20
+ # an unrendered mustache template ({{ placeholder }}) is not real content yet.
21
+ # Only an identifier-like placeholder counts: `{{` is also the ActionScript
22
+ # escape for a literal `{`, so heredoc payloads (YARA, JSON, C#) contain `{{`
23
+ # around arbitrary content and must not be mistaken for a template.
24
+ # A GUID-shaped "placeholder" is not one: `msiexec /x{{{GUID}}` escapes a
25
+ # literal `{` in front of an MSI product code.
26
+ MUSTACHE_RE = re.compile(
27
+ r"\{\{(?!\s*[0-9A-Fa-f]{8}(?:-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}\s*\}\})"
28
+ r"\s*[#/^!&>]?\s*[\w.-]+\s*\}\}"
29
+ )
30
+
31
+ # directories never searched by discover_bes_files (hidden ones are skipped too)
32
+ DISCOVERY_SKIP_DIRS = frozenset({"__pycache__", "node_modules"})
33
+
34
+
35
+ def read_source(path):
36
+ """Read `path`; return (raw bytes, text with LF line endings, was_crlf).
37
+
38
+ The text is decoded as UTF-8 (undecodable bytes replaced) and every CRLF or
39
+ lone CR is turned into LF, so checks need not care about line endings.
40
+ `was_crlf` records whether the file had any CRLF, for encode() to restore.
41
+ """
42
+ with open(path, "rb") as handle:
43
+ raw = handle.read()
44
+ src = (
45
+ raw.decode("utf-8", errors="replace").replace("\r\n", "\n").replace("\r", "\n")
46
+ )
47
+ return raw, src, b"\r\n" in raw
48
+
49
+
50
+ def encode(src, was_crlf):
51
+ """Turn checked text back into file bytes, restoring CRLF if that is the file."""
52
+ return (src.replace("\n", "\r\n") if was_crlf else src).encode("utf-8")
53
+
54
+
55
+ @functools.lru_cache(maxsize=4)
56
+ def _newline_offsets(src):
57
+ """The offset of every LF in `src`, in order (cached per text).
58
+
59
+ An array of 8-byte ints rather than a list of int objects, so a text with
60
+ many short lines does not cost several times its own size to index.
61
+ """
62
+ return array.array("q", (match.start() for match in re.finditer("\n", src)))
63
+
64
+
65
+ def lineno(src, pos):
66
+ """Return the 1-based line number of character offset `pos` in `src`.
67
+
68
+ Hooks call this once per match, so counting LFs from the start each time
69
+ is quadratic on a large file (a 12 MB file spent most of its time here).
70
+ Instead the LF offsets are found once per text and bisected; a text
71
+ changed by an auto-fix is a new string and gets its own offsets. `pos` is
72
+ meant to be a match offset; one below 0 gives line 1 and one past the end
73
+ the last line.
74
+ """
75
+ return bisect.bisect_left(_newline_offsets(src), pos) + 1
76
+
77
+
78
+ def discover_bes_files(root=".", extensions=(".bes",)):
79
+ """Return the files under `root` ending in `extensions`, sorted.
80
+
81
+ Hidden directories and DISCOVERY_SKIP_DIRS are pruned from the walk.
82
+ """
83
+ root = os.path.normpath(root)
84
+ found = []
85
+ for dirpath, dirnames, filenames in os.walk(root):
86
+ dirnames[:] = [
87
+ d
88
+ for d in dirnames
89
+ if not d.startswith(".") and d not in DISCOVERY_SKIP_DIRS
90
+ ]
91
+ for name in filenames:
92
+ if name.endswith(extensions):
93
+ found.append(os.path.join(dirpath, name))
94
+ return sorted(found)
95
+
96
+
97
+ def report(results, errors_only=False):
98
+ """Print every fix and issue in `results`; return (issues, warnings, fixes).
99
+
100
+ `results` is a list of (path, issues, fixed) tuples, each of `issues` and
101
+ `fixed` a list of (lineno, code, message). A code starting with W is a
102
+ warning; any other is an error. With `errors_only`, warnings are neither
103
+ printed nor counted.
104
+ """
105
+ issue_count = 0
106
+ warning_count = 0
107
+ fix_count = 0
108
+ for path, issues, fixed in results:
109
+ for line, check_id, message in fixed:
110
+ fix_count += 1
111
+ print(f"{path}:{line}: [{check_id}] auto-fixed: {message}")
112
+ for line, check_id, message in issues:
113
+ if check_id.startswith("W"):
114
+ if errors_only:
115
+ continue
116
+ warning_count += 1
117
+ print(f"{path}:{line}: [{check_id}] warning: {message}")
118
+ else:
119
+ issue_count += 1
120
+ print(f"{path}:{line}: [{check_id}] {message}")
121
+ return issue_count, warning_count, fix_count
122
+
123
+
124
+ def _bundled_schemas(validate_bes_xml):
125
+ """Return the schema files validate_bes_xml ships with, not cwd ones.
126
+
127
+ Upstream's SCHEMA_FILES also holds any *.xsd in the current folder, which
128
+ would let a repo-local Foo.xsd make <Foo> checkable, or a repo-local
129
+ BES.xsd stand in for the real one.
130
+ """
131
+ bundled = os.path.join(
132
+ os.path.dirname(os.path.realpath(validate_bes_xml.__file__)), "schemas"
133
+ )
134
+ return [
135
+ path
136
+ for path in validate_bes_xml.SCHEMA_FILES
137
+ if os.path.dirname(os.path.realpath(path)) == bundled
138
+ ]
139
+
140
+
141
+ _schema_check_warned = [] # non-empty once the warning below has been printed
142
+
143
+
144
+ def _warn_schema_check_unavailable():
145
+ """Say, once per run, that auto-fixes are written without a schema check.
146
+
147
+ It goes to stderr so the `path:line: [CODE]` report on stdout stays clean.
148
+ """
149
+ if _schema_check_warned:
150
+ return
151
+ _schema_check_warned.append(True)
152
+ print(
153
+ "warning: BES.xsd schema check unavailable, so auto-fixes are written "
154
+ 'unchecked; install "validate_bes_xml>=2.4.1" to enable it',
155
+ file=sys.stderr,
156
+ )
157
+
158
+
159
+ def schema_errors(raw, path=None):
160
+ """Validate BES XML bytes against the schema bes-schema-validate would pick.
161
+
162
+ `path`, if given, is the file's name; validate_bes_xml uses it only for
163
+ its extension (.ojo -> BESOJO.xsd, .BESDomain -> BESDomain.xsd). Returns a
164
+ list of "Line N: message" strings, empty when valid. Returns None when no
165
+ bundled schema applies, meaning validity is unknown. validate_bes_xml
166
+ (and so lxml) is imported only here: importing it scans and compiles every
167
+ schema, which only a fix that writes needs. When it is not installed (the
168
+ stdlib-only bes-conventions-check run as a bare script, say), validity is
169
+ unknown too: a warning says so once, and the fix is written unchecked.
170
+ """
171
+ try:
172
+ import validate_bes_xml # pylint: disable=import-outside-toplevel
173
+ except ImportError:
174
+ _warn_schema_check_unavailable()
175
+ return None
176
+
177
+ schemas = _bundled_schemas(validate_bes_xml)
178
+ if not schemas: # an empty list would make upstream fall back to the cwd
179
+ return None
180
+ result = validate_bes_xml.validate_bes(
181
+ xml=raw, filename=path, schema_pathnames=schemas
182
+ )
183
+ if result:
184
+ return []
185
+ # compare against the value: every Status member is a non-empty str, so
186
+ # testing `result.status` for truth would always pass.
187
+ if result.status == "no_schema":
188
+ return None
189
+ # an error with no line number (an unreadable file, say) is just its message
190
+ return [
191
+ message if line is None else f"Line {line}: {message}"
192
+ for line, message in result.errors
193
+ ]
194
+
195
+
196
+ def schema_regression(original, new, path=None):
197
+ """Return the schema errors an edit introduced, or [] if it introduced none.
198
+
199
+ `original` and `new` are a file's bytes before and after an auto-fix. Only
200
+ a valid-to-invalid change counts: a file that already failed validation
201
+ is bes-schema-validate's to report, and blocking every fix to it would
202
+ help nobody. An unknown schema (see schema_errors) also counts as none.
203
+ """
204
+ if new == original:
205
+ return []
206
+ errors = schema_errors(new, path)
207
+ if not errors:
208
+ return []
209
+ return errors if schema_errors(original, path) == [] else []
210
+
211
+
212
+ class SchemaGuard:
213
+ """Accept a file's auto-fix passes one at a time, keeping it BES.xsd-valid.
214
+
215
+ Start it with the file's text, then hand each fix pass's result to
216
+ apply(). A pass that would turn a schema-valid file invalid is held back:
217
+ its fixes are dropped and one issue is recorded under `code` naming them.
218
+ The passes before and after it still apply, so one bad fix does not cost
219
+ the others. A file that already fails validation is never held back,
220
+ since bes-schema-validate reports it regardless -- but once a pass makes
221
+ it valid, the passes after that are checked like any other. Line endings
222
+ do not affect validity, so the text is checked in whatever form it is
223
+ given.
224
+
225
+ Afterwards `src` is the accepted text, `fixed` the accepted fixes, and
226
+ `refused` the issues to report.
227
+ """
228
+
229
+ def __init__(self, src, code, path=None, validate=True):
230
+ self.src = src
231
+ self.fixed = []
232
+ self.refused = []
233
+ self._code = code
234
+ self._path = path
235
+ self._validate = validate
236
+ self._valid = None # is the accepted text schema-valid? (checked lazily)
237
+
238
+ def _errors(self, new_src):
239
+ """Schema errors `new_src` would introduce over the accepted text."""
240
+ if self._valid is None:
241
+ self._valid = schema_errors(self.src.encode("utf-8"), self._path) == []
242
+ if not self._valid:
243
+ return []
244
+ return schema_errors(new_src.encode("utf-8"), self._path) or []
245
+
246
+ def apply(self, new_src, fixes):
247
+ """Take one pass's rewritten text and its fixes; return True if kept."""
248
+ if self._validate and new_src != self.src:
249
+ errors = self._errors(new_src)
250
+ if errors:
251
+ codes = ", ".join(sorted({code for _line, code, _msg in fixes}))
252
+ self.refused.append(
253
+ (
254
+ 1,
255
+ self._code,
256
+ (
257
+ f"auto-fix ({codes or 'unreported'}) not written: it "
258
+ "would make the file fail BES.xsd validation "
259
+ f"({errors[0]}); fix by hand, or --disable "
260
+ f"{self._code} to write it anyway"
261
+ ),
262
+ )
263
+ )
264
+ return False
265
+ if self._valid is False and new_src != self.src:
266
+ # an invalid (or unknown) file may have just been made valid:
267
+ # re-check before the next pass rather than wave it through
268
+ self._valid = None
269
+ self.src = new_src
270
+ self.fixed += fixes
271
+ return True