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.
@@ -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,6 @@
1
+ """``python -m bigfix_relevance_debugger``."""
2
+
3
+ from bigfix_relevance_debugger.app import main
4
+
5
+ if __name__ in {"__main__", "__mp_main__"}:
6
+ main()
@@ -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]