bigfix-relevance-debugger 0.1.0__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.
- bigfix_relevance_debugger/__init__.py +15 -0
- bigfix_relevance_debugger/__main__.py +6 -0
- bigfix_relevance_debugger/analysis.py +284 -0
- bigfix_relevance_debugger/app.py +1276 -0
- bigfix_relevance_debugger/evaluation.py +1223 -0
- bigfix_relevance_debugger/log_bridge.py +88 -0
- bigfix_relevance_debugger/upstream/__init__.py +17 -0
- bigfix_relevance_debugger/upstream/besapi.py +114 -0
- bigfix_relevance_debugger/upstream/relevance_analyzer.py +146 -0
- bigfix_relevance_debugger/upstream/remote_client_relevance.py +121 -0
- bigfix_relevance_debugger-0.1.0.dist-info/METADATA +337 -0
- bigfix_relevance_debugger-0.1.0.dist-info/RECORD +15 -0
- bigfix_relevance_debugger-0.1.0.dist-info/WHEEL +4 -0
- bigfix_relevance_debugger-0.1.0.dist-info/entry_points.txt +2 -0
- bigfix_relevance_debugger-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""A browser GUI for BigFix client and session relevance.
|
|
2
|
+
|
|
3
|
+
Live static analysis via ``bigfix-relevance-analyzer``, client relevance
|
|
4
|
+
evaluated with real ``qna`` via ``bigfix-remote-client-relevance``, session
|
|
5
|
+
relevance on a root server via ``besapi``, and a per-level breakdown that
|
|
6
|
+
shows where an expression goes empty on a given endpoint.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from importlib.metadata import PackageNotFoundError
|
|
10
|
+
from importlib.metadata import version as _version
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
__version__ = _version("bigfix-relevance-debugger")
|
|
14
|
+
except PackageNotFoundError: # pragma: no cover - only when run from a source tree
|
|
15
|
+
__version__ = "0.0.0+unknown"
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Static analysis of relevance, flattened into a view-model.
|
|
2
|
+
|
|
3
|
+
Pure and synchronous: wraps ``bigfix-relevance-analyzer`` and returns frozen
|
|
4
|
+
dataclasses of plain values, so the UI never touches analyzer types and this
|
|
5
|
+
module can be tested without a browser. No NiceGUI imports here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from bigfix_relevance_analyzer import (
|
|
13
|
+
Dialect,
|
|
14
|
+
LintConfig,
|
|
15
|
+
analyze_relevance,
|
|
16
|
+
lint_text,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from bigfix_relevance_debugger.upstream.relevance_analyzer import (
|
|
20
|
+
DialectFit,
|
|
21
|
+
client_platforms,
|
|
22
|
+
dialect_fit,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
CLIENT = "client"
|
|
26
|
+
SESSION = "session"
|
|
27
|
+
AUTO = "auto"
|
|
28
|
+
DIALECT_MODES = {
|
|
29
|
+
AUTO: (),
|
|
30
|
+
CLIENT: (CLIENT,),
|
|
31
|
+
SESSION: (SESSION,),
|
|
32
|
+
"both": (SESSION, CLIENT),
|
|
33
|
+
}
|
|
34
|
+
"""Dialect picker choices -> what runs. Session first: its results list first."""
|
|
35
|
+
|
|
36
|
+
SESSION_BREAKDOWN_REASON = (
|
|
37
|
+
"Breakdown runs client relevance; session relevance can't break down yet."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class ParseErrorView:
|
|
43
|
+
message: str
|
|
44
|
+
offset: int
|
|
45
|
+
"""0-based ``str`` index into the text, usable as an editor offset."""
|
|
46
|
+
line: int
|
|
47
|
+
column: int
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class FindingView:
|
|
52
|
+
severity: str
|
|
53
|
+
"""``"error"`` or ``"warning"`` (``Severity`` value)."""
|
|
54
|
+
code: str
|
|
55
|
+
message: str
|
|
56
|
+
line: int | None
|
|
57
|
+
suggestions: tuple[str, ...]
|
|
58
|
+
spans: tuple[tuple[int, int], ...]
|
|
59
|
+
"""``(start, end)`` ``str`` offsets into the text."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class LevelView:
|
|
64
|
+
index: int
|
|
65
|
+
label: str
|
|
66
|
+
start: int
|
|
67
|
+
end: int
|
|
68
|
+
line: int
|
|
69
|
+
column: int
|
|
70
|
+
probe: str
|
|
71
|
+
"""Count-probe relevance to evaluate for this level."""
|
|
72
|
+
unfiltered_probe: str | None
|
|
73
|
+
"""Set on ``whose`` levels: the same collection without its filter."""
|
|
74
|
+
is_whole_expression: bool
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class AnalysisView:
|
|
79
|
+
text: str
|
|
80
|
+
parse_error: ParseErrorView | None
|
|
81
|
+
dialect_mode: str
|
|
82
|
+
"""The picker's choice: ``"auto"`` or one of the overrides in ``DIALECT_MODES``."""
|
|
83
|
+
run_dialects: tuple[str, ...]
|
|
84
|
+
"""What Evaluate runs: ``"session"`` and/or ``"client"``, session first."""
|
|
85
|
+
dialect_reason: str
|
|
86
|
+
"""User-facing: why ``run_dialects`` is what it is."""
|
|
87
|
+
type_summary: str | None
|
|
88
|
+
"""E.g. ``"singular string"``; None when the text did not parse."""
|
|
89
|
+
type_errors: tuple[str, ...]
|
|
90
|
+
platforms: tuple[str, ...]
|
|
91
|
+
"""Client platforms the expression's inspectors exist on (``"debian"``,
|
|
92
|
+
``"macos"``, ``"rhel"``, ``"ubuntu"``, ``"windows"``), sorted. Empty when
|
|
93
|
+
the text did not parse."""
|
|
94
|
+
complexity_score: float
|
|
95
|
+
evaluation_cost: float
|
|
96
|
+
costly_inspectors: tuple[str, ...]
|
|
97
|
+
findings: tuple[FindingView, ...]
|
|
98
|
+
levels: tuple[LevelView, ...]
|
|
99
|
+
can_breakdown: bool
|
|
100
|
+
breakdown_disabled_reason: str | None
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def dialect(self) -> str:
|
|
104
|
+
"""For display: ``"client"``, ``"session"`` or ``"session + client"``."""
|
|
105
|
+
return " + ".join(self.run_dialects)
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def session_optional(self) -> bool:
|
|
109
|
+
"""Auto added session beside client, so nobody asked for it.
|
|
110
|
+
|
|
111
|
+
Then a missing root server configuration isn't worth an error row.
|
|
112
|
+
"""
|
|
113
|
+
return self.dialect_mode == AUTO and self.run_dialects == (SESSION, CLIENT)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _type_summary(check: object) -> str | None:
|
|
117
|
+
value = getattr(check, "value", None)
|
|
118
|
+
if value is None:
|
|
119
|
+
return None
|
|
120
|
+
types = value.types
|
|
121
|
+
plurality = value.plurality.value
|
|
122
|
+
if not types:
|
|
123
|
+
return f"{plurality} (unknown type)" if plurality != "unknown" else "unknown"
|
|
124
|
+
return f"{plurality} {' | '.join(sorted(types))}"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _names(names: tuple[str, ...], limit: int = 3) -> str:
|
|
128
|
+
shown = ", ".join(names[:limit])
|
|
129
|
+
return shown + (f" (+{len(names) - limit} more)" if len(names) > limit else "")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _fit_reason(fit: DialectFit) -> str:
|
|
133
|
+
"""Why auto picked what it did, in a phrase."""
|
|
134
|
+
only = next(iter(fit.dialects)) if len(fit.dialects) == 1 else None
|
|
135
|
+
match fit.basis:
|
|
136
|
+
case "resolved":
|
|
137
|
+
names = fit.session_only if only is Dialect.SESSION else fit.client_only
|
|
138
|
+
kind = only.value if only else ""
|
|
139
|
+
return f"only {kind} relevance has {_names(names)}" if names else f"{kind} relevance"
|
|
140
|
+
case "type-check":
|
|
141
|
+
return f"only type-checks as {only.value if only else ''} relevance"
|
|
142
|
+
case "either":
|
|
143
|
+
return "valid as either client or session relevance"
|
|
144
|
+
case "unclear":
|
|
145
|
+
return "type-checks as neither dialect; assuming client relevance"
|
|
146
|
+
case "mixed":
|
|
147
|
+
if fit.session_only and fit.client_only:
|
|
148
|
+
return (
|
|
149
|
+
f"mixes session ({_names(fit.session_only)}) and client "
|
|
150
|
+
f"({_names(fit.client_only)}) inspectors, so trying both"
|
|
151
|
+
)
|
|
152
|
+
return "mixes session and client inspectors, so trying both"
|
|
153
|
+
case "classified":
|
|
154
|
+
return f"looks like {only.value if only else ''} relevance"
|
|
155
|
+
case _:
|
|
156
|
+
return "nothing says which dialect; assuming client relevance"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _run_dialects(text: str, mode: str) -> tuple[tuple[str, ...], str]:
|
|
160
|
+
if mode not in DIALECT_MODES:
|
|
161
|
+
raise ValueError(f"unknown dialect mode {mode!r}")
|
|
162
|
+
if mode != AUTO:
|
|
163
|
+
return DIALECT_MODES[mode], f"set to {mode}"
|
|
164
|
+
fit = dialect_fit(text)
|
|
165
|
+
run = tuple(d for d in (SESSION, CLIENT) if Dialect(d) in fit.dialects)
|
|
166
|
+
return run, _fit_reason(fit)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def summarize(text: str, mode: str = AUTO) -> AnalysisView:
|
|
170
|
+
"""Analyze and lint ``text``, and decide which dialects to run it as.
|
|
171
|
+
|
|
172
|
+
``mode`` is the dialect picker: ``"auto"`` decides from the relevance
|
|
173
|
+
itself, anything else in ``DIALECT_MODES`` overrides it. Analysis and lint
|
|
174
|
+
run as session relevance only when that is all that will run; otherwise as
|
|
175
|
+
client relevance, which is also what Breakdown evaluates. Never raises for
|
|
176
|
+
bad relevance.
|
|
177
|
+
"""
|
|
178
|
+
run_dialects, dialect_reason = _run_dialects(text, mode)
|
|
179
|
+
dialect = Dialect.SESSION if run_dialects == (SESSION,) else Dialect.CLIENT
|
|
180
|
+
analysis = analyze_relevance(text, dialect=dialect)
|
|
181
|
+
# suggest=True is fine for one statement; it is the "did you mean" list.
|
|
182
|
+
findings = lint_text(text, LintConfig(dialect=dialect, suggest=True))
|
|
183
|
+
|
|
184
|
+
parse_error = None
|
|
185
|
+
if analysis.parse_error is not None:
|
|
186
|
+
pe = analysis.parse_error
|
|
187
|
+
parse_error = ParseErrorView(
|
|
188
|
+
message=pe.message, offset=pe.offset, line=pe.line, column=pe.column
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
check = analysis.check
|
|
192
|
+
type_errors = tuple(d.message for d in check.diagnostics) if check is not None else ()
|
|
193
|
+
platforms = client_platforms(analysis)
|
|
194
|
+
|
|
195
|
+
levels = tuple(
|
|
196
|
+
LevelView(
|
|
197
|
+
index=i,
|
|
198
|
+
label=level.label,
|
|
199
|
+
start=level.span.start,
|
|
200
|
+
end=level.span.end,
|
|
201
|
+
line=level.span.line,
|
|
202
|
+
column=level.span.column,
|
|
203
|
+
probe=level.probe.relevance,
|
|
204
|
+
unfiltered_probe=level.unfiltered.relevance if level.unfiltered else None,
|
|
205
|
+
# The analyzer emits the whole expression first, then the rest.
|
|
206
|
+
is_whole_expression=i == 0,
|
|
207
|
+
)
|
|
208
|
+
for i, level in enumerate(analysis.levels)
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
if CLIENT not in run_dialects:
|
|
212
|
+
reason: str | None = SESSION_BREAKDOWN_REASON
|
|
213
|
+
elif parse_error is not None:
|
|
214
|
+
reason = f"Breakdown needs a clean parse: {parse_error.message}"
|
|
215
|
+
elif not levels:
|
|
216
|
+
reason = "Nothing to break down in this expression."
|
|
217
|
+
else:
|
|
218
|
+
reason = None
|
|
219
|
+
|
|
220
|
+
complexity = analysis.complexity
|
|
221
|
+
return AnalysisView(
|
|
222
|
+
text=text,
|
|
223
|
+
parse_error=parse_error,
|
|
224
|
+
dialect_mode=mode,
|
|
225
|
+
run_dialects=run_dialects,
|
|
226
|
+
dialect_reason=dialect_reason,
|
|
227
|
+
type_summary=_type_summary(check),
|
|
228
|
+
type_errors=type_errors,
|
|
229
|
+
platforms=platforms,
|
|
230
|
+
complexity_score=complexity.score,
|
|
231
|
+
evaluation_cost=complexity.evaluation_cost,
|
|
232
|
+
costly_inspectors=tuple(complexity.costly_inspectors),
|
|
233
|
+
findings=tuple(
|
|
234
|
+
FindingView(
|
|
235
|
+
severity=f.severity.value,
|
|
236
|
+
code=f.code,
|
|
237
|
+
message=f.message,
|
|
238
|
+
line=f.line,
|
|
239
|
+
suggestions=tuple(f.suggestions),
|
|
240
|
+
spans=tuple((s.start, s.end) for s in f.spans),
|
|
241
|
+
)
|
|
242
|
+
for f in findings
|
|
243
|
+
),
|
|
244
|
+
levels=levels,
|
|
245
|
+
can_breakdown=reason is None,
|
|
246
|
+
breakdown_disabled_reason=reason,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _mark(text: str, start: int, end: int, css_class: str) -> dict[str, object] | None:
|
|
251
|
+
"""A CodeMirror mark spec, or None when the range can't be one.
|
|
252
|
+
|
|
253
|
+
NiceGUI rejects an empty or inverted mark with a ValueError on assignment,
|
|
254
|
+
so anything degenerate is dropped here rather than taking the editor down.
|
|
255
|
+
"""
|
|
256
|
+
if not 0 <= start < end <= len(text):
|
|
257
|
+
return None
|
|
258
|
+
return {"kind": "mark", "from": start, "to": end, "class": css_class}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def editor_decorations(
|
|
262
|
+
view: AnalysisView, highlight: tuple[int, int] | None = None
|
|
263
|
+
) -> list[dict[str, object]]:
|
|
264
|
+
"""CodeMirror decoration specs for ``view``: parse error, lint findings, a level span.
|
|
265
|
+
|
|
266
|
+
Offsets are ``str`` indices into ``view.text``, which is what NiceGUI's
|
|
267
|
+
``codemirror.decorations`` expects. CSS classes are ``bfrd-*``; the page
|
|
268
|
+
ships the styles.
|
|
269
|
+
"""
|
|
270
|
+
text = view.text
|
|
271
|
+
marks: list[dict[str, object] | None] = []
|
|
272
|
+
if view.parse_error is not None:
|
|
273
|
+
# An error at end of text (e.g. a missing ')') has nothing after it to
|
|
274
|
+
# mark, so mark the last character instead.
|
|
275
|
+
start = min(view.parse_error.offset, len(text) - 1)
|
|
276
|
+
marks.append(_mark(text, start, start + 1, "bfrd-parse-error"))
|
|
277
|
+
for finding in view.findings:
|
|
278
|
+
if finding.code == "parse-error":
|
|
279
|
+
continue # already marked above
|
|
280
|
+
for start, end in finding.spans:
|
|
281
|
+
marks.append(_mark(text, start, end, f"bfrd-{finding.severity}"))
|
|
282
|
+
if highlight is not None:
|
|
283
|
+
marks.append(_mark(text, highlight[0], highlight[1], "bfrd-level"))
|
|
284
|
+
return [m for m in marks if m is not None]
|