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.
- pre_commit_bigfix/__init__.py +3 -0
- pre_commit_bigfix/bes_actionscript_lint_schclass.py +937 -0
- pre_commit_bigfix/bes_actionscript_validate_prefetch.py +1258 -0
- pre_commit_bigfix/bes_actionscript_validate_script.py +2871 -0
- pre_commit_bigfix/bes_common.py +271 -0
- pre_commit_bigfix/bes_conventions_check.py +3484 -0
- pre_commit_bigfix/bes_relevance_convert_group.py +407 -0
- pre_commit_bigfix/bes_relevance_lint.py +402 -0
- pre_commit_bigfix/bes_schema_validate.py +84 -0
- pre_commit_bigfix/bes_siterepo_validate_layout.py +1203 -0
- pre_commit_bigfix/run_all.py +305 -0
- pre_commit_bigfix/schclass.py +284 -0
- pre_commit_bigfix/schclass_data/ExpandedActionScript.schclass +412 -0
- pre_commit_bigfix/schclass_data/bigfix_overrides.schclass +43 -0
- pre_commit_bigfix/schclass_tokenizer.py +321 -0
- pre_commit_bigfix-2.0.1.dist-info/METADATA +821 -0
- pre_commit_bigfix-2.0.1.dist-info/RECORD +21 -0
- pre_commit_bigfix-2.0.1.dist-info/WHEEL +5 -0
- pre_commit_bigfix-2.0.1.dist-info/entry_points.txt +10 -0
- pre_commit_bigfix-2.0.1.dist-info/licenses/LICENSE +21 -0
- pre_commit_bigfix-2.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1203 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Check a BigFix Git External Custom Site repo's layout, file names and paths.
|
|
3
|
+
|
|
4
|
+
A Git External Custom Site ("repository site", BigFix 11.0.7+) is gathered by
|
|
5
|
+
the root server from a git repo, and one bad file can fail the whole sync. This
|
|
6
|
+
hook checks the site as a whole, so pre-commit runs it once per commit
|
|
7
|
+
(pass_filenames: false, always_run: true). The files it sees are those
|
|
8
|
+
`git ls-files` lists: untracked and ignored files do not count.
|
|
9
|
+
|
|
10
|
+
Each site root (--site-root, repeatable; default the repo root) may hold a
|
|
11
|
+
siteConfig.xml naming its folders (BesFileDir, NonClientFilesDir,
|
|
12
|
+
OtherFilesDir); the defaults are Fixlets, NonClientFiles and OtherFiles.
|
|
13
|
+
|
|
14
|
+
A root server may run Windows or RHEL, and one repo site may serve both, so
|
|
15
|
+
names and paths must be valid on both: the stricter limit always applies.
|
|
16
|
+
Folder names, the .bes extension and digest.xml are matched ignoring case,
|
|
17
|
+
as on Windows, so nothing goes unchecked, but a name that matches only that
|
|
18
|
+
way is a warning (W808), since RHEL is case-sensitive.
|
|
19
|
+
|
|
20
|
+
Checks (E-codes fail the hook; W-codes are advisory unless --strict):
|
|
21
|
+
|
|
22
|
+
E800 a .bes file in the .bes folder whose name does not start with a
|
|
23
|
+
numeric ID followed by a space or hyphen (`42- Name.bes`,
|
|
24
|
+
`42 Name.bes`); the server skips it
|
|
25
|
+
E801 two or more .bes files in one site share an ID, compared as
|
|
26
|
+
integers (0042 and 42 collide); each names the others
|
|
27
|
+
E802 a .bes file's server path is over the limit and the rename cannot
|
|
28
|
+
fix it: even `<ID> X.bes` is too long (the folders are), it has no
|
|
29
|
+
ID, or E805 is disabled. Renaming a folder changes its .fxf or
|
|
30
|
+
digest, so that is not done
|
|
31
|
+
E803 fix: zero padding in the ID (0042- X.bes -> 42- X.bes); done even
|
|
32
|
+
when the ID is a duplicate (E801), since the ID does not change
|
|
33
|
+
E804 fix: non-ASCII characters in the name, folded to ASCII
|
|
34
|
+
E805 fix: the server path is over the limit, or the name is over 255
|
|
35
|
+
bytes; renamed `<ID> <Title>.bes`
|
|
36
|
+
E806 a rename was skipped: the new name is taken, ignoring case (by a
|
|
37
|
+
tracked file, another planned rename or an untracked file), the
|
|
38
|
+
file has unstaged changes pre-commit set aside, the commit uses a
|
|
39
|
+
temporary index (git commit -a, git commit <paths>), or the
|
|
40
|
+
rename failed
|
|
41
|
+
E807 any other tracked file whose server path is over the limit, in or
|
|
42
|
+
outside the site roots (the server clones the whole repo); it is
|
|
43
|
+
not renamed, since actions may refer to it by name
|
|
44
|
+
E808 a file or folder name over 255 bytes in UTF-8 that the rename
|
|
45
|
+
cannot fix; a folder is reported once
|
|
46
|
+
W800 a .bes file outside the .bes folder; it is not gathered
|
|
47
|
+
W801 a .bes file directly in the .bes folder, or deeper than
|
|
48
|
+
<BesFileDir>/<fxf>/<digest>/ (server behavior undocumented)
|
|
49
|
+
W802 a file in the .bes folder other than .bes or digest.xml (an .xsd
|
|
50
|
+
too: bes-schema-validate already has the schemas)
|
|
51
|
+
W803 a folder siteConfig.xml renames does not exist (has no files); a
|
|
52
|
+
default name spelled out, as the template does, is not checked,
|
|
53
|
+
since those folders are optional
|
|
54
|
+
W804 a site with no content: no .bes files in the .bes folder and no
|
|
55
|
+
files in the NonClient or Other folders (server files alone pass)
|
|
56
|
+
W806 an ID outside 1..2147483647 (the range is undocumented)
|
|
57
|
+
W807 siteConfig.xml is not readable XML; the default folders are used
|
|
58
|
+
W808 a folder, .bes extension or digest.xml that matches the expected
|
|
59
|
+
name only when case is ignored
|
|
60
|
+
|
|
61
|
+
Path limit (Windows): the server clones the whole repo to
|
|
62
|
+
`<server-root>\\RepositorySiteGather\\Sites\\<repo>_<branch>_<8 hex>\\Source\\`
|
|
63
|
+
and each file's path relative to the REPO root (not the site root) follows
|
|
64
|
+
that. It must be at most --max-path (259: MAX_PATH 260 includes the NUL),
|
|
65
|
+
counted in UTF-16 units. <repo> is the git repo's name (--repo-name, default
|
|
66
|
+
the origin remote's, else the repo folder's), not the BigFix site name.
|
|
67
|
+
Over-long paths fail on 11.0.7 even with Windows long paths enabled. Only
|
|
68
|
+
this Windows layout is confirmed; the RHEL clone path is not, so it is
|
|
69
|
+
the one measured.
|
|
70
|
+
|
|
71
|
+
Name limit (Linux): each file and folder name must be at most NAME_MAX, 255
|
|
72
|
+
bytes in UTF-8. Linux's PATH_MAX (4096 bytes) cannot be reached once the
|
|
73
|
+
Windows limit holds, so it has no check of its own.
|
|
74
|
+
|
|
75
|
+
Auto-fix (the default; --check is a dry run that reports and changes
|
|
76
|
+
nothing): each .bes file in the .bes folder is renamed at most once, combining
|
|
77
|
+
E803, E804 and, if the path or name is still too long, E805. E805 builds
|
|
78
|
+
`<ID> <Title><extension>` (no hyphen) from the first content object's
|
|
79
|
+
<Title> (or the current name without its ID, if the Title has no letter or
|
|
80
|
+
digit), made ASCII and Windows-safe, cut to fit both limits, with trailing
|
|
81
|
+
spaces, dots and hyphens dropped. A name that already fits is left alone,
|
|
82
|
+
even when it differs from <Title>. The ID never changes and no file moves
|
|
83
|
+
between folders. Files without an ID are not given one: --assign-ids is
|
|
84
|
+
deferred (assigning IDs is a policy decision; CommunityContent uses a
|
|
85
|
+
counter file, and max+1 would reuse the IDs of deleted content).
|
|
86
|
+
|
|
87
|
+
How files are renamed: a tracked file with `git mv`, anything else (only a
|
|
88
|
+
file list a caller passes in) with a plain rename. git mv stages the
|
|
89
|
+
rename, so the next commit carries both the new name and the removal of the
|
|
90
|
+
old one; a plain rename would leave an unstaged deletion plus an untracked
|
|
91
|
+
file, and `git commit -a` (or `git add -u`) would then commit the deletion
|
|
92
|
+
alone, dropping the content from the repo. Three cases rename nothing
|
|
93
|
+
(E806), because the rename would be lost:
|
|
94
|
+
- git commit -a and git commit <paths> give the hook a temporary index
|
|
95
|
+
that git discards when the hook fails (see uses_real_index);
|
|
96
|
+
- inside a pre-commit run, pre-commit has stashed unstaged changes in a
|
|
97
|
+
patch it re-applies after the hooks, and cannot re-apply it to a
|
|
98
|
+
renamed file, so a file named in the patch is left alone (see
|
|
99
|
+
pre_commit_stashed_paths; `pre-commit try-repo` uses a temporary cache,
|
|
100
|
+
where the patch is not found);
|
|
101
|
+
- --check.
|
|
102
|
+
Any rename makes the hook exit 1, so the commit stops and the renames can
|
|
103
|
+
be reviewed.
|
|
104
|
+
|
|
105
|
+
Not in scope: more than one object per .bes file and Baselines (repository
|
|
106
|
+
sites are thought to handle both); case conflicts and names Windows forbids
|
|
107
|
+
(use check-case-conflict and check-illegal-windows-names from
|
|
108
|
+
pre-commit-hooks >= 5.0.0); schemas, GroupRelevance and `//` comments (the
|
|
109
|
+
bes-schema-validate, bes-relevance-convert-group and bes-conventions-check
|
|
110
|
+
hooks); writing a siteConfig.xml for a blank repo.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
import argparse
|
|
114
|
+
import collections
|
|
115
|
+
import glob
|
|
116
|
+
import os
|
|
117
|
+
import posixpath
|
|
118
|
+
import re
|
|
119
|
+
import subprocess
|
|
120
|
+
import sys
|
|
121
|
+
import unicodedata
|
|
122
|
+
|
|
123
|
+
from lxml import etree
|
|
124
|
+
|
|
125
|
+
if __package__ in (None, ""): # run directly as a script, not as a module
|
|
126
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
127
|
+
|
|
128
|
+
from pre_commit_bigfix import bes_common
|
|
129
|
+
|
|
130
|
+
DEFAULT_SERVER_ROOT = "C:\\Program Files (x86)\\BigFix Enterprise\\BES Server"
|
|
131
|
+
DEFAULT_BRANCH = "main"
|
|
132
|
+
# MAX_PATH is 260 including the terminating NUL
|
|
133
|
+
DEFAULT_MAX_PATH = 259
|
|
134
|
+
# a root server may run Linux (RHEL) instead, and one repo site may serve
|
|
135
|
+
# both, so the stricter limit always applies. Linux limits each file or
|
|
136
|
+
# folder name to NAME_MAX bytes (UTF-8). Its PATH_MAX (4096 bytes) needs no
|
|
137
|
+
# check of its own: 259 UTF-16 units are at most 777 bytes in UTF-8.
|
|
138
|
+
NAME_MAX = 255
|
|
139
|
+
# the server names the clone <repo>_<branch>_<8 hex>; the hash is unknown here
|
|
140
|
+
# and only its length matters
|
|
141
|
+
HASH_PLACEHOLDER = "0" * 8
|
|
142
|
+
|
|
143
|
+
DEFAULT_DIRS = ("Fixlets", "NonClientFiles", "OtherFiles")
|
|
144
|
+
SITE_CONFIG = "siteConfig.xml"
|
|
145
|
+
SITE_CONFIG_ELEMENTS = ("BesFileDir", "NonClientFilesDir", "OtherFilesDir")
|
|
146
|
+
|
|
147
|
+
KNOWN_CODES = frozenset(
|
|
148
|
+
{
|
|
149
|
+
"E800",
|
|
150
|
+
"E801",
|
|
151
|
+
"E802",
|
|
152
|
+
"E803",
|
|
153
|
+
"E804",
|
|
154
|
+
"E805",
|
|
155
|
+
"E806",
|
|
156
|
+
"E807",
|
|
157
|
+
"E808",
|
|
158
|
+
"W800",
|
|
159
|
+
"W801",
|
|
160
|
+
"W802",
|
|
161
|
+
"W803",
|
|
162
|
+
"W804",
|
|
163
|
+
"W806",
|
|
164
|
+
"W807",
|
|
165
|
+
"W808",
|
|
166
|
+
}
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# a numeric ID, then a space or a hyphen: `42- Name.bes`, `42 Name.bes`
|
|
170
|
+
ID_PREFIX_RE = re.compile(r"^([0-9]+)[ -]")
|
|
171
|
+
# undocumented; the largest signed 32-bit integer is the likely ceiling
|
|
172
|
+
MAX_ID = 2147483647
|
|
173
|
+
|
|
174
|
+
# non-ASCII characters NFKD does not fold, or folds to something unwanted:
|
|
175
|
+
# currency from #32, typographic quotes and dashes (the one non-ASCII name
|
|
176
|
+
# found in the corpora was Docker\u2019s). Double quotes become ' because
|
|
177
|
+
# Windows forbids " in file names.
|
|
178
|
+
ASCII_TABLE = {
|
|
179
|
+
"\u20ac": "EUR",
|
|
180
|
+
"\u00a3": "GBP",
|
|
181
|
+
"\u00df": "ss",
|
|
182
|
+
"\u2018": "'",
|
|
183
|
+
"\u2019": "'",
|
|
184
|
+
"\u201a": "'",
|
|
185
|
+
"\u201b": "'",
|
|
186
|
+
"\u201c": "'",
|
|
187
|
+
"\u201d": "'",
|
|
188
|
+
"\u201e": "'",
|
|
189
|
+
"\u201f": "'",
|
|
190
|
+
"\u2012": "-",
|
|
191
|
+
"\u2013": "-",
|
|
192
|
+
"\u2014": "-",
|
|
193
|
+
"\u2015": "-",
|
|
194
|
+
}
|
|
195
|
+
# characters Windows forbids in a file name (control characters too)
|
|
196
|
+
WINDOWS_FORBIDDEN = frozenset('<>:"/\\|?*')
|
|
197
|
+
FIX_CODES = ("E803", "E804", "E805")
|
|
198
|
+
|
|
199
|
+
SiteConfig = collections.namedtuple(
|
|
200
|
+
"SiteConfig", "bes_dir nonclient_dir other_dir named issue"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def server_prefix(server_root, repo_name, branch, commit_hash=HASH_PLACEHOLDER):
|
|
205
|
+
r"""Return the server folder a repo file's relative path is appended to.
|
|
206
|
+
|
|
207
|
+
That is `<server_root>\RepositorySiteGather\Sites\<repo>_<branch>_<hash>\Source\`,
|
|
208
|
+
joined with `/` instead when `server_root` is a POSIX path. Only the
|
|
209
|
+
Windows layout is confirmed; a separator is one character either way,
|
|
210
|
+
so the length is the same.
|
|
211
|
+
"""
|
|
212
|
+
sep = "/" if server_root.startswith("/") else "\\"
|
|
213
|
+
root = server_root.rstrip("\\/")
|
|
214
|
+
return sep.join(
|
|
215
|
+
[root, "RepositorySiteGather", "Sites", f"{repo_name}_{branch}_{commit_hash}"]
|
|
216
|
+
+ ["Source", ""]
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def server_path_length(prefix, rel_path):
|
|
221
|
+
"""Length of `rel_path` on the server, in the UTF-16 units MAX_PATH counts.
|
|
222
|
+
|
|
223
|
+
A name git listed as bytes that are not UTF-8 holds lone surrogates
|
|
224
|
+
(os.fsdecode); surrogatepass counts each as one unit instead of failing.
|
|
225
|
+
"""
|
|
226
|
+
full = prefix + rel_path.replace("/", "\\")
|
|
227
|
+
return len(full.encode("utf-16-le", "surrogatepass")) // 2
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def name_bytes(name):
|
|
231
|
+
"""Bytes `name` takes in UTF-8, as a Linux file system counts NAME_MAX.
|
|
232
|
+
|
|
233
|
+
A name git listed as bytes that are not UTF-8 holds surrogate escapes
|
|
234
|
+
(os.fsdecode); surrogateescape turns those back into their bytes.
|
|
235
|
+
"""
|
|
236
|
+
try:
|
|
237
|
+
return len(name.encode("utf-8", "surrogateescape"))
|
|
238
|
+
except UnicodeEncodeError: # a surrogate that is not an escape
|
|
239
|
+
return len(name.encode("utf-8", "surrogatepass"))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def repo_name_from_url(url):
|
|
243
|
+
"""The last part of a git remote URL, without `.git`.
|
|
244
|
+
|
|
245
|
+
Handles https://host/owner/name(.git), ssh://host/owner/name.git, the
|
|
246
|
+
scp-like git@host:owner/name.git, and local paths, Windows ones too
|
|
247
|
+
(C:\\repos\\name, \\\\server\\share\\name.git). `.git` matches in any case.
|
|
248
|
+
"""
|
|
249
|
+
name = url.strip().replace("\\", "/").rstrip("/")
|
|
250
|
+
name = name.replace(":", "/").rsplit("/", 1)[-1]
|
|
251
|
+
# not str.removesuffix(): that needs Python 3.9, and this supports 3.8
|
|
252
|
+
stem, extension = os.path.splitext(name)
|
|
253
|
+
return stem if extension.lower() == ".git" else name
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _git(repo_root, *args):
|
|
257
|
+
"""Run git in `repo_root`; return its stdout bytes, or None if it failed."""
|
|
258
|
+
try:
|
|
259
|
+
result = subprocess.run(
|
|
260
|
+
["git", *args], cwd=repo_root, capture_output=True, check=False
|
|
261
|
+
)
|
|
262
|
+
except OSError: # git not installed
|
|
263
|
+
return None
|
|
264
|
+
return result.stdout if result.returncode == 0 else None
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def repo_root():
|
|
268
|
+
"""The top folder of the git repo the current folder is in, or None."""
|
|
269
|
+
out = _git(".", "rev-parse", "--show-toplevel")
|
|
270
|
+
return os.fsdecode(out).strip() if out else None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def default_repo_name(root):
|
|
274
|
+
"""The origin remote's repo name, or the repo folder's name if no origin."""
|
|
275
|
+
url = _git(root, "remote", "get-url", "origin")
|
|
276
|
+
if url and os.fsdecode(url).strip():
|
|
277
|
+
return repo_name_from_url(os.fsdecode(url))
|
|
278
|
+
return os.path.basename(os.path.normpath(root))
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def git_ls_files(root):
|
|
282
|
+
"""Return the files git tracks under `root`, as sorted repo-relative paths.
|
|
283
|
+
|
|
284
|
+
`-z` keeps names unquoted (non-ASCII and spaces as they are). An index
|
|
285
|
+
with a merge conflict lists a path once per stage, hence the set.
|
|
286
|
+
"""
|
|
287
|
+
out = _git(root, "ls-files", "-z")
|
|
288
|
+
if out is None:
|
|
289
|
+
raise OSError(f"`git ls-files` failed in {root}")
|
|
290
|
+
return sorted({os.fsdecode(name) for name in out.split(b"\0") if name})
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _join(site_root, rel):
|
|
294
|
+
"""Join a site root and a site-relative path into a repo-relative path."""
|
|
295
|
+
return f"{site_root}/{rel}" if site_root else rel
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _clean_dir(text):
|
|
299
|
+
"""A folder name from siteConfig.xml, without surrounding space or slashes.
|
|
300
|
+
|
|
301
|
+
`./Content`, `.\\Content\\` and `Content/./` all mean Content; `.` (or
|
|
302
|
+
nothing) names no folder, so the caller uses the default.
|
|
303
|
+
"""
|
|
304
|
+
text = (text or "").strip().replace("\\", "/")
|
|
305
|
+
if not text:
|
|
306
|
+
return ""
|
|
307
|
+
text = posixpath.normpath(text).strip("/")
|
|
308
|
+
return "" if text == "." else text
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def read_site_config(root, site_root, files):
|
|
312
|
+
"""Read the folder names from `<site_root>/siteConfig.xml`.
|
|
313
|
+
|
|
314
|
+
Only a tracked siteConfig.xml counts, since the server only sees what is
|
|
315
|
+
committed. Returns a SiteConfig: the three folder names (defaults for
|
|
316
|
+
any not set), `named` the ones it renames to something other than
|
|
317
|
+
the default, and `issue` a W807
|
|
318
|
+
(lineno, code, message) when the file is not readable XML, in which
|
|
319
|
+
case the defaults are used. Validating it is bes-schema-validate's job.
|
|
320
|
+
"""
|
|
321
|
+
defaults = SiteConfig(*DEFAULT_DIRS, named=(), issue=None)
|
|
322
|
+
rel = _join(site_root, SITE_CONFIG)
|
|
323
|
+
if rel not in set(files):
|
|
324
|
+
return defaults
|
|
325
|
+
parser = etree.XMLParser(resolve_entities=False, no_network=True)
|
|
326
|
+
try:
|
|
327
|
+
tree = etree.parse(os.path.join(root, rel), parser)
|
|
328
|
+
except (OSError, etree.XMLSyntaxError) as err:
|
|
329
|
+
return defaults._replace(
|
|
330
|
+
issue=(
|
|
331
|
+
1,
|
|
332
|
+
"W807",
|
|
333
|
+
(
|
|
334
|
+
f"{SITE_CONFIG} is not readable XML ({err}); assuming the "
|
|
335
|
+
f"default folders {', '.join(DEFAULT_DIRS)}"
|
|
336
|
+
),
|
|
337
|
+
)
|
|
338
|
+
)
|
|
339
|
+
found = []
|
|
340
|
+
for tag, default in zip(SITE_CONFIG_ELEMENTS, DEFAULT_DIRS):
|
|
341
|
+
value = _clean_dir(tree.getroot().findtext(tag))
|
|
342
|
+
found.append(value or default)
|
|
343
|
+
# a default name spelled out (as the RepositorySiteExample template does)
|
|
344
|
+
# renames nothing, so it does not count
|
|
345
|
+
named = tuple(
|
|
346
|
+
name
|
|
347
|
+
for name, default in zip(found, DEFAULT_DIRS)
|
|
348
|
+
if name.lower() != default.lower()
|
|
349
|
+
)
|
|
350
|
+
return SiteConfig(*found, named=named, issue=None)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _windows_safe(char):
|
|
354
|
+
"""True for a printable ASCII character Windows allows in a file name."""
|
|
355
|
+
return " " <= char <= "~" and char not in WINDOWS_FORBIDDEN
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def fold_ascii(text):
|
|
359
|
+
"""Return `text` with each non-ASCII character replaced by ASCII (#32).
|
|
360
|
+
|
|
361
|
+
The text is first composed (NFC), so a decomposed accent (a combining
|
|
362
|
+
mark after its letter, as macOS writes names) joins its letter. Then
|
|
363
|
+
ASCII_TABLE, then Unicode NFKD with the combining marks dropped
|
|
364
|
+
(\u00e9 -> e, \u2122 -> TM); a character with no Windows-safe ASCII
|
|
365
|
+
equivalent becomes `_`. A combining mark with no letter to join, and an
|
|
366
|
+
invisible format character (category Cf: zero-width space, BOM), are
|
|
367
|
+
dropped. ASCII characters are left as they are.
|
|
368
|
+
"""
|
|
369
|
+
out = []
|
|
370
|
+
for char in unicodedata.normalize("NFC", text):
|
|
371
|
+
if char.isascii():
|
|
372
|
+
out.append(char)
|
|
373
|
+
elif char in ASCII_TABLE:
|
|
374
|
+
out.append(ASCII_TABLE[char])
|
|
375
|
+
elif unicodedata.combining(char) or unicodedata.category(char) == "Cf":
|
|
376
|
+
continue
|
|
377
|
+
else:
|
|
378
|
+
decomposed = "".join(
|
|
379
|
+
c
|
|
380
|
+
for c in unicodedata.normalize("NFKD", char)
|
|
381
|
+
if not unicodedata.combining(c)
|
|
382
|
+
)
|
|
383
|
+
ok = decomposed and all(_windows_safe(c) for c in decomposed)
|
|
384
|
+
out.append(decomposed if ok else "_")
|
|
385
|
+
return "".join(out)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def safe_title(title):
|
|
389
|
+
"""Turn a Title into ASCII text that is safe in a Windows file name.
|
|
390
|
+
|
|
391
|
+
Runs of whitespace become one space, and the ends are trimmed; then
|
|
392
|
+
non-ASCII is folded and anything Windows forbids becomes `_`.
|
|
393
|
+
"""
|
|
394
|
+
text = fold_ascii(re.sub(r"\s+", " ", title).strip())
|
|
395
|
+
return "".join(c if _windows_safe(c) else "_" for c in text)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def read_title(path):
|
|
399
|
+
"""The first content object's <Title> in a .bes file, or None."""
|
|
400
|
+
parser = etree.XMLParser(resolve_entities=False, no_network=True)
|
|
401
|
+
try:
|
|
402
|
+
titles = etree.parse(path, parser).xpath("/BES/*/Title")
|
|
403
|
+
except (OSError, ValueError, etree.XMLSyntaxError): # ValueError: Unicode
|
|
404
|
+
return None
|
|
405
|
+
return "".join(titles[0].itertext()) if titles else None
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _within(rel, folder):
|
|
409
|
+
"""`rel` relative to `folder` if it is inside it, else None.
|
|
410
|
+
|
|
411
|
+
Compared ignoring case, so nothing goes unchecked: on a Windows root
|
|
412
|
+
server `fixlets/` and `Fixlets/` are the same folder. A RHEL root server
|
|
413
|
+
is case-sensitive, so a match that holds only this way is flagged W808.
|
|
414
|
+
"""
|
|
415
|
+
if not folder:
|
|
416
|
+
return rel
|
|
417
|
+
if rel.lower().startswith(folder.lower() + "/"):
|
|
418
|
+
return rel[len(folder) + 1 :]
|
|
419
|
+
return None
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _is_bes(path):
|
|
423
|
+
"""True for a .bes file, whatever the case of its extension."""
|
|
424
|
+
return path.lower().endswith(".bes")
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _assign_sites(files, site_roots):
|
|
428
|
+
"""Return {site root: [repo-relative paths]}; a file goes to its deepest root."""
|
|
429
|
+
by_site = {site: [] for site in site_roots}
|
|
430
|
+
deepest_first = sorted(site_roots, key=len, reverse=True)
|
|
431
|
+
for path in files:
|
|
432
|
+
for site in deepest_first:
|
|
433
|
+
if _within(path, site) is not None:
|
|
434
|
+
by_site[site].append(path)
|
|
435
|
+
break
|
|
436
|
+
return by_site
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
class _Results:
|
|
440
|
+
"""Collects (lineno, code, message) issues and fixes per path."""
|
|
441
|
+
|
|
442
|
+
def __init__(self):
|
|
443
|
+
self.issues = collections.defaultdict(list)
|
|
444
|
+
self.fixed = collections.defaultdict(list)
|
|
445
|
+
|
|
446
|
+
def issue(self, path, code, message):
|
|
447
|
+
self.issues[path].append((1, code, message))
|
|
448
|
+
|
|
449
|
+
def as_list(self, disabled):
|
|
450
|
+
paths = sorted(set(self.issues) | set(self.fixed))
|
|
451
|
+
return [
|
|
452
|
+
(
|
|
453
|
+
path,
|
|
454
|
+
sorted(i for i in self.issues[path] if i[1] not in disabled),
|
|
455
|
+
self.fixed[path],
|
|
456
|
+
)
|
|
457
|
+
for path in paths
|
|
458
|
+
]
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _id_of(path):
|
|
462
|
+
"""The numeric ID a .bes file's name starts with, as an int, or None."""
|
|
463
|
+
match = ID_PREFIX_RE.match(path.rsplit("/", 1)[-1])
|
|
464
|
+
return int(match.group(1)) if match else None
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _check_ids(results, bes_paths):
|
|
468
|
+
"""E800, W806 and E801 for the .bes files of one site's .bes folder."""
|
|
469
|
+
by_id = collections.defaultdict(list)
|
|
470
|
+
for path in bes_paths:
|
|
471
|
+
name = path.rsplit("/", 1)[-1]
|
|
472
|
+
match = ID_PREFIX_RE.match(name)
|
|
473
|
+
if match is None:
|
|
474
|
+
results.issue(
|
|
475
|
+
path,
|
|
476
|
+
"E800",
|
|
477
|
+
"file name does not start with a numeric ID followed by a space "
|
|
478
|
+
"or hyphen (`42- Name.bes` or `42 Name.bes`); the server "
|
|
479
|
+
"skips it",
|
|
480
|
+
)
|
|
481
|
+
continue
|
|
482
|
+
number = int(match.group(1))
|
|
483
|
+
by_id[number].append(path)
|
|
484
|
+
if not 1 <= number <= MAX_ID:
|
|
485
|
+
results.issue(
|
|
486
|
+
path,
|
|
487
|
+
"W806",
|
|
488
|
+
f"ID {number} is outside 1..{MAX_ID} (the range is undocumented)",
|
|
489
|
+
)
|
|
490
|
+
for number, paths in by_id.items():
|
|
491
|
+
if len(paths) < 2:
|
|
492
|
+
continue
|
|
493
|
+
for path in paths:
|
|
494
|
+
others = ", ".join(other for other in paths if other != path)
|
|
495
|
+
results.issue(
|
|
496
|
+
path,
|
|
497
|
+
"E801",
|
|
498
|
+
f"ID {number} is also used by {others} (IDs are compared as "
|
|
499
|
+
"integers, so leading zeros do not count); the server skips "
|
|
500
|
+
"duplicates",
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _check_layout(results, site, site_files, config):
|
|
505
|
+
"""W800-W804 for one site; return the .bes files in its .bes folder."""
|
|
506
|
+
bes_dir = config.bes_dir
|
|
507
|
+
bes_paths = []
|
|
508
|
+
case_reported = set()
|
|
509
|
+
for path in site_files:
|
|
510
|
+
rel = _within(path, site)
|
|
511
|
+
for folder in (bes_dir, config.nonclient_dir, config.other_dir):
|
|
512
|
+
actual = rel[: len(folder)]
|
|
513
|
+
if (
|
|
514
|
+
_within(rel, folder) is not None
|
|
515
|
+
and actual != folder
|
|
516
|
+
and actual not in case_reported
|
|
517
|
+
):
|
|
518
|
+
case_reported.add(actual)
|
|
519
|
+
results.issue(
|
|
520
|
+
_join(site, actual),
|
|
521
|
+
"W808",
|
|
522
|
+
f"folder {actual}/ is the {folder}/ the site uses only when "
|
|
523
|
+
"case is ignored; a case-sensitive root server (RHEL) may "
|
|
524
|
+
"not find it",
|
|
525
|
+
)
|
|
526
|
+
inside = _within(rel, bes_dir)
|
|
527
|
+
if inside is None:
|
|
528
|
+
if _is_bes(path):
|
|
529
|
+
results.issue(
|
|
530
|
+
path,
|
|
531
|
+
"W800",
|
|
532
|
+
f"a .bes file outside the .bes folder ({_join(site, bes_dir)}/) "
|
|
533
|
+
"is not gathered",
|
|
534
|
+
)
|
|
535
|
+
continue
|
|
536
|
+
name = inside.rsplit("/", 1)[-1]
|
|
537
|
+
if _is_bes(path):
|
|
538
|
+
bes_paths.append(path)
|
|
539
|
+
if not path.endswith(".bes"):
|
|
540
|
+
results.issue(
|
|
541
|
+
path,
|
|
542
|
+
"W808",
|
|
543
|
+
f"extension {path[-4:]} is .bes only when case is ignored; "
|
|
544
|
+
"a case-sensitive root server (RHEL) may not gather it",
|
|
545
|
+
)
|
|
546
|
+
depth = inside.count("/")
|
|
547
|
+
if depth == 0 or depth > 2:
|
|
548
|
+
where = "directly in" if depth == 0 else "more than two folders into"
|
|
549
|
+
results.issue(
|
|
550
|
+
path,
|
|
551
|
+
"W801",
|
|
552
|
+
f"a .bes file {where} {bes_dir}/; the documented layout is "
|
|
553
|
+
f"{bes_dir}/<fxf>/ or {bes_dir}/<fxf>/<digest>/ (how the "
|
|
554
|
+
"server handles this is undocumented)",
|
|
555
|
+
)
|
|
556
|
+
elif name.lower() == "digest.xml":
|
|
557
|
+
if name != "digest.xml":
|
|
558
|
+
results.issue(
|
|
559
|
+
path,
|
|
560
|
+
"W808",
|
|
561
|
+
f"{name} is digest.xml only when case is ignored; a "
|
|
562
|
+
"case-sensitive root server (RHEL) may not find it",
|
|
563
|
+
)
|
|
564
|
+
elif name.lower().endswith(".xsd"):
|
|
565
|
+
results.issue(
|
|
566
|
+
path,
|
|
567
|
+
"W802",
|
|
568
|
+
f"an .xsd in {bes_dir}/ is not site content; the site does not "
|
|
569
|
+
"need schemas, bes-schema-validate already has them",
|
|
570
|
+
)
|
|
571
|
+
else:
|
|
572
|
+
results.issue(
|
|
573
|
+
path,
|
|
574
|
+
"W802",
|
|
575
|
+
f"only .bes files and digest.xml belong in {bes_dir}/; this is "
|
|
576
|
+
"not site content",
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
for folder in config.named:
|
|
580
|
+
# a folder name may have several parts (Content/Fixlets)
|
|
581
|
+
if not any(_within(_within(p, site), folder) for p in site_files):
|
|
582
|
+
results.issue(
|
|
583
|
+
_join(site, SITE_CONFIG),
|
|
584
|
+
"W803",
|
|
585
|
+
f"{SITE_CONFIG} names the folder {folder}/, which has no files",
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
has_content = bes_paths or any(
|
|
589
|
+
_within(_within(p, site), folder)
|
|
590
|
+
for p in site_files
|
|
591
|
+
for folder in (config.nonclient_dir, config.other_dir)
|
|
592
|
+
)
|
|
593
|
+
if not has_content:
|
|
594
|
+
results.issue(
|
|
595
|
+
site or ".",
|
|
596
|
+
"W804",
|
|
597
|
+
f"the site has no content: no .bes files in {bes_dir}/ and no files "
|
|
598
|
+
f"in {config.nonclient_dir}/ or {config.other_dir}/",
|
|
599
|
+
)
|
|
600
|
+
return bes_paths
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _shortest_name(path):
|
|
604
|
+
"""The shortest name the length fix could give a .bes file: `<ID> X.bes`."""
|
|
605
|
+
folder, _, name = path.rpartition("/")
|
|
606
|
+
extension = os.path.splitext(name)[1]
|
|
607
|
+
short = f"{_id_of(path)} X{extension}"
|
|
608
|
+
return f"{folder}/{short}" if folder else short
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _check_limits(results, files, gathered, prefix, max_path, disabled=frozenset()):
|
|
612
|
+
"""E802, E807 and E808: names and paths a root server cannot hold.
|
|
613
|
+
|
|
614
|
+
Path: the server path must be at most `max_path` (Windows). Name: each
|
|
615
|
+
file and folder name must be at most NAME_MAX bytes (Linux). `gathered`
|
|
616
|
+
are the .bes files in a site's .bes folder; the rename fix can shorten
|
|
617
|
+
those that have an ID, so only one even the shortest name cannot bring
|
|
618
|
+
under the path limit is reported here (E802), or, with the E805 fix
|
|
619
|
+
disabled, any of them over a limit. Every other tracked file over a
|
|
620
|
+
limit is an error too (E807, E808): the server clones the whole repo, so
|
|
621
|
+
one long path anywhere fails the sync. Those are not renamed, since
|
|
622
|
+
actions may refer to them by name, and a folder is not renamed since
|
|
623
|
+
that changes its .fxf or digest.
|
|
624
|
+
"""
|
|
625
|
+
long_folders = set()
|
|
626
|
+
for path in files:
|
|
627
|
+
parts = path.split("/")
|
|
628
|
+
for depth, part in enumerate(parts[:-1], start=1):
|
|
629
|
+
folder = "/".join(parts[:depth])
|
|
630
|
+
size = name_bytes(part)
|
|
631
|
+
if size > NAME_MAX and folder not in long_folders:
|
|
632
|
+
long_folders.add(folder)
|
|
633
|
+
results.issue(
|
|
634
|
+
folder,
|
|
635
|
+
"E808",
|
|
636
|
+
f"folder name is {size} bytes in UTF-8, over the {NAME_MAX}-"
|
|
637
|
+
"byte limit of Linux file systems, so a RHEL root server "
|
|
638
|
+
"cannot check it out; rename it by hand",
|
|
639
|
+
)
|
|
640
|
+
fixable = path in gathered and _id_of(path) is not None
|
|
641
|
+
_check_path_length(
|
|
642
|
+
results, path, path in gathered, fixable, prefix, max_path, disabled
|
|
643
|
+
)
|
|
644
|
+
size = name_bytes(parts[-1])
|
|
645
|
+
if size <= NAME_MAX:
|
|
646
|
+
continue
|
|
647
|
+
if fixable and "E805" not in disabled:
|
|
648
|
+
continue # the rename makes it ASCII and short
|
|
649
|
+
if (
|
|
650
|
+
fixable
|
|
651
|
+
and "E804" not in disabled
|
|
652
|
+
and name_bytes(fold_ascii(parts[-1])) <= NAME_MAX
|
|
653
|
+
):
|
|
654
|
+
continue # folding it to ASCII is enough
|
|
655
|
+
results.issue(
|
|
656
|
+
path,
|
|
657
|
+
"E808",
|
|
658
|
+
f"file name is {size} bytes in UTF-8, over the {NAME_MAX}-byte limit "
|
|
659
|
+
"of Linux file systems, so a RHEL root server cannot check it out"
|
|
660
|
+
+ ("; renaming it would fix it, but E805 is disabled" if fixable else "")
|
|
661
|
+
+ ("" if fixable else "; shorten it by hand"),
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _check_path_length(results, path, gathered, fixable, prefix, max_path, disabled):
|
|
666
|
+
"""E802 or E807 for `path` if its server path is over `max_path`.
|
|
667
|
+
|
|
668
|
+
`gathered`: it is a .bes file in a site's .bes folder; `fixable`: one
|
|
669
|
+
with an ID, which the E805 rename can shorten.
|
|
670
|
+
"""
|
|
671
|
+
length = server_path_length(prefix, path)
|
|
672
|
+
if length <= max_path:
|
|
673
|
+
return
|
|
674
|
+
over = f"server path is {length} characters, over the {max_path} limit"
|
|
675
|
+
if not fixable:
|
|
676
|
+
if gathered: # a gathered .bes file with no ID
|
|
677
|
+
results.issue(
|
|
678
|
+
path,
|
|
679
|
+
"E802",
|
|
680
|
+
f"{over}, and with no numeric ID there is no short name to "
|
|
681
|
+
"rename it to",
|
|
682
|
+
)
|
|
683
|
+
else:
|
|
684
|
+
results.issue(
|
|
685
|
+
path,
|
|
686
|
+
"E807",
|
|
687
|
+
f"{over}, so a Windows root server cannot check it out; "
|
|
688
|
+
"shorten it by hand (it is not renamed automatically, since "
|
|
689
|
+
"actions may refer to it by name)",
|
|
690
|
+
)
|
|
691
|
+
return
|
|
692
|
+
shortest = _shortest_name(path)
|
|
693
|
+
shortest_length = server_path_length(prefix, shortest)
|
|
694
|
+
if shortest_length > max_path:
|
|
695
|
+
results.issue(
|
|
696
|
+
path,
|
|
697
|
+
"E802",
|
|
698
|
+
f"{over}, and even the shortest name ({shortest}) would be "
|
|
699
|
+
f"{shortest_length}: the folders are too long. Renaming a folder "
|
|
700
|
+
"changes its .fxf or digest, so this is not fixed",
|
|
701
|
+
)
|
|
702
|
+
elif "E805" in disabled:
|
|
703
|
+
results.issue(
|
|
704
|
+
path,
|
|
705
|
+
"E802",
|
|
706
|
+
f"{over}; renaming it to <ID> <Title> would fix it, but E805 is "
|
|
707
|
+
"disabled",
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
class _Renamer:
|
|
712
|
+
"""Plans the one rename each gathered .bes file may get, then makes them.
|
|
713
|
+
|
|
714
|
+
The new name combines E803 (zero padding stripped from the ID), E804
|
|
715
|
+
(non-ASCII folded) and, when the server path is still too long, E805
|
|
716
|
+
(`<ID> <Title>` cut to fit). The ID never changes and the file never
|
|
717
|
+
leaves its folder. A name already taken, ignoring case, by a tracked
|
|
718
|
+
file, another planned rename or anything on disk is E806 instead.
|
|
719
|
+
"""
|
|
720
|
+
|
|
721
|
+
def __init__(self, root, files, prefix, max_path, disabled, held=frozenset()):
|
|
722
|
+
self.root = root
|
|
723
|
+
self.held = held
|
|
724
|
+
self.temporary_index = False
|
|
725
|
+
self.prefix = prefix
|
|
726
|
+
self.max_path = max_path
|
|
727
|
+
self.disabled = disabled
|
|
728
|
+
# lowercased path -> the path holding that name
|
|
729
|
+
self.taken = {path.lower(): path for path in files}
|
|
730
|
+
self.renames = [] # (old path, new path, [(code, reason)])
|
|
731
|
+
|
|
732
|
+
def _length(self, path):
|
|
733
|
+
return server_path_length(self.prefix, path)
|
|
734
|
+
|
|
735
|
+
def _new_name(self, results, path):
|
|
736
|
+
"""Return (new name, [(code, reason)]) for `path`; reasons may be []."""
|
|
737
|
+
folder, _, name = path.rpartition("/")
|
|
738
|
+
digits = ID_PREFIX_RE.match(name).group(1)
|
|
739
|
+
number = int(digits)
|
|
740
|
+
new, reasons = name, []
|
|
741
|
+
if "E803" not in self.disabled and digits != str(number):
|
|
742
|
+
new = str(number) + new[len(digits) :]
|
|
743
|
+
reasons.append(("E803", f"zero-padded ID {digits} is {number}"))
|
|
744
|
+
if "E804" not in self.disabled and not new.isascii():
|
|
745
|
+
new = fold_ascii(new)
|
|
746
|
+
reasons.append(("E804", "non-ASCII characters in the file name"))
|
|
747
|
+
length = self._length(_join(folder, new))
|
|
748
|
+
size = name_bytes(new)
|
|
749
|
+
if "E805" not in self.disabled and (length > self.max_path or size > NAME_MAX):
|
|
750
|
+
extension = os.path.splitext(name)[1]
|
|
751
|
+
stem = _join(folder, f"{number} ")
|
|
752
|
+
room = min(
|
|
753
|
+
self.max_path - self._length(stem + extension),
|
|
754
|
+
NAME_MAX - name_bytes(f"{number} {extension}"),
|
|
755
|
+
)
|
|
756
|
+
if length > self.max_path:
|
|
757
|
+
why = (
|
|
758
|
+
f"server path is {length} characters, over the "
|
|
759
|
+
f"{self.max_path} limit"
|
|
760
|
+
)
|
|
761
|
+
else:
|
|
762
|
+
why = (
|
|
763
|
+
f"file name is {size} bytes in UTF-8, over the {NAME_MAX}-"
|
|
764
|
+
"byte limit of Linux file systems"
|
|
765
|
+
)
|
|
766
|
+
if room >= 1: # otherwise _check_limits reported E802
|
|
767
|
+
title = read_title(os.path.join(self.root, path))
|
|
768
|
+
# a Title of only forbidden characters would give `42 ___`
|
|
769
|
+
if title is None or not any(c.isalnum() for c in safe_title(title)):
|
|
770
|
+
# the current name, without its ID prefix
|
|
771
|
+
title = os.path.splitext(name[len(digits) :])[0].lstrip(" -")
|
|
772
|
+
short = safe_title(title)[:room].rstrip(" .-")
|
|
773
|
+
if short:
|
|
774
|
+
new = f"{number} {short}{extension}"
|
|
775
|
+
reasons.append(("E805", why))
|
|
776
|
+
else:
|
|
777
|
+
# E802 is the path code, E808 the name code
|
|
778
|
+
results.issue(
|
|
779
|
+
path,
|
|
780
|
+
"E802" if length > self.max_path else "E808",
|
|
781
|
+
f"{why}, and there is no title to build a shorter name from",
|
|
782
|
+
)
|
|
783
|
+
return new, reasons
|
|
784
|
+
|
|
785
|
+
def plan(self, results, path):
|
|
786
|
+
"""Plan the rename of gathered .bes file `path`, if it needs one."""
|
|
787
|
+
if ID_PREFIX_RE.match(path.rsplit("/", 1)[-1]) is None:
|
|
788
|
+
return # E800: no ID to keep
|
|
789
|
+
new, reasons = self._new_name(results, path)
|
|
790
|
+
if not reasons:
|
|
791
|
+
return
|
|
792
|
+
new_path = _join(path.rpartition("/")[0], new)
|
|
793
|
+
if path in self.held:
|
|
794
|
+
results.issue(
|
|
795
|
+
path,
|
|
796
|
+
"E806",
|
|
797
|
+
f"not renamed to {new}: pre-commit set aside its unstaged "
|
|
798
|
+
"changes for this run, and pre-commit could not restore them "
|
|
799
|
+
"to a renamed file; stage or stash them, then run again",
|
|
800
|
+
)
|
|
801
|
+
return
|
|
802
|
+
holder = self._holder(path, new_path)
|
|
803
|
+
if holder:
|
|
804
|
+
why = "; ".join(reason for _code, reason in reasons)
|
|
805
|
+
results.issue(
|
|
806
|
+
path,
|
|
807
|
+
"E806",
|
|
808
|
+
f"not renamed to {new} ({why}): {holder} has that name "
|
|
809
|
+
"(names are compared ignoring case); rename it by hand",
|
|
810
|
+
)
|
|
811
|
+
return
|
|
812
|
+
self.renames.append((path, new_path, reasons))
|
|
813
|
+
|
|
814
|
+
def _holder(self, path, new_path):
|
|
815
|
+
"""Describe what already has the name `new_path`, or return None."""
|
|
816
|
+
key = new_path.lower()
|
|
817
|
+
if key == path.lower():
|
|
818
|
+
return None # only the file itself
|
|
819
|
+
if key in self.taken:
|
|
820
|
+
return self.taken[key]
|
|
821
|
+
for old, planned, _reasons in self.renames:
|
|
822
|
+
if key == planned.lower():
|
|
823
|
+
return f"{old}, which is being renamed to it,"
|
|
824
|
+
if os.path.lexists(os.path.join(self.root, new_path)):
|
|
825
|
+
return "an untracked file"
|
|
826
|
+
return None
|
|
827
|
+
|
|
828
|
+
def apply(self, results, check, tracked):
|
|
829
|
+
"""Make the planned renames (or, under `check`, report them)."""
|
|
830
|
+
for old, new_path, reasons in self.renames:
|
|
831
|
+
new = new_path.rsplit("/", 1)[-1]
|
|
832
|
+
if check:
|
|
833
|
+
for code, reason in reasons:
|
|
834
|
+
results.issue(
|
|
835
|
+
old,
|
|
836
|
+
code,
|
|
837
|
+
f"{reason}; would be renamed to {new} (run without --check)",
|
|
838
|
+
)
|
|
839
|
+
continue
|
|
840
|
+
if self.temporary_index:
|
|
841
|
+
results.issue(
|
|
842
|
+
old,
|
|
843
|
+
"E806",
|
|
844
|
+
f"not renamed to {new}: this commit uses a temporary index "
|
|
845
|
+
"(git commit -a, or git commit <paths>), which git throws "
|
|
846
|
+
"away when the hook fails, so the rename would be lost. "
|
|
847
|
+
"git add the changes and run a plain git commit (or "
|
|
848
|
+
"pre-commit run) to rename it",
|
|
849
|
+
)
|
|
850
|
+
continue
|
|
851
|
+
error = _rename(self.root, old, new_path, old in tracked)
|
|
852
|
+
if error:
|
|
853
|
+
results.issue(old, "E806", f"could not rename to {new}: {error}")
|
|
854
|
+
continue
|
|
855
|
+
for code, reason in reasons:
|
|
856
|
+
results.fixed[old].append((1, code, f"{reason}; renamed to {new}"))
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _rename(root, old, new, use_git):
|
|
860
|
+
"""Rename repo-relative `old` to `new`; return an error message or None."""
|
|
861
|
+
if use_git:
|
|
862
|
+
try:
|
|
863
|
+
result = subprocess.run(
|
|
864
|
+
["git", "mv", "--", old, new],
|
|
865
|
+
cwd=root,
|
|
866
|
+
capture_output=True,
|
|
867
|
+
encoding="utf-8",
|
|
868
|
+
errors="backslashreplace", # git echoes names that may not be UTF-8
|
|
869
|
+
check=False,
|
|
870
|
+
)
|
|
871
|
+
except OSError as err:
|
|
872
|
+
return str(err)
|
|
873
|
+
return result.stderr.strip() or "git mv failed" if result.returncode else None
|
|
874
|
+
try:
|
|
875
|
+
os.rename(os.path.join(root, old), os.path.join(root, new))
|
|
876
|
+
except OSError as err:
|
|
877
|
+
return str(err)
|
|
878
|
+
return None
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def uses_real_index(root, environ=None):
|
|
882
|
+
"""False when git gave this hook a temporary index (GIT_INDEX_FILE).
|
|
883
|
+
|
|
884
|
+
git commit -a and git commit <paths> run the pre-commit hook against a
|
|
885
|
+
temporary index (index.lock, next-index-<pid>.lock) that git discards
|
|
886
|
+
when the hook fails, so a `git mv` made there is lost while the work
|
|
887
|
+
tree keeps the new name. A relative GIT_INDEX_FILE is taken relative to
|
|
888
|
+
`root`, where the hook runs.
|
|
889
|
+
"""
|
|
890
|
+
environ = os.environ if environ is None else environ
|
|
891
|
+
index = environ.get("GIT_INDEX_FILE")
|
|
892
|
+
if not index:
|
|
893
|
+
return True
|
|
894
|
+
git_dir = _git(root, "rev-parse", "--absolute-git-dir")
|
|
895
|
+
if git_dir is None:
|
|
896
|
+
return False
|
|
897
|
+
real = os.path.join(os.fsdecode(git_dir).strip(), "index")
|
|
898
|
+
given = os.path.join(root, index) # an absolute `index` wins in join
|
|
899
|
+
return os.path.realpath(given) == os.path.realpath(real)
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def _pre_commit_home(environ):
|
|
903
|
+
"""The folder pre-commit keeps its cache, and so its stash patches, in."""
|
|
904
|
+
if environ.get("PRE_COMMIT_HOME"):
|
|
905
|
+
return environ["PRE_COMMIT_HOME"]
|
|
906
|
+
cache = environ.get("XDG_CACHE_HOME") or os.path.join(
|
|
907
|
+
environ.get("HOME") or os.path.expanduser("~"), ".cache"
|
|
908
|
+
)
|
|
909
|
+
return os.path.join(cache, "pre-commit")
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def _is_windows():
|
|
913
|
+
"""True on Windows; a function so tests can pretend either way."""
|
|
914
|
+
return os.name == "nt"
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _parent_pid_windows(pid):
|
|
918
|
+
"""The parent of process `pid` on Windows, from a Toolhelp snapshot."""
|
|
919
|
+
import ctypes # pylint: disable=import-outside-toplevel
|
|
920
|
+
from ctypes import wintypes # pylint: disable=import-outside-toplevel
|
|
921
|
+
|
|
922
|
+
class ProcessEntry(ctypes.Structure): # PROCESSENTRY32W
|
|
923
|
+
_fields_ = [
|
|
924
|
+
("dwSize", wintypes.DWORD),
|
|
925
|
+
("cntUsage", wintypes.DWORD),
|
|
926
|
+
("th32ProcessID", wintypes.DWORD),
|
|
927
|
+
("th32DefaultHeapID", ctypes.c_size_t),
|
|
928
|
+
("th32ModuleID", wintypes.DWORD),
|
|
929
|
+
("cntThreads", wintypes.DWORD),
|
|
930
|
+
("th32ParentProcessID", wintypes.DWORD),
|
|
931
|
+
("pcPriClassBase", ctypes.c_long),
|
|
932
|
+
("dwFlags", wintypes.DWORD),
|
|
933
|
+
("szExeFile", ctypes.c_wchar * 260),
|
|
934
|
+
]
|
|
935
|
+
|
|
936
|
+
kernel32 = ctypes.windll.kernel32
|
|
937
|
+
# declared, so a HANDLE is passed whole rather than as a C int
|
|
938
|
+
kernel32.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD]
|
|
939
|
+
kernel32.CreateToolhelp32Snapshot.restype = wintypes.HANDLE
|
|
940
|
+
for walk in (kernel32.Process32FirstW, kernel32.Process32NextW):
|
|
941
|
+
walk.argtypes = [wintypes.HANDLE, ctypes.POINTER(ProcessEntry)]
|
|
942
|
+
walk.restype = wintypes.BOOL
|
|
943
|
+
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
944
|
+
kernel32.CloseHandle.restype = wintypes.BOOL
|
|
945
|
+
snapshot = kernel32.CreateToolhelp32Snapshot(2, 0) # TH32CS_SNAPPROCESS
|
|
946
|
+
if not snapshot or snapshot == wintypes.HANDLE(-1).value:
|
|
947
|
+
return None
|
|
948
|
+
try:
|
|
949
|
+
entry = ProcessEntry()
|
|
950
|
+
entry.dwSize = ctypes.sizeof(ProcessEntry)
|
|
951
|
+
found = kernel32.Process32FirstW(snapshot, ctypes.byref(entry))
|
|
952
|
+
while found:
|
|
953
|
+
if entry.th32ProcessID == pid:
|
|
954
|
+
return entry.th32ParentProcessID
|
|
955
|
+
found = kernel32.Process32NextW(snapshot, ctypes.byref(entry))
|
|
956
|
+
return None
|
|
957
|
+
finally:
|
|
958
|
+
kernel32.CloseHandle(snapshot)
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def _parent_pid(pid):
|
|
962
|
+
"""The parent of process `pid`, or None when it cannot be told."""
|
|
963
|
+
try:
|
|
964
|
+
if _is_windows():
|
|
965
|
+
return _parent_pid_windows(pid)
|
|
966
|
+
out = subprocess.run(
|
|
967
|
+
["ps", "-o", "ppid=", "-p", str(pid)],
|
|
968
|
+
capture_output=True,
|
|
969
|
+
text=True,
|
|
970
|
+
check=False,
|
|
971
|
+
).stdout.strip()
|
|
972
|
+
return int(out) if out else None
|
|
973
|
+
except Exception: # noqa: BLE001 - e.g. ctypes.ArgumentError: fail safe
|
|
974
|
+
return None
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def pre_commit_stashed_paths(root, pid=None, environ=None):
|
|
978
|
+
"""Return the files whose unstaged changes pre-commit set aside for this run.
|
|
979
|
+
|
|
980
|
+
Before running hooks on staged files, pre-commit saves unstaged changes
|
|
981
|
+
as a patch named `patch<time>-<its pid>` in its cache folder, checks
|
|
982
|
+
them out, and re-applies the patch afterwards. Its pid is this hook's
|
|
983
|
+
parent's (`pid` defaults to os.getppid()). On Windows a launcher may sit
|
|
984
|
+
in between (a console script is an .exe that starts python.exe as its
|
|
985
|
+
child), so there the grandparent and great-grandparent are tried too,
|
|
986
|
+
found with a Toolhelp snapshot; if that fails, only `pid` is tried. Not
|
|
987
|
+
on Linux or macOS: there those are git and a shell, and pre-commit never
|
|
988
|
+
deletes old patches, so one whose pid matches theirs would hold a file
|
|
989
|
+
with no unstaged changes. Verified on macOS only. A file renamed in between
|
|
990
|
+
is not where the patch expects it, so pre-commit cannot restore those
|
|
991
|
+
changes and stops with an error. The file names are read with
|
|
992
|
+
`git apply --numstat -z`, which applies nothing. Outside a pre-commit
|
|
993
|
+
run, or with no unstaged changes, there is no such patch: empty set.
|
|
994
|
+
This relies on how pre-commit names the patch; if that changes, the set
|
|
995
|
+
is empty and such a file is renamed as before.
|
|
996
|
+
"""
|
|
997
|
+
pid = os.getppid() if pid is None else pid
|
|
998
|
+
environ = os.environ if environ is None else environ
|
|
999
|
+
pids = [pid]
|
|
1000
|
+
while _is_windows() and len(pids) < 3 and pids[-1] is not None:
|
|
1001
|
+
pids.append(_parent_pid(pids[-1]))
|
|
1002
|
+
home = glob.escape(_pre_commit_home(environ))
|
|
1003
|
+
patches = []
|
|
1004
|
+
for candidate in pids:
|
|
1005
|
+
if candidate is not None:
|
|
1006
|
+
patches += glob.glob(os.path.join(home, f"patch*-{candidate}"))
|
|
1007
|
+
paths = set()
|
|
1008
|
+
for patch in patches:
|
|
1009
|
+
out = _git(root, "apply", "--numstat", "-z", os.path.abspath(patch))
|
|
1010
|
+
# records are `added<TAB>deleted<TAB>path`; a rename is
|
|
1011
|
+
# `added<TAB>deleted<TAB>`, then the old and new paths
|
|
1012
|
+
fields = (out or b"").split(b"\0")
|
|
1013
|
+
index = 0
|
|
1014
|
+
while index < len(fields):
|
|
1015
|
+
record = fields[index].split(b"\t", 2)
|
|
1016
|
+
index += 1
|
|
1017
|
+
if len(record) < 3:
|
|
1018
|
+
continue
|
|
1019
|
+
if record[2]:
|
|
1020
|
+
paths.add(os.fsdecode(record[2]))
|
|
1021
|
+
else:
|
|
1022
|
+
paths.update(os.fsdecode(f) for f in fields[index : index + 2])
|
|
1023
|
+
index += 2
|
|
1024
|
+
return paths
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def check_repo(
|
|
1028
|
+
root,
|
|
1029
|
+
files,
|
|
1030
|
+
site_roots=("",),
|
|
1031
|
+
*,
|
|
1032
|
+
prefix=None,
|
|
1033
|
+
max_path=DEFAULT_MAX_PATH,
|
|
1034
|
+
disabled=frozenset(),
|
|
1035
|
+
check=False,
|
|
1036
|
+
tracked=frozenset(),
|
|
1037
|
+
held=frozenset(),
|
|
1038
|
+
temporary_index=False,
|
|
1039
|
+
):
|
|
1040
|
+
"""Check the sites in repo `root`; return a list of (path, issues, fixed).
|
|
1041
|
+
|
|
1042
|
+
`files` are the repo-relative paths to consider (normally `git ls-files`)
|
|
1043
|
+
and `site_roots` the repo-relative folders holding a site ("" is the repo
|
|
1044
|
+
root). `prefix` is the server folder the repo is cloned into (see
|
|
1045
|
+
server_prefix(); by default the default server root, the repo's name and
|
|
1046
|
+
branch main). This is the programmatic entry point: it does no printing.
|
|
1047
|
+
|
|
1048
|
+
Unless `check`, the .bes files the fix codes (E803-E805) apply to are
|
|
1049
|
+
renamed: with `git mv` when they are in `tracked`, otherwise with a
|
|
1050
|
+
plain filesystem rename. A file in `held` (see pre_commit_stashed_paths)
|
|
1051
|
+
is not renamed (E806); with `temporary_index` (see uses_real_index)
|
|
1052
|
+
nothing is (E806). Codes in `disabled` are dropped; for a fix code
|
|
1053
|
+
that also stops that part of the rename.
|
|
1054
|
+
"""
|
|
1055
|
+
if prefix is None:
|
|
1056
|
+
prefix = server_prefix(
|
|
1057
|
+
DEFAULT_SERVER_ROOT, default_repo_name(root), DEFAULT_BRANCH
|
|
1058
|
+
)
|
|
1059
|
+
results = _Results()
|
|
1060
|
+
gathered = set()
|
|
1061
|
+
for site, site_files in _assign_sites(files, site_roots).items():
|
|
1062
|
+
config = read_site_config(root, site, files)
|
|
1063
|
+
if config.issue:
|
|
1064
|
+
results.issue(_join(site, SITE_CONFIG), *config.issue[1:])
|
|
1065
|
+
bes_paths = _check_layout(results, site, site_files, config)
|
|
1066
|
+
_check_ids(results, bes_paths)
|
|
1067
|
+
gathered.update(bes_paths)
|
|
1068
|
+
_check_limits(results, files, gathered, prefix, max_path, disabled)
|
|
1069
|
+
renamer = _Renamer(root, files, prefix, max_path, disabled, held)
|
|
1070
|
+
renamer.temporary_index = temporary_index
|
|
1071
|
+
for path in sorted(gathered):
|
|
1072
|
+
renamer.plan(results, path)
|
|
1073
|
+
renamer.apply(results, check, tracked)
|
|
1074
|
+
return results.as_list(disabled)
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def main(argv=None, list_files=git_ls_files):
|
|
1078
|
+
"""Execution starts here.
|
|
1079
|
+
|
|
1080
|
+
`list_files(repo_root)` returns the repo-relative files to check; tests
|
|
1081
|
+
can pass their own instead of the default `git ls-files`.
|
|
1082
|
+
"""
|
|
1083
|
+
parser = argparse.ArgumentParser(
|
|
1084
|
+
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
|
1085
|
+
)
|
|
1086
|
+
parser.add_argument(
|
|
1087
|
+
"--check",
|
|
1088
|
+
action="store_true",
|
|
1089
|
+
help="dry run: report what would be renamed, change nothing",
|
|
1090
|
+
)
|
|
1091
|
+
parser.add_argument(
|
|
1092
|
+
"--strict",
|
|
1093
|
+
action="store_true",
|
|
1094
|
+
help="treat warnings (W8xx) as failures; default: advisory",
|
|
1095
|
+
)
|
|
1096
|
+
parser.add_argument(
|
|
1097
|
+
"--disable",
|
|
1098
|
+
default="",
|
|
1099
|
+
metavar="CODES",
|
|
1100
|
+
help="comma-separated check IDs to skip entirely, e.g. --disable W806",
|
|
1101
|
+
)
|
|
1102
|
+
parser.add_argument(
|
|
1103
|
+
"--site-root",
|
|
1104
|
+
action="append",
|
|
1105
|
+
default=[],
|
|
1106
|
+
metavar="DIR",
|
|
1107
|
+
help=(
|
|
1108
|
+
"a folder holding a site, relative to the current folder; repeat "
|
|
1109
|
+
"for a repo of several sites (default: the repo root)"
|
|
1110
|
+
),
|
|
1111
|
+
)
|
|
1112
|
+
parser.add_argument(
|
|
1113
|
+
"--server-root",
|
|
1114
|
+
default=DEFAULT_SERVER_ROOT,
|
|
1115
|
+
help=f"the BES Server install folder (default: {DEFAULT_SERVER_ROOT})",
|
|
1116
|
+
)
|
|
1117
|
+
parser.add_argument(
|
|
1118
|
+
"--repo-name",
|
|
1119
|
+
help=(
|
|
1120
|
+
"the repo name the server names its clone after (default: the last "
|
|
1121
|
+
"part of the origin remote URL, or the repo folder's name)"
|
|
1122
|
+
),
|
|
1123
|
+
)
|
|
1124
|
+
parser.add_argument(
|
|
1125
|
+
"--branch",
|
|
1126
|
+
default=DEFAULT_BRANCH,
|
|
1127
|
+
help=f"the branch the site syncs from (default: {DEFAULT_BRANCH})",
|
|
1128
|
+
)
|
|
1129
|
+
parser.add_argument(
|
|
1130
|
+
"--max-path",
|
|
1131
|
+
type=int,
|
|
1132
|
+
default=DEFAULT_MAX_PATH,
|
|
1133
|
+
metavar="N",
|
|
1134
|
+
help=f"longest allowed server path (default: {DEFAULT_MAX_PATH})",
|
|
1135
|
+
)
|
|
1136
|
+
args = parser.parse_args(argv)
|
|
1137
|
+
# a file name that is not UTF-8 holds lone surrogates; show them escaped
|
|
1138
|
+
# rather than fail on print
|
|
1139
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
1140
|
+
sys.stdout.reconfigure(errors="backslashreplace")
|
|
1141
|
+
|
|
1142
|
+
disabled = {
|
|
1143
|
+
code.strip().upper() for code in args.disable.split(",") if code.strip()
|
|
1144
|
+
}
|
|
1145
|
+
unknown = disabled - KNOWN_CODES
|
|
1146
|
+
if unknown:
|
|
1147
|
+
print(
|
|
1148
|
+
f"warning: ignoring unknown --disable code(s): {', '.join(sorted(unknown))}"
|
|
1149
|
+
)
|
|
1150
|
+
|
|
1151
|
+
root = repo_root()
|
|
1152
|
+
if root is None:
|
|
1153
|
+
print("bes-siterepo-validate-layout: not in a git repo (git rev-parse failed)")
|
|
1154
|
+
return 1
|
|
1155
|
+
try:
|
|
1156
|
+
files = list_files(root)
|
|
1157
|
+
except OSError as err:
|
|
1158
|
+
print(f"bes-siterepo-validate-layout: {err}")
|
|
1159
|
+
return 1
|
|
1160
|
+
try:
|
|
1161
|
+
tracked = set(files if list_files is git_ls_files else git_ls_files(root))
|
|
1162
|
+
except OSError:
|
|
1163
|
+
tracked = set()
|
|
1164
|
+
site_roots = []
|
|
1165
|
+
for site in args.site_root or [root]:
|
|
1166
|
+
rel = os.path.relpath(os.path.realpath(site), os.path.realpath(root))
|
|
1167
|
+
if rel == os.pardir or rel.startswith(os.pardir + os.sep):
|
|
1168
|
+
print(f"bes-siterepo-validate-layout: --site-root {site} is outside {root}")
|
|
1169
|
+
return 1
|
|
1170
|
+
site_roots.append("" if rel == os.curdir else rel.replace(os.sep, "/"))
|
|
1171
|
+
|
|
1172
|
+
issue_count, warning_count, fix_count = bes_common.report(
|
|
1173
|
+
check_repo(
|
|
1174
|
+
root,
|
|
1175
|
+
files,
|
|
1176
|
+
site_roots,
|
|
1177
|
+
prefix=server_prefix(
|
|
1178
|
+
args.server_root, args.repo_name or default_repo_name(root), args.branch
|
|
1179
|
+
),
|
|
1180
|
+
max_path=args.max_path,
|
|
1181
|
+
disabled=disabled,
|
|
1182
|
+
check=args.check,
|
|
1183
|
+
tracked=tracked,
|
|
1184
|
+
held=set() if args.check else pre_commit_stashed_paths(root),
|
|
1185
|
+
temporary_index=not uses_real_index(root),
|
|
1186
|
+
)
|
|
1187
|
+
)
|
|
1188
|
+
if fix_count:
|
|
1189
|
+
print(
|
|
1190
|
+
f"\nrenamed .bes file(s) ({fix_count} fix(es)); tracked files are "
|
|
1191
|
+
"renamed with git mv, so the renames are staged: review them and "
|
|
1192
|
+
"commit again."
|
|
1193
|
+
)
|
|
1194
|
+
if warning_count:
|
|
1195
|
+
print(f"{warning_count} site layout warning(s).")
|
|
1196
|
+
if issue_count:
|
|
1197
|
+
print(f"{issue_count} site layout issue(s).")
|
|
1198
|
+
# E-codes and any rename always fail; warnings fail only under --strict
|
|
1199
|
+
return 1 if (issue_count or fix_count or (warning_count and args.strict)) else 0
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
if __name__ == "__main__":
|
|
1203
|
+
sys.exit(main(sys.argv[1:]))
|