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.
- {pre_commit_bigfix-2.0.1/pre_commit_bigfix.egg-info → pre_commit_bigfix-2.1.0}/PKG-INFO +13 -2
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/README.md +12 -1
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix/__init__.py +1 -1
- pre_commit_bigfix-2.1.0/pre_commit_bigfix/run_all.py +573 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0/pre_commit_bigfix.egg-info}/PKG-INFO +13 -2
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/tests/test_run_all.py +340 -0
- pre_commit_bigfix-2.0.1/pre_commit_bigfix/run_all.py +0 -305
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/LICENSE +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/MANIFEST.in +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix/bes_actionscript_lint_schclass.py +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix/bes_actionscript_validate_prefetch.py +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix/bes_actionscript_validate_script.py +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix/bes_common.py +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix/bes_conventions_check.py +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix/bes_relevance_convert_group.py +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix/bes_relevance_lint.py +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix/bes_schema_validate.py +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix/bes_siterepo_validate_layout.py +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix/schclass.py +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix/schclass_data/ExpandedActionScript.schclass +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix/schclass_data/bigfix_overrides.schclass +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix/schclass_tokenizer.py +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix.egg-info/SOURCES.txt +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix.egg-info/dependency_links.txt +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix.egg-info/entry_points.txt +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix.egg-info/requires.txt +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pre_commit_bigfix.egg-info/top_level.txt +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/pyproject.toml +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/setup.cfg +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/setup.py +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/tests/test_bes_actionscript_lint_schclass.py +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/tests/test_bes_actionscript_validate_prefetch.py +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/tests/test_bes_actionscript_validate_script.py +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/tests/test_bes_common.py +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/tests/test_bes_conventions_check.py +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/tests/test_bes_relevance_convert_group.py +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/tests/test_bes_relevance_lint.py +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/tests/test_bes_schema_validate.py +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/tests/test_bes_siterepo_validate_layout.py +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/tests/test_hook_declarations.py +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/tests/test_package_metadata.py +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/tests/test_release_workflow.py +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/tests/test_schclass.py +0 -0
- {pre_commit_bigfix-2.0.1 → pre_commit_bigfix-2.1.0}/tests/test_schclass_tokenizer.py +0 -0
- {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
|
|
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
|
|
@@ -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
|
|
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
|