pre-commit-bigfix 2.0.1__tar.gz → 2.1.0__tar.gz

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.
Files changed (45) hide show
  1. {pre_commit_bigfix-2.0.1/pre_commit_bigfix.egg-info → pre_commit_bigfix-2.1.0}/PKG-INFO +13 -2
  2. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/README.md +12 -1
  3. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix/__init__.py +1 -1
  4. pre_commit_bigfix-2.1.0/pre_commit_bigfix/run_all.py +573 -0
  5. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0/pre_commit_bigfix.egg-info}/PKG-INFO +13 -2
  6. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/tests/test_run_all.py +340 -0
  7. pre_commit_bigfix-2.0.1/pre_commit_bigfix/run_all.py +0 -305
  8. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/LICENSE +0 -0
  9. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/MANIFEST.in +0 -0
  10. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix/bes_actionscript_lint_schclass.py +0 -0
  11. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix/bes_actionscript_validate_prefetch.py +0 -0
  12. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix/bes_actionscript_validate_script.py +0 -0
  13. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix/bes_common.py +0 -0
  14. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix/bes_conventions_check.py +0 -0
  15. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix/bes_relevance_convert_group.py +0 -0
  16. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix/bes_relevance_lint.py +0 -0
  17. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix/bes_schema_validate.py +0 -0
  18. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix/bes_siterepo_validate_layout.py +0 -0
  19. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix/schclass.py +0 -0
  20. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix/schclass_data/ExpandedActionScript.schclass +0 -0
  21. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix/schclass_data/bigfix_overrides.schclass +0 -0
  22. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix/schclass_tokenizer.py +0 -0
  23. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix.egg-info/SOURCES.txt +0 -0
  24. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix.egg-info/dependency_links.txt +0 -0
  25. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix.egg-info/entry_points.txt +0 -0
  26. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix.egg-info/requires.txt +0 -0
  27. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix.egg-info/top_level.txt +0 -0
  28. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pyproject.toml +0 -0
  29. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/setup.cfg +0 -0
  30. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/setup.py +0 -0
  31. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/tests/test_bes_actionscript_lint_schclass.py +0 -0
  32. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/tests/test_bes_actionscript_validate_prefetch.py +0 -0
  33. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/tests/test_bes_actionscript_validate_script.py +0 -0
  34. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/tests/test_bes_common.py +0 -0
  35. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/tests/test_bes_conventions_check.py +0 -0
  36. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/tests/test_bes_relevance_convert_group.py +0 -0
  37. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/tests/test_bes_relevance_lint.py +0 -0
  38. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/tests/test_bes_schema_validate.py +0 -0
  39. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/tests/test_bes_siterepo_validate_layout.py +0 -0
  40. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/tests/test_hook_declarations.py +0 -0
  41. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/tests/test_package_metadata.py +0 -0
  42. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/tests/test_release_workflow.py +0 -0
  43. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/tests/test_schclass.py +0 -0
  44. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/tests/test_schclass_tokenizer.py +0 -0
  45. {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/tests/test_schema_guard.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pre-commit-bigfix
3
- Version: 2.0.1
3
+ Version: 2.1.0
4
4
  Summary: pre-commit hooks for BigFix content
5
5
  Home-page: https://github.com/jgstew/pre-commit-bigfix
6
6
  Author: JGStew
@@ -90,7 +90,18 @@ line per hook is printed at the end. The exit code is 1 if any hook fails, and
90
90
  - Symlinked files found while searching are skipped, as pre-commit skips
91
91
  them. A file named on the command line that no hook checks is reported.
92
92
  - `bes-siterepo-validate-layout` runs once in each git repo that holds a given
93
- path, and is skipped when none does.
93
+ path, and is skipped when none does. It is also skipped, and not counted
94
+ as a failure, in a repo that shows no sign of being a site: git tracks no
95
+ `site.xml` or `siteConfig.xml` file and no `Fixlets`, `NonClientFiles` or
96
+ `OtherFiles` folder at its top (names compared ignoring case).
97
+ - A hook with more than 20 results is summarized instead of listed: a count
98
+ per code, and the files with the most results. The summary table at the end
99
+ shows each hook's result count.
100
+ - `--log-file [FILE]` writes every hook's full output to FILE (default
101
+ `pre-commit-bigfix-results.log`), replacing it if it exists. This is the
102
+ only file the command writes, and it will not replace a given path or a
103
+ file some hook checks. Give paths to check before `--log-file`, or use
104
+ `--log-file=FILE`, so a path is not taken as FILE.
94
105
  - `--skip HOOK` (repeatable) leaves a hook out.
