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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,407 @@
1
+ #!/usr/bin/env python3
2
+ """Convert a BES file's console-exported <GroupRelevance> into plain <Relevance>.
3
+
4
+ The BigFix console can save a Fixlet/Task/Baseline/Analysis/FixletStream's
5
+ applicability as a computer-group style <GroupRelevance>: a list of search
6
+ components, each carrying its own already-built <Relevance>, joined by
7
+ intersection (all must hold) or union (any may hold). BES.xsd allows either
8
+ that or a run of <Relevance> elements in the same place, and plain relevance is
9
+ what reads and diffs well in source control, so this hook rewrites
10
+
11
+ <GroupRelevance JoinByIntersection="true"> one <Relevance> per component
12
+ (sibling Relevance must all hold)
13
+ <GroupRelevance JoinByIntersection="false"> one <Relevance>(a) OR (b)</Relevance>,
14
+ or just <Relevance>a</Relevance>
15
+ when there is one component
16
+
17
+ A GroupRelevance is found with the XPath /BES/*/GroupRelevance, and parsed with
18
+ lxml; the rewrite itself is a text splice, so everything outside the replaced
19
+ block -- formatting, comments, CDATA, line endings -- is left byte-identical.
20
+ Each component's <Relevance> is copied verbatim (CDATA stays CDATA, escaped
21
+ stays escaped) except when union-joining several, where the decoded texts are
22
+ joined and written once: as CDATA when any input was CDATA or the text holds
23
+ `<`, `>` or `&`, entity-escaped when it contains `]]>`, and plain otherwise.
24
+
25
+ Checks (E-codes fail the hook; W-codes are advisory unless --strict):
26
+
27
+ E700 a GroupRelevance was converted (or, under --check, needs converting)
28
+ E701 a GroupRelevance holds a SearchComponentGroupReference (or another
29
+ component with no Relevance); converting group membership is not
30
+ supported yet, so that whole GroupRelevance is left unchanged
31
+ E702 JoinByIntersection is not an xs:boolean, or is missing while there
32
+ are several components (with one, AND and OR are the same, so it
33
+ converts); left unchanged
34
+ E703 a GroupRelevance has no components, or one with an empty Relevance;
35
+ left unchanged
36
+ E704 the converted file would no longer pass BES.xsd schema validation;
37
+ nothing is written
38
+ W700 skipped: file not found, not parseable XML, or a GroupRelevance that
39
+ could not be located for the splice
40
+
41
+ The hook converts in place by default and exits 1 when it changed anything,
42
+ so pre-commit shows the rewrite for review. --check is a dry run: it only
43
+ reports. When no files are given, every *.bes under the current folder is
44
+ checked (never rewritten). A file carrying the text
45
+
46
+ pre-commit-skip: bes-relevance-convert-group
47
+
48
+ anywhere is skipped, as is an unrendered mustache template.
49
+ """
50
+
51
+ import argparse
52
+ import os
53
+ import re
54
+ import sys
55
+
56
+ from lxml import etree
57
+
58
+ if __package__ in (None, ""): # run directly as a script, not as a module
59
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
60
+
61
+ from pre_commit_bigfix import bes_common
62
+ from pre_commit_bigfix.bes_common import MUSTACHE_RE, encode, lineno, read_source
63
+
64
+ SKIP_MARKER = "pre-commit-skip: bes-relevance-convert-group"
65
+ BES_EXTENSIONS = (".bes",)
66
+ KNOWN_CODES = frozenset({"E700", "E701", "E702", "E703", "E704", "W700"})
67
+
68
+ # where BES.xsd allows a GroupRelevance: directly in a content object
69
+ GROUP_RELEVANCE_XPATH = "/BES/*/GroupRelevance"
70
+ # search components whose <Relevance> child is the component's whole test
71
+ RELEVANCE_COMPONENTS = frozenset(
72
+ {"SearchComponentRelevance", "SearchComponentPropertyReference"}
73
+ )
74
+
75
+ # comments and CDATA are blanked out (same length) before the regexes below
76
+ # run, so tag-like text inside them cannot be mistaken for markup
77
+ OPAQUE_RE = re.compile(r"<!--.*?-->|<!\[CDATA\[.*?\]\]>", re.DOTALL)
78
+ GROUP_RELEVANCE_RE = re.compile(
79
+ r"<GroupRelevance\b[^>]*?(?:/>|>.*?</GroupRelevance>)", re.DOTALL
80
+ )
81
+ RELEVANCE_RE = re.compile(r"<Relevance\b[^>]*>(.*?)</Relevance>", re.DOTALL)
82
+ CDATA_START = "<![CDATA["
83
+ XS_BOOLEAN = {"true": True, "1": True, "false": False, "0": False}
84
+
85
+
86
+ def _mask_opaque(src):
87
+ """Return `src` with comment and CDATA text blanked, offsets unchanged."""
88
+ return OPAQUE_RE.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), src)
89
+
90
+
91
+ def _text(element):
92
+ """The element's own text as the evaluator sees it (CDATA unwrapped)."""
93
+ return "".join(element.xpath("text()"))
94
+
95
+
96
+ def _escape(text):
97
+ """Entity-escape `text` for an XML element body."""
98
+ return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
99
+
100
+
101
+ def _component_relevance(component):
102
+ """Return the relevance a search component stands for, or None.
103
+
104
+ None means the component has no relevance of its own to copy: a
105
+ SearchComponentGroupReference names a computer group, and turning
106
+ membership into relevance needs a template this hook does not have yet.
107
+ """
108
+ if component.tag not in RELEVANCE_COMPONENTS:
109
+ return None
110
+ relevance = component.find("Relevance")
111
+ return "" if relevance is None else _text(relevance)
112
+
113
+
114
+ def _components(group):
115
+ """The search-component elements of `group` (comments left out)."""
116
+ return [child for child in group if isinstance(child.tag, str)]
117
+
118
+
119
+ def _intersection(group):
120
+ """Return True/False for how `group` joins its components, or None.
121
+
122
+ None means it cannot be told. BES.xsd makes JoinByIntersection optional
123
+ and documents no default, so a missing attribute is only safe with a
124
+ single component, where AND and OR mean the same thing.
125
+ """
126
+ join = group.get("JoinByIntersection")
127
+ if join is None:
128
+ return True if len(_components(group)) == 1 else None
129
+ return XS_BOOLEAN.get(join.strip())
130
+
131
+
132
+ def _refusal(group):
133
+ """Return (code, message) if `group` cannot be converted, else None."""
134
+ components = _components(group)
135
+ for component in components:
136
+ if _component_relevance(component) is None:
137
+ if component.tag == "SearchComponentGroupReference":
138
+ what = (
139
+ f'a SearchComponentGroupReference (GroupName "'
140
+ f'{component.get("GroupName", "")}")'
141
+ )
142
+ else:
143
+ what = f"a <{component.tag}> component"
144
+ return (
145
+ "E701",
146
+ (
147
+ f"GroupRelevance holds {what}; converting group membership is "
148
+ "not supported yet, so it was left unchanged"
149
+ ),
150
+ )
151
+ # before the join check: with nothing to join, how they join is moot
152
+ if not components:
153
+ return "E703", "GroupRelevance has no search components; left unchanged"
154
+ if _intersection(group) is None:
155
+ found = group.get("JoinByIntersection")
156
+ detail = (
157
+ f"missing and it has {len(components)} components"
158
+ if found is None
159
+ else f'"{found}"'
160
+ )
161
+ return (
162
+ "E702",
163
+ (
164
+ f"GroupRelevance JoinByIntersection is {detail}, so how they join "
165
+ 'is unknown; add JoinByIntersection="true" (all must hold) or '
166
+ 'JoinByIntersection="false" (any may hold). Left unchanged'
167
+ ),
168
+ )
169
+ for number, component in enumerate(components, start=1):
170
+ if not _component_relevance(component).strip():
171
+ return (
172
+ "E703",
173
+ (
174
+ f"GroupRelevance component {number} (<{component.tag}>) has an "
175
+ "empty Relevance; left unchanged"
176
+ ),
177
+ )
178
+ return None
179
+
180
+
181
+ def _union_body(statements, any_cdata):
182
+ """The body of one <Relevance> OR-joining the decoded `statements`."""
183
+ joined = " OR ".join(f"({statement.strip()})" for statement in statements)
184
+ if "]]>" in joined:
185
+ return _escape(joined) # cannot sit inside a single CDATA section
186
+ if any_cdata or any(char in joined for char in "<>&"):
187
+ return f"{CDATA_START}{joined}]]>"
188
+ return joined
189
+
190
+
191
+ def _replacement(src, span, raw_inners, statements, intersection):
192
+ """Return the text that replaces the GroupRelevance at `span`."""
193
+ if intersection or len(statements) == 1:
194
+ bodies = raw_inners
195
+ else:
196
+ any_cdata = any(raw.strip().startswith(CDATA_START) for raw in raw_inners)
197
+ bodies = [_union_body(statements, any_cdata)]
198
+ line_start = src.rfind("\n", 0, span[0]) + 1
199
+ indent = src[line_start : span[0]]
200
+ separator = "\n" + indent if not indent.strip() else ""
201
+ return separator.join(f"<Relevance>{body}</Relevance>" for body in bodies)
202
+
203
+
204
+ def _describe(statements, intersection, join):
205
+ """E700's account of what a GroupRelevance became; `join` is the attribute."""
206
+ count = len(statements)
207
+ if intersection:
208
+ result = f"{count} <Relevance> element(s)"
209
+ elif count == 1:
210
+ result = "one <Relevance>"
211
+ else:
212
+ result = "one OR-joined <Relevance>"
213
+ join = "missing" if join is None else join.strip()
214
+ return (
215
+ f"GroupRelevance (JoinByIntersection {join}, {count} component(s)) -> {result}"
216
+ )
217
+
218
+
219
+ def convert_group_relevance(src):
220
+ """Convert every convertible GroupRelevance in BES text `src`.
221
+
222
+ Returns (new_src, converted, issues): `converted` lists an E700 per
223
+ GroupRelevance rewritten, `issues` the E701-E703 refusals and any W700.
224
+ Each entry is (lineno, code, message). Pure: no file is touched.
225
+ """
226
+ try:
227
+ root = etree.fromstring(src.encode("utf-8"))
228
+ except etree.XMLSyntaxError as err:
229
+ return src, [], [(1, "W700", f"not parseable BES XML ({err}); skipping")]
230
+
231
+ groups = root.xpath(GROUP_RELEVANCE_XPATH)
232
+ # no tag anywhere: nothing to convert and no misplaced one to warn about
233
+ if "<GroupRelevance" not in src:
234
+ return src, [], []
235
+
236
+ masked = _mask_opaque(src)
237
+ spans = [match.span() for match in GROUP_RELEVANCE_RE.finditer(masked)]
238
+ if len(spans) != len(groups):
239
+ return (
240
+ src,
241
+ [],
242
+ [
243
+ (
244
+ 1,
245
+ "W700",
246
+ (
247
+ f"found {len(spans)} <GroupRelevance> tag(s) but "
248
+ f"{len(groups)} at {GROUP_RELEVANCE_XPATH}; skipping"
249
+ ),
250
+ )
251
+ ],
252
+ )
253
+
254
+ converted, issues, edits = [], [], []
255
+ for group, span in zip(groups, spans):
256
+ line = lineno(src, span[0])
257
+ refusal = _refusal(group)
258
+ if refusal:
259
+ issues.append((line, *refusal))
260
+ continue
261
+ statements = [_component_relevance(c) for c in _components(group)]
262
+ raw_inners = [
263
+ src[m.start(1) + span[0] : m.end(1) + span[0]]
264
+ for m in RELEVANCE_RE.finditer(masked[span[0] : span[1]])
265
+ ]
266
+ if len(raw_inners) != len(statements):
267
+ issues.append(
268
+ (
269
+ line,
270
+ "W700",
271
+ "could not locate each component's <Relevance>; skipping",
272
+ )
273
+ )
274
+ continue
275
+ intersection = _intersection(group)
276
+ edits.append(
277
+ (span, _replacement(src, span, raw_inners, statements, intersection))
278
+ )
279
+ converted.append(
280
+ (
281
+ line,
282
+ "E700",
283
+ _describe(statements, intersection, group.get("JoinByIntersection")),
284
+ )
285
+ )
286
+
287
+ for (start, end), text in reversed(edits):
288
+ src = src[:start] + text + src[end:]
289
+ return src, converted, issues
290
+
291
+
292
+ def check_file(path, disabled=frozenset(), check=False):
293
+ """Check (and unless `check`, convert) one file; return (issues, fixed).
294
+
295
+ Each is a list of (lineno, code, message). Under `check`, a convertible
296
+ GroupRelevance is an E700 issue and nothing is written; otherwise it is
297
+ rewritten in place (line endings preserved) and reported under `fixed`,
298
+ unless the result would break BES.xsd validity (E704). Codes in
299
+ `disabled` are dropped; disabling E700 also stops the conversion.
300
+ """
301
+ if not os.path.isfile(path):
302
+ return [(1, "W700", "file not found; skipping")], []
303
+
304
+ _raw, src, was_crlf = read_source(path)
305
+ if SKIP_MARKER in src or MUSTACHE_RE.search(src):
306
+ return [], []
307
+
308
+ new_src, converted, issues = convert_group_relevance(src)
309
+ fixed = []
310
+ if "E700" in disabled:
311
+ converted = []
312
+ elif check:
313
+ issues += [
314
+ (line, code, f"{message}; run without --check to convert")
315
+ for line, code, message in converted
316
+ ]
317
+ elif converted:
318
+ guard = bes_common.SchemaGuard(
319
+ src, "E704", path, validate="E704" not in disabled
320
+ )
321
+ if guard.apply(new_src, converted):
322
+ with open(path, "wb") as handle:
323
+ handle.write(encode(new_src, was_crlf))
324
+ fixed = converted
325
+ issues += guard.refused
326
+
327
+ issues = [issue for issue in issues if issue[1] not in disabled]
328
+ return sorted(issues), fixed
329
+
330
+
331
+ def check_files(paths, disabled=frozenset(), check=False):
332
+ """Check several files; return a list of (path, issues, fixed) tuples.
333
+
334
+ This is the programmatic entry point: it does no printing.
335
+ """
336
+ return [(path, *check_file(path, disabled=disabled, check=check)) for path in paths]
337
+
338
+
339
+ def main(argv=None):
340
+ """Execution starts here.
341
+
342
+ argv defaults to None so this works both as a console_scripts entry point
343
+ (pre-commit calls it with no arguments; argparse then reads sys.argv) and
344
+ when called directly as `main(sys.argv[1:])`.
345
+ """
346
+ parser = argparse.ArgumentParser(
347
+ description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
348
+ )
349
+ parser.add_argument(
350
+ "--check",
351
+ action="store_true",
352
+ help="dry run: report what would be converted, write nothing",
353
+ )
354
+ parser.add_argument(
355
+ "--strict",
356
+ action="store_true",
357
+ help="treat warnings (W700) as failures; default: advisory",
358
+ )
359
+ parser.add_argument(
360
+ "--disable",
361
+ default="",
362
+ metavar="CODES",
363
+ help="comma-separated check IDs to skip entirely, e.g. --disable E701",
364
+ )
365
+ parser.add_argument(
366
+ "files",
367
+ nargs="*",
368
+ help=(
369
+ "BES files to convert; if omitted, all *.bes files in the current "
370
+ "folder and below are checked (never rewritten)"
371
+ ),
372
+ )
373
+ args = parser.parse_args(argv)
374
+
375
+ disabled = {
376
+ code.strip().upper() for code in args.disable.split(",") if code.strip()
377
+ }
378
+ unknown = disabled - KNOWN_CODES
379
+ if unknown:
380
+ print(
381
+ f"warning: ignoring unknown --disable code(s): {', '.join(sorted(unknown))}"
382
+ )
383
+
384
+ # like the sibling hooks, auto-discovery never rewrites a whole tree
385
+ check = args.check or not args.files
386
+ paths = (
387
+ args.files if args.files else bes_common.discover_bes_files(".", BES_EXTENSIONS)
388
+ )
389
+
390
+ issue_count, warning_count, fix_count = bes_common.report(
391
+ check_files(paths, disabled=disabled, check=check)
392
+ )
393
+
394
+ if fix_count:
395
+ print(
396
+ f"\nconverted {fix_count} GroupRelevance(s); review and re-stage the changes."
397
+ )
398
+ if warning_count:
399
+ print(f"{warning_count} GroupRelevance warning(s).")
400
+ if issue_count:
401
+ print(f"{issue_count} GroupRelevance issue(s).")
402
+ # E-codes and any conversion always fail; warnings fail only under --strict
403
+ return 1 if (issue_count or fix_count or (warning_count and args.strict)) else 0
404
+
405
+
406
+ if __name__ == "__main__":
407
+ sys.exit(main(sys.argv[1:]))