95
106
 
96
107
  ## Hooks
@@ -58,7 +58,18 @@ line per hook is printed at the end. The exit code is 1 if any hook fails, and
58
58
  - Symlinked files found while searching are skipped, as pre-commit skips
59
59
  them. A file named on the command line that no hook checks is reported.
60
60
  - `bes-siterepo-validate-layout` runs once in each git repo that holds a given
61
- path, and is skipped when none does.
61
+ path, and is skipped when none does. It is also skipped, and not counted
62
+ as a failure, in a repo that shows no sign of being a site: git tracks no
63
+ `site.xml` or `siteConfig.xml` file and no `Fixlets`, `NonClientFiles` or
64
+ `OtherFiles` folder at its top (names compared ignoring case).
65
+ - A hook with more than 20 results is summarized instead of listed: a count
66
+ per code, and the files with the most results. The summary table at the end
67
+ shows each hook's result count.
68
+ - `--log-file [FILE]` writes every hook's full output to FILE (default
69
+ `pre-commit-bigfix-results.log`), replacing it if it exists. This is the
70
+ only file the command writes, and it will not replace a given path or a
71
+ file some hook checks. Give paths to check before `--log-file`, or use
72
+ `--log-file=FILE`, so a path is not taken as FILE.
62
73
  - `--skip HOOK` (repeatable) leaves a hook out.
63
74
 
64
75
  ## Hooks
@@ -1,3 +1,3 @@
1
1
  """To support running this directly as a python module."""
2
2
 
3
- __version__ = "2.0.1"
3
+ __version__ = "2.1.0"
@@ -0,0 +1,573 @@
1
+ #!/usr/bin/env python3
2
+ """Run every pre-commit-bigfix hook over a folder, without pre-commit.
3
+
4
+ uvx pre-commit-bigfix [PATH ...]
5
+
6
+ This is the `pre-commit-bigfix` command. It finds the files under each PATH
7
+ (default: the current folder), gives each hook the ones its `files` pattern in
8
+ .pre-commit-hooks.yaml matches, with that hook's default `args`, and prints a
9
+ pass/fail line per hook. The exit code is 1 when any hook fails, and 2 for
10
+ a PATH that does not exist or a bad option.
11
+
12
+ It is read-only. Run by pre-commit, several hooks fix files in place by
13
+ default; here each one is told not to (--auto-fix=no, or --check for the
14
+ hooks that convert or rename), so fixable problems are reported instead.
15
+ To apply the fixes, run the hook itself, or use pre-commit.
16
+
17
+ Hidden folders (a name starting with `.`, or the hidden flag on macOS and
18
+ Windows) are not searched unless --include-hidden is given, and __pycache__
19
+ and node_modules never are; a PATH that names a folder is searched anyway.
20
+ Symlinked files found while searching are skipped, as pre-commit skips them.
21
+ A named file that no hook checks is reported on stderr.
22
+
23
+ bes-siterepo-validate-layout checks a whole repo from `git ls-files`, so it
24
+ runs once in each git repo that holds a PATH, and is skipped when none does.
25
+ It is also skipped in a repo that shows no sign of being a site: git tracks
26
+ no site.xml or siteConfig.xml file and no Fixlets, NonClientFiles or
27
+ OtherFiles folder at its top (names compared ignoring case).
28
+
29
+ A hook that reports more than 20 results (`path:line: message` or
30
+ `path: message` lines) has them summarized, counted by code and by file,
31
+ instead of listed. --log-file writes the full output of every hook to FILE
32
+ (default pre-commit-bigfix-results.log), replacing it if it exists; that is
33
+ the only file this command writes. It will not replace a PATH or a file some
34
+ hook checks. Give PATHs before --log-file, or use --log-file=FILE, so a PATH
35
+ is not taken as FILE.
36
+
37
+ A hook with no matching files is skipped rather than run with no file names:
38
+ run that way, most hooks would search the current folder themselves.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import argparse
44
+ import contextlib
45
+ import importlib
46
+ import io
47
+ import os
48
+ import re
49
+ import stat
50
+ import subprocess
51
+ import sys
52
+ import traceback
53
+ from collections import Counter
54
+ from typing import NamedTuple
55
+
56
+ from . import bes_common
57
+
58
+
59
+ class Hook(NamedTuple):
60
+ """One hook from .pre-commit-hooks.yaml, as the runner calls it."""
61
+
62
+ id: str
63
+ module: str # in this package; its main(argv) is the console script
64
+ files: str | None # the hook's `files` regex; None: it lists its own
65
+ args: tuple[str, ...] = () # the hook's default `args`
66
+ read_only: tuple[str, ...] = () # what keeps it from writing
67
+
68
+ def load(self):
69
+ """Import the hook's module (only when it runs, as some are heavy)."""
70
+ return importlib.import_module(f"{__package__}.{self.module}")
71
+
72
+
73
+ # Same order, ids, `files` and `args` as .pre-commit-hooks.yaml (minus aliases);
74
+ # tests/test_run_all.py keeps the two in step.
75
+ HOOKS = (
76
+ Hook(
77
+ "bes-schema-validate",
78
+ "bes_schema_validate",
79
+ r"\.(bes|ojo)$|(^|/)(siteConfig|site|digest)\.xml$"
80
+ r"|(^|/)Signatures/[^/]+\.xml$",
81
+ ),
82
+ Hook(
83
+ "bes-conventions-check",
84
+ "bes_conventions_check",
85
+ r"\.bes$",
86
+ read_only=("--auto-fix=no",),
87
+ ),
88
+ Hook(
89
+ "bes-actionscript-lint-schclass",
90
+ "bes_actionscript_lint_schclass",
91
+ r"\.bes$",
92
+ read_only=("--auto-fix=no",),
93
+ ),
94
+ Hook(
95
+ "bes-actionscript-validate-prefetch",
96
+ "bes_actionscript_validate_prefetch",
97
+ r"\.bes$",
98
+ # --auto-fix-network is already off by default; say so anyway
99
+ read_only=("--auto-fix=no", "--auto-fix-network=no"),
100
+ ),
101
+ Hook(
102
+ "bes-actionscript-validate-script",
103
+ "bes_actionscript_validate_script",
104
+ r"\.bes$",
105
+ read_only=("--auto-fix=no",),
106
+ ),
107
+ Hook(
108
+ "bes-relevance-lint",
109
+ "bes_relevance_lint",
110
+ r"\.(bes|bes\.xml|ojo|besrpt|beswrpt|webreport|bsr|rel)$",
111
+ args=("--disable", "W601"),
112
+ ),
113
+ Hook(
114
+ "bes-relevance-convert-group",
115
+ "bes_relevance_convert_group",
116
+ r"\.bes$",
117
+ read_only=("--check",),
118
+ ),
119
+ Hook(
120
+ "bes-siterepo-validate-layout",
121
+ "bes_siterepo_validate_layout",
122
+ None,
123
+ read_only=("--check",),
124
+ ),
125
+ )
126
+
127
+ # any file some hook checks; anything else is not worth listing
128
+ ANY_HOOK_RE = re.compile("|".join(f"(?:{h.files})" for h in HOOKS if h.files))
129
+
130
+ # a hook with more results than this has them summarized, not listed
131
+ SUMMARY_OVER = 20
132
+ # files and folders listed in the summary, most results first
133
+ SUMMARY_TOP_FILES = 5
134
+ DEFAULT_LOG_FILE = "pre-commit-bigfix-results.log"
135
+
136
+ # one result line: `path:line: message`, maybe `[CODE] ` first; a Windows
137
+ # drive letter (C:\...) does not end the path, as `:\d+: ` must follow it
138
+ RESULT_RE = re.compile(r"^(?P<path>.+?):\d+: (?:\[(?P<code>[A-Z]+\d+)\] )?")
139
+
140
+ # a site repo's top folder holds one of these, compared lowercase. The folder
141
+ # names are bes_siterepo_validate_layout.DEFAULT_DIRS, copied so the layout
142
+ # module is only imported when it runs.
143
+ SITE_FILES = frozenset({"site.xml", "siteconfig.xml"})
144
+ SITE_DIRS = frozenset({"fixlets", "nonclientfiles", "otherfiles"})
145
+
146
+
147
+ def _is_hidden(path, name):
148
+ """True for a dot folder, or one with the macOS or Windows hidden flag."""
149
+ if name.startswith("."):
150
+ return True
151
+ try:
152
+ st = os.lstat(path)
153
+ except OSError:
154
+ return False
155
+ # st_flags exists on BSD/macOS and st_file_attributes on Windows; each
156
+ # constant is missing (so 0) on the other platforms
157
+ if getattr(st, "st_flags", 0) & getattr(stat, "UF_HIDDEN", 0):
158
+ return True
159
+ return bool(
160
+ getattr(st, "st_file_attributes", 0) & getattr(stat, "FILE_ATTRIBUTE_HIDDEN", 0)
161
+ )
162
+
163
+
164
+ def _match_path(path):
165
+ """The string a hook's `files` regex is searched in.
166
+
167
+ pre-commit searches the repo-relative path, so `(^|/)Signatures/x.xml`
168
+ sees the folder. The path as given may not hold it (`x.xml`, run from
169
+ inside Signatures/), so the absolute path is used instead.
170
+ """
171
+ return os.path.abspath(path).replace(os.sep, "/")
172
+
173
+
174
+ def discover(paths, include_hidden=False):
175
+ """Return the files under `paths` that some hook checks, sorted, each once.
176
+
177
+ A file named in `paths` is kept even if no hook checks it. Folders are
178
+ walked, skipping symlinked files (pre-commit does not pass them to these
179
+ hooks), bes_common.DISCOVERY_SKIP_DIRS and, unless `include_hidden`, hidden
180
+ folders; a folder named in `paths` is walked even if it is hidden.
181
+ """
182
+ found = set()
183
+ for top in paths:
184
+ if not os.path.isdir(top):
185
+ found.add(os.path.normpath(top))
186
+ continue
187
+ for dirpath, dirnames, filenames in os.walk(top):
188
+ dirnames[:] = [
189
+ d
190
+ for d in dirnames
191
+ if d not in bes_common.DISCOVERY_SKIP_DIRS
192
+ and (include_hidden or not _is_hidden(os.path.join(dirpath, d), d))
193
+ ]
194
+ for name in filenames:
195
+ path = os.path.normpath(os.path.join(dirpath, name))
196
+ if not os.path.islink(path) and ANY_HOOK_RE.search(_match_path(path)):
197
+ found.add(path)
198
+ return sorted(found)
199
+
200
+
201
+ def repo_roots(paths):
202
+ """Return the top folders of the git repos holding `paths`, each once."""
203
+ roots = []
204
+ for path in paths:
205
+ folder = path if os.path.isdir(path) else os.path.dirname(path) or "."
206
+ try:
207
+ result = subprocess.run(
208
+ ["git", "rev-parse", "--show-toplevel"],
209
+ cwd=folder,
210
+ capture_output=True,
211
+ check=False,
212
+ )
213
+ except OSError: # git not installed
214
+ return []
215
+ root = os.fsdecode(result.stdout).strip()
216
+ if result.returncode == 0 and root and root not in roots:
217
+ roots.append(root)
218
+ return roots
219
+
220
+
221
+ def looks_like_site(root):
222
+ """True if git repo `root` tracks a site file or site folder at its top.
223
+
224
+ See SITE_FILES and SITE_DIRS. Only tracked files count, as the layout hook
225
+ sees only what `git ls-files` lists.
226
+ """
227
+ try:
228
+ result = subprocess.run(
229
+ ["git", "ls-files", "-z"], cwd=root, capture_output=True, check=False
230
+ )
231
+ except OSError: # git not installed
232
+ return False
233
+ if result.returncode:
234
+ return False
235
+ for path in os.fsdecode(result.stdout).split("\0"):
236
+ top, slash, _rest = path.partition("/")
237
+ if top.lower() in (SITE_DIRS if slash else SITE_FILES):
238
+ return True
239
+ return False
240
+
241
+
242
+ def _path_prefix(line, paths):
243
+ """The one of `paths` that `line` starts with, followed by `: `; or None."""
244
+ end = line.find(": ")
245
+ while end != -1: # a path may hold ": " itself
246
+ if line[:end] in paths:
247
+ return line[:end]
248
+ end = line.find(": ", end + 1)
249
+ return None
250
+
251
+
252
+ def split_output(text, paths=()):
253
+ """Split a hook's output into its results and its other lines.
254
+
255
+ Returns ([(path, code or None), ...], [other line, ...]). A result is a
256
+ `path:line: message` line, or `path: message` for one of the files the
257
+ hook was given (`paths`), as bes-schema-validate prints when it has no
258
+ line. The indented lines after a result (a suggested fix) belong to it;
259
+ any other line (a count such as `7 warning(s).`, a crash) is kept as is.
260
+ """
261
+ results, other = [], []
262
+ in_result = False
263
+ paths = frozenset(paths)
264
+ for line in text.splitlines():
265
+ match = RESULT_RE.match(line)
266
+ path = None if match else _path_prefix(line, paths)
267
+ if match or path:
268
+ results.append((match["path"], match["code"]) if match else (path, None))
269
+ in_result = True
270
+ elif in_result and line[:1].isspace():
271
+ continue
272
+ else:
273
+ other.append(line)
274
+ in_result = False
275
+ return results, other
276
+
277
+
278
+ def summarize(results, other, log_path):
279
+ """The text printed in place of a hook's output when it has many results."""
280
+ files = Counter(path for path, _code in results)
281
+ lines = [
282
+ (
283
+ f"{len(results)} results in {len(files)} file(s), more than"
284
+ f" {SUMMARY_OVER}, so they are summarized. By code:"
285
+ )
286
+ ]
287
+ codes = Counter(code or "(no code)" for _path, code in results)
288
+ width = max(len(code) for code in codes)
289
+ lines += [f" {code:<{width}} {n}" for code, n in codes.most_common()]
290
+ lines.append("Most results:")
291
+ lines += [f" {n:>5} {path}" for path, n in files.most_common(SUMMARY_TOP_FILES)]
292
+ lines += other
293
+ if log_path:
294
+ lines.append(f"All of them are in {log_path}.")
295
+ else:
296
+ lines.append(
297
+ "Run again with --log-file to write all of them to"
298
+ f" {DEFAULT_LOG_FILE} (or --log-file=FILE)."
299
+ )
300
+ return "\n".join(lines) + "\n"
301
+
302
+
303
+ class _Tee:
304
+ """A text stream that writes to each of `streams`.
305
+
306
+ Not an io.TextIOBase: its __del__ would flush the streams, by then
307
+ perhaps closed (the log).
308
+ """
309
+
310
+ def __init__(self, *streams):
311
+ self.streams = streams
312
+
313
+ def write(self, text):
314
+ for stream in self.streams:
315
+ stream.write(text)
316
+ return len(text)
317
+
318
+ def flush(self):
319
+ for stream in self.streams:
320
+ stream.flush()
321
+
322
+
323
+ class Report:
324
+ """Where the output goes: stdout, and the --log-file if one is given.
325
+
326
+ The log gets every hook's full output; stdout gets it too, unless the
327
+ hook has more than SUMMARY_OVER results, when it gets summarize() instead.
328
+ """
329
+
330
+ def __init__(self, log=None, log_path=None):
331
+ self.log = log
332
+ self.log_path = log_path
333
+
334
+ def show(self, text):
335
+ """Write `text` to stdout only."""
336
+ sys.stdout.write(text)
337
+ sys.stdout.flush()
338
+
339
+ def write(self, text):
340
+ """Write `text` to stdout and the log."""
341
+ self.show(text)
342
+ if self.log:
343
+ self.log.write(text)
344
+
345
+ def _capture(self, hook, argv, folder):
346
+ """Run `hook` in `folder` (None: here) with stdout caught; return (code,
347
+ text).
348
+ """
349
+ caught = io.StringIO()
350
+ # stderr (warnings, a crash's traceback) is not summarized: it is
351
+ # shown as it comes, and copied to the log
352
+ err = (
353
+ contextlib.redirect_stderr(_Tee(sys.stderr, self.log))
354
+ if self.log
355
+ else contextlib.nullcontext()
356
+ )
357
+ here = os.getcwd()
358
+ try:
359
+ with contextlib.redirect_stdout(caught), err:
360
+ if folder: # a whole-repo hook finds its repo from the current folder
361
+ os.chdir(folder)
362
+ try:
363
+ code = _call(hook, argv)
364
+ finally:
365
+ os.chdir(here)
366
+ except BaseException: # Ctrl-C: _call already catches the rest
367
+ self.write(caught.getvalue()) # what the hook printed before it
368
+ raise
369
+ return code, caught.getvalue()
370
+
371
+ def run(self, hook, argv, folders=(None,), paths=()):
372
+ """Run `hook` once in each of `folders`; return (worst code, result count).
373
+
374
+ `paths` are the files the hook is given, for split_output(). Each run
375
+ goes to the log as it ends. Stdout gets each run under its own
376
+ `=== hook (folder)` line, or, when all of them together have more than
377
+ SUMMARY_OVER results, one summary of them.
378
+ """
379
+ headers = [
380
+ f"=== {hook.id}" + (f" ({folder})" if folder else "") + "\n"
381
+ for folder in folders
382
+ ]
383
+ if len(folders) == 1:
384
+ self.show(headers[0]) # before it runs, to show which hook is busy
385
+ worst, results, other, texts = 0, [], [], []
386
+ for header, folder in zip(headers, folders):
387
+ if self.log:
388
+ self.log.write(header)
389
+ code, text = self._capture(hook, argv, folder)
390
+ if self.log:
391
+ self.log.write(text)
392
+ worst = max(worst, code)
393
+ found, rest = split_output(text, paths)
394
+ results += found
395
+ other += rest
396
+ texts.append(text)
397
+ if len(results) > SUMMARY_OVER:
398
+ if len(folders) > 1:
399
+ self.show(f"=== {hook.id} ({', '.join(folders)})\n")
400
+ self.show(summarize(results, other, self.log_path))
401
+ else:
402
+ for header, text in zip(headers, texts):
403
+ self.show(text if len(folders) == 1 else header + text)
404
+ return worst, len(results)
405
+
406
+
407
+ def _call(hook, argv):
408
+ """Run `hook`'s main(argv); return its exit code, a crash counting as 1."""
409
+ try:
410
+ code = hook.load().main(argv)
411
+ except SystemExit as exc: # argparse errors, or a hook that exits
412
+ code = exc.code
413
+ except Exception: # noqa: BLE001 - one broken hook must not hide the rest
414
+ traceback.print_exc()
415
+ print(f"{hook.id}: crashed")
416
+ return 1
417
+ if code is None:
418
+ return 0
419
+ return code if isinstance(code, int) else 1
420
+
421
+
422
+ def run_hook(hook, files, roots, report=None):
423
+ """Run one hook read-only; return (status, code, result count).
424
+
425
+ A file hook gets the `files` its pattern matches; a whole-repo hook runs in
426
+ each of the git repos `roots` that looks_like_site(). status is "passed",
427
+ "failed" or "skipped: <why>".
428
+ """
429
+ report = report or Report()
430
+ if hook.files is None:
431
+ if not roots:
432
+ return "skipped: not a git repo", 0, 0
433
+ sites = [root for root in roots if looks_like_site(root)]
434
+ if not sites:
435
+ return "skipped: not a site repo", 0, 0
436
+ code, count = report.run(hook, [*hook.args, *hook.read_only], sites)
437
+ else:
438
+ pattern = re.compile(hook.files)
439
+ matched = [path for path in files if pattern.search(_match_path(path))]
440
+ if not matched:
441
+ return "skipped: no files", 0, 0
442
+ # `--` so a file named like an option (-h.bes) is still a file name
443
+ argv = [*hook.args, *hook.read_only, "--", *matched]
444
+ code, count = report.run(hook, argv, paths=matched)
445
+ return ("failed" if code else "passed"), code, count
446
+
447
+
448
+ def build_parser():
449
+ parser = argparse.ArgumentParser(
450
+ prog="pre-commit-bigfix",
451
+ description=__doc__,
452
+ formatter_class=argparse.RawDescriptionHelpFormatter,
453
+ )
454
+ parser.add_argument(
455
+ "paths",
456
+ nargs="*",
457
+ default=["."],
458
+ metavar="PATH",
459
+ help="files or folders to check (default: the current folder)",
460
+ )
461
+ parser.add_argument(
462
+ "--skip",
463
+ action="append",
464
+ default=[],
465
+ choices=[hook.id for hook in HOOKS],
466
+ metavar="HOOK",
467
+ help="a hook id not to run; repeatable",
468
+ )
469
+ parser.add_argument(
470
+ "--include-hidden",
471
+ action="store_true",
472
+ help="also search hidden folders (see above)",
473
+ )
474
+ parser.add_argument(
475
+ "--log-file",
476
+ nargs="?",
477
+ const=DEFAULT_LOG_FILE,
478
+ metavar="FILE",
479
+ help="write every hook's full output to FILE, replacing it"
480
+ f" (default FILE: {DEFAULT_LOG_FILE})",
481
+ )
482
+ return parser
483
+
484
+
485
+ def main(argv=None):
486
+ """Execution starts here; returns 1 if any hook failed, 2 for a bad PATH."""
487
+ args = build_parser().parse_args(argv)
488
+ # a file name the console cannot show (an ASCII or cp1252 console, or not
489
+ # UTF-8) is escaped rather than crash the run; the layout hook does this
490
+ # itself, but its stdout is a StringIO here
491
+ for stream in (sys.stdout, sys.stderr):
492
+ if hasattr(stream, "reconfigure"):
493
+ stream.reconfigure(errors="backslashreplace")
494
+
495
+ missing = [path for path in args.paths if not os.path.exists(path)]
496
+ if missing:
497
+ for path in missing:
498
+ print(f"pre-commit-bigfix: no such file or folder: {path}", file=sys.stderr)
499
+ return 2
500
+
501
+ files = discover(args.paths, include_hidden=args.include_hidden)
502
+ for path in args.paths:
503
+ if os.path.isfile(path) and not ANY_HOOK_RE.search(_match_path(path)):
504
+ print(f"{path}: not checked by any hook", file=sys.stderr)
505
+ roots = repo_roots(args.paths)
506
+
507
+ if not args.log_file:
508
+ return _run_all(args, files, roots, Report())
509
+ if _would_replace_input(args.log_file, args.paths):
510
+ print(
511
+ f"pre-commit-bigfix: --log-file {args.log_file} is a file to check,"
512
+ " not a log; give PATHs before --log-file, or --log-file=FILE",
513
+ file=sys.stderr,
514
+ )
515
+ return 2
516
+ try:
517
+ # "w": a log from an earlier run is replaced, not added to. Not a
518
+ # `with` here, so that only open()'s own error is caught
519
+ log = open( # noqa: SIM115 - closed by the `with log:` below
520
+ args.log_file, "w", encoding="utf-8", errors="backslashreplace"
521
+ )
522
+ except OSError as exc:
523
+ print(
524
+ f"pre-commit-bigfix: cannot write {args.log_file}: {exc}",
525
+ file=sys.stderr,
526
+ )
527
+ return 2
528
+ with log:
529
+ code = _run_all(args, files, roots, Report(log, args.log_file))
530
+ print(f"Full results written to {args.log_file}")
531
+ return code
532
+
533
+
534
+ def _would_replace_input(log_file, paths):
535
+ """True if `log_file` is a file some hook checks, or one of `paths`.
536
+
537
+ --log-file FILE takes the next argument when FILE is left out, so
538
+ `--log-file x.bes` would otherwise empty x.bes before it is checked.
539
+ """
540
+ if ANY_HOOK_RE.search(_match_path(log_file)):
541
+ return True
542
+ if not os.path.exists(log_file):
543
+ return False
544
+ for path in paths:
545
+ try:
546
+ if os.path.samefile(path, log_file):
547
+ return True
548
+ except OSError:
549
+ continue
550
+ return False
551
+
552
+
553
+ def _run_all(args, files, roots, report):
554
+ """Run every hook not skipped, then write the summary table; return the exit
555
+ code.
556
+ """
557
+ results = []
558
+ for hook in HOOKS:
559
+ if hook.id in args.skip:
560
+ results.append((hook.id, "skipped: --skip", 0, 0))
561
+ continue
562
+ results.append((hook.id, *run_hook(hook, files, roots, report)))
563
+
564
+ width = max(len(hook.id) for hook in HOOKS) + 3
565
+ report.write("\n")
566
+ for hook_id, status, _code, count in results:
567
+ found = f" ({count} result{'' if count == 1 else 's'})" if count else ""
568
+ report.write(f"{hook_id} {'.' * (width - len(hook_id))} {status}{found}\n")
569
+ return 1 if any(code for _id, _status, code, _count in results) else 0
570
+
571
+
572
+ if __name__ == "__main__":
573
+ sys.exit(main(sys.argv[1:]))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pre-commit-bigfix
3
- Version: 2.0.1
3
+ Version: 2.1.0
4
4
  Summary: pre-commit hooks for BigFix content
5
5
  Home-page: https://github.com/jgstew/pre-commit-bigfix
6
6
  Author: JGStew
@@ -90,7 +90,18 @@ line per hook is printed at the end. The exit code is 1 if any hook fails, and
90
90
  - Symlinked files found while searching are skipped, as pre-commit skips
91
91
  them. A file named on the command line that no hook checks is reported.
92
92
  - `bes-siterepo-validate-layout` runs once in each git repo that holds a given
93
- path, and is skipped when none does.
93
+ path, and is skipped when none does. It is also skipped, and not counted
94
+ as a failure, in a repo that shows no sign of being a site: git tracks no
95
+ `site.xml` or `siteConfig.xml` file and no `Fixlets`, `NonClientFiles` or
96
+ `OtherFiles` folder at its top (names compared ignoring case).
97
+ - A hook with more than 20 results is summarized instead of listed: a count
98
+ per code, and the files with the most results. The summary table at the end
99
+ shows each hook's result count.
100
+ - `--log-file [FILE]` writes every hook's full output to FILE (default
101
+ `pre-commit-bigfix-results.log`), replacing it if it exists. This is the
102
+ only file the command writes, and it will not replace a given path or a
103
+ file some hook checks. Give paths to check before `--log-file`, or use
104
+ `--log-file=FILE`, so a path is not taken as FILE.
94
105
  - `--skip HOOK` (repeatable) leaves a hook out.
95
106
 
96
107
  ## Hooks