cadence-diff 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.
- cadence_diff-0.1.0.dist-info/METADATA +245 -0
- cadence_diff-0.1.0.dist-info/RECORD +78 -0
- cadence_diff-0.1.0.dist-info/WHEEL +4 -0
- cadence_diff-0.1.0.dist-info/entry_points.txt +3 -0
- cadence_diff-0.1.0.dist-info/licenses/LICENSE +190 -0
- qc_tool/__init__.py +3 -0
- qc_tool/__main__.py +6 -0
- qc_tool/attestation.py +209 -0
- qc_tool/availability.py +273 -0
- qc_tool/cli.py +769 -0
- qc_tool/config/__init__.py +1 -0
- qc_tool/config/lint.py +480 -0
- qc_tool/config/profile.py +207 -0
- qc_tool/coverage.py +35 -0
- qc_tool/crosscheck/__init__.py +1 -0
- qc_tool/crosscheck/numbers.py +146 -0
- qc_tool/crosscheck/package.py +154 -0
- qc_tool/crosscheck/trace.py +374 -0
- qc_tool/engine.py +978 -0
- qc_tool/excel/__init__.py +1 -0
- qc_tool/excel/align.py +452 -0
- qc_tool/excel/charts.py +931 -0
- qc_tool/excel/context.py +106 -0
- qc_tool/excel/controls.py +266 -0
- qc_tool/excel/dependency.py +252 -0
- qc_tool/excel/diff_structure.py +326 -0
- qc_tool/excel/diff_values.py +327 -0
- qc_tool/excel/formulas.py +516 -0
- qc_tool/excel/interaction.py +548 -0
- qc_tool/excel/periods.py +117 -0
- qc_tool/excel/preflight.py +615 -0
- qc_tool/excel/references.py +436 -0
- qc_tool/excel/regions.py +201 -0
- qc_tool/findings.py +220 -0
- qc_tool/fingerprint.py +369 -0
- qc_tool/fingerprint.schema.json +15 -0
- qc_tool/history/__init__.py +1 -0
- qc_tool/history/store.py +287 -0
- qc_tool/io/__init__.py +1 -0
- qc_tool/io/decrypt.py +64 -0
- qc_tool/io/excel_formula.py +256 -0
- qc_tool/io/excel_formula_worker.py +280 -0
- qc_tool/io/formula_enrichment.py +95 -0
- qc_tool/io/libreoffice_formula.py +232 -0
- qc_tool/io/loader.py +973 -0
- qc_tool/io/model.py +356 -0
- qc_tool/io/ooxml_chart.py +530 -0
- qc_tool/io/ooxml_interaction.py +400 -0
- qc_tool/io/ooxml_worksheet.py +405 -0
- qc_tool/io/xlsb_formula.py +313 -0
- qc_tool/package-sanitize.schema.json +31 -0
- qc_tool/package_sanitize.py +255 -0
- qc_tool/ppt/__init__.py +1 -0
- qc_tool/ppt/chart_xml.py +422 -0
- qc_tool/ppt/diff.py +120 -0
- qc_tool/ppt/element_diff.py +878 -0
- qc_tool/ppt/element_match.py +317 -0
- qc_tool/ppt/extract.py +212 -0
- qc_tool/ppt/match.py +137 -0
- qc_tool/ppt/model.py +132 -0
- qc_tool/ppt/preflight.py +307 -0
- qc_tool/privacy.py +392 -0
- qc_tool/progress.py +78 -0
- qc_tool/report/__init__.py +1 -0
- qc_tool/report/excel_report.py +205 -0
- qc_tool/report/findings.schema.json +32 -0
- qc_tool/report/html_report.py +38 -0
- qc_tool/report/json_report.py +55 -0
- qc_tool/report/templates/report.html.j2 +117 -0
- qc_tool/sanitize.py +598 -0
- qc_tool/security.py +34 -0
- qc_tool/server_config.py +86 -0
- qc_tool/triage/__init__.py +1 -0
- qc_tool/triage/rules.py +211 -0
- qc_tool/ui/__init__.py +1 -0
- qc_tool/ui/app.py +1261 -0
- qc_tool/ui/guide.py +452 -0
- qc_tool/ui/theme.py +505 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Deliverable profile schema and loading."""
|
qc_tool/config/lint.py
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
"""Profile lint: validate a deliverable profile, optionally against files.
|
|
2
|
+
|
|
3
|
+
Static checks catch unparseable ranges, duplicate mapping anchors, and
|
|
4
|
+
expired waivers. With ``--against`` files, references are resolved for
|
|
5
|
+
real: sheets, cells, control ranges, tie-out references, required slides,
|
|
6
|
+
and cross-check mapping anchors.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import datetime as dt
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
from openpyxl.utils.cell import coordinate_to_tuple, range_boundaries
|
|
15
|
+
|
|
16
|
+
from qc_tool.config.profile import DeliverableProfile
|
|
17
|
+
from qc_tool.crosscheck.trace import extract_deck_figures
|
|
18
|
+
from qc_tool.excel.periods import parse_period
|
|
19
|
+
from qc_tool.io.model import WorkbookSnapshot
|
|
20
|
+
from qc_tool.ppt.extract import DeckSnapshot
|
|
21
|
+
|
|
22
|
+
_SHEET_REF_RE = re.compile(r"^(?:'(?P<quoted>[^']+)'|(?P<plain>[^'!]+))!(?P<ref>.+)$")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class LintIssue:
|
|
27
|
+
level: Literal["error", "warning"]
|
|
28
|
+
where: str
|
|
29
|
+
message: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _check_range(where: str, cell_range: str, issues: list[LintIssue]) -> None:
|
|
33
|
+
try:
|
|
34
|
+
range_boundaries(cell_range)
|
|
35
|
+
except ValueError:
|
|
36
|
+
issues.append(
|
|
37
|
+
LintIssue("error", where, f"range {cell_range!r} is not a valid A1 range")
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _range_bounds(cell_range: str) -> tuple[int, int, int, int] | None:
|
|
42
|
+
try:
|
|
43
|
+
min_col, min_row, max_col, max_row = range_boundaries(cell_range)
|
|
44
|
+
except ValueError:
|
|
45
|
+
return None
|
|
46
|
+
if min_col is None or min_row is None or max_col is None or max_row is None:
|
|
47
|
+
return None
|
|
48
|
+
return min_col, min_row, max_col, max_row
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _split_sheet_ref(text: str) -> tuple[str, str] | None:
|
|
52
|
+
match = _SHEET_REF_RE.match(text)
|
|
53
|
+
if match is None:
|
|
54
|
+
return None
|
|
55
|
+
return (match["quoted"] or match["plain"]), match["ref"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _check_sheet_cell(
|
|
59
|
+
where: str,
|
|
60
|
+
reference: str,
|
|
61
|
+
workbook: WorkbookSnapshot | None,
|
|
62
|
+
issues: list[LintIssue],
|
|
63
|
+
*,
|
|
64
|
+
numeric: bool = False,
|
|
65
|
+
) -> None:
|
|
66
|
+
parts = _split_sheet_ref(reference)
|
|
67
|
+
if parts is None:
|
|
68
|
+
issues.append(
|
|
69
|
+
LintIssue("error", where, f"{reference!r} is not a Sheet!Cell reference")
|
|
70
|
+
)
|
|
71
|
+
return
|
|
72
|
+
sheet_name, ref = parts
|
|
73
|
+
try:
|
|
74
|
+
coordinate_to_tuple(ref)
|
|
75
|
+
except ValueError:
|
|
76
|
+
issues.append(LintIssue("error", where, f"{ref!r} is not a valid cell"))
|
|
77
|
+
return
|
|
78
|
+
if workbook is None:
|
|
79
|
+
return
|
|
80
|
+
try:
|
|
81
|
+
sheet = workbook.sheet(sheet_name)
|
|
82
|
+
except KeyError:
|
|
83
|
+
issues.append(
|
|
84
|
+
LintIssue("error", where, f"sheet {sheet_name!r} not found in workbook")
|
|
85
|
+
)
|
|
86
|
+
return
|
|
87
|
+
cell = sheet.cell(ref)
|
|
88
|
+
if cell is None:
|
|
89
|
+
issues.append(
|
|
90
|
+
LintIssue("error", where, f"{sheet_name}!{ref} is empty in the workbook")
|
|
91
|
+
)
|
|
92
|
+
elif numeric and not isinstance(cell.value, int | float):
|
|
93
|
+
issues.append(
|
|
94
|
+
LintIssue(
|
|
95
|
+
"warning",
|
|
96
|
+
where,
|
|
97
|
+
f"{sheet_name}!{ref} holds no numeric value (formula without a "
|
|
98
|
+
"cached result, or text)",
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _check_sheet_range(
|
|
104
|
+
where: str,
|
|
105
|
+
reference: str,
|
|
106
|
+
workbook: WorkbookSnapshot | None,
|
|
107
|
+
issues: list[LintIssue],
|
|
108
|
+
*,
|
|
109
|
+
numeric: bool = False,
|
|
110
|
+
) -> None:
|
|
111
|
+
parts = _split_sheet_ref(reference)
|
|
112
|
+
if parts is None:
|
|
113
|
+
issues.append(
|
|
114
|
+
LintIssue("error", where, f"{reference!r} is not a Sheet!Range reference")
|
|
115
|
+
)
|
|
116
|
+
return
|
|
117
|
+
sheet_name, cell_range = parts
|
|
118
|
+
bounds = _range_bounds(cell_range)
|
|
119
|
+
if bounds is None:
|
|
120
|
+
issues.append(
|
|
121
|
+
LintIssue("error", where, f"{cell_range!r} is not a valid A1 range")
|
|
122
|
+
)
|
|
123
|
+
return
|
|
124
|
+
if workbook is None:
|
|
125
|
+
return
|
|
126
|
+
try:
|
|
127
|
+
sheet = workbook.sheet(sheet_name)
|
|
128
|
+
except KeyError:
|
|
129
|
+
issues.append(
|
|
130
|
+
LintIssue("error", where, f"sheet {sheet_name!r} not found in workbook")
|
|
131
|
+
)
|
|
132
|
+
return
|
|
133
|
+
min_col, min_row, max_col, max_row = bounds
|
|
134
|
+
if max_row > sheet.max_row or max_col > sheet.max_column:
|
|
135
|
+
issues.append(
|
|
136
|
+
LintIssue(
|
|
137
|
+
"error",
|
|
138
|
+
where,
|
|
139
|
+
f"{sheet_name}!{cell_range} extends beyond populated workbook bounds "
|
|
140
|
+
f"({sheet.max_row} rows x {sheet.max_column} columns)",
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
return
|
|
144
|
+
if numeric:
|
|
145
|
+
non_numeric = 0
|
|
146
|
+
for row in range(min_row, max_row + 1):
|
|
147
|
+
for col in range(min_col, max_col + 1):
|
|
148
|
+
cell = sheet.cells.get((row, col))
|
|
149
|
+
value = None if cell is None else cell.value
|
|
150
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
151
|
+
non_numeric += 1
|
|
152
|
+
if non_numeric:
|
|
153
|
+
issues.append(
|
|
154
|
+
LintIssue(
|
|
155
|
+
"warning",
|
|
156
|
+
where,
|
|
157
|
+
f"{sheet_name}!{cell_range} contains {non_numeric} non-numeric or "
|
|
158
|
+
"uncached cells",
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def lint_profile(
|
|
164
|
+
profile: DeliverableProfile,
|
|
165
|
+
*,
|
|
166
|
+
workbook: WorkbookSnapshot | None = None,
|
|
167
|
+
deck: DeckSnapshot | None = None,
|
|
168
|
+
) -> list[LintIssue]:
|
|
169
|
+
issues: list[LintIssue] = []
|
|
170
|
+
sheet_names = set(workbook.sheet_names) if workbook is not None else None
|
|
171
|
+
|
|
172
|
+
def check_sheet_exists(where: str, name: str) -> None:
|
|
173
|
+
if sheet_names is not None and name not in sheet_names:
|
|
174
|
+
issues.append(
|
|
175
|
+
LintIssue("error", where, f"sheet {name!r} not found in workbook")
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# --- excel section -----------------------------------------------------
|
|
179
|
+
for name in profile.excel.ignore_sheets:
|
|
180
|
+
check_sheet_exists("excel.ignore_sheets", name)
|
|
181
|
+
for sheet_name, sheet_profile in profile.excel.sheets.items():
|
|
182
|
+
where = f"excel.sheets[{sheet_name}]"
|
|
183
|
+
check_sheet_exists(where, sheet_name)
|
|
184
|
+
for cell_range in sheet_profile.ignore_ranges:
|
|
185
|
+
_check_range(f"{where}.ignore_ranges", cell_range, issues)
|
|
186
|
+
for cell_range in sheet_profile.refresh_ranges:
|
|
187
|
+
_check_range(f"{where}.refresh_ranges", cell_range, issues)
|
|
188
|
+
for region in sheet_profile.regions:
|
|
189
|
+
_check_range(f"{where}.regions", region.cell_range, issues)
|
|
190
|
+
for band in sheet_profile.cadence_bands:
|
|
191
|
+
_check_range(f"{where}.cadence_bands", band.cell_range, issues)
|
|
192
|
+
for index, rule in enumerate(sheet_profile.availability_rules):
|
|
193
|
+
rule_where = f"{where}.availability_rules[{rule.name or index}]"
|
|
194
|
+
_check_range(f"{rule_where}.range", rule.cell_range, issues)
|
|
195
|
+
_check_range(f"{rule_where}.periods", rule.period_range, issues)
|
|
196
|
+
if parse_period(rule.required_through) is None:
|
|
197
|
+
issues.append(
|
|
198
|
+
LintIssue(
|
|
199
|
+
"error",
|
|
200
|
+
rule_where,
|
|
201
|
+
(
|
|
202
|
+
f"required_through {rule.required_through!r} is not "
|
|
203
|
+
"a supported period"
|
|
204
|
+
),
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
target_bounds = _range_bounds(rule.cell_range)
|
|
208
|
+
period_bounds = _range_bounds(rule.period_range)
|
|
209
|
+
if target_bounds is None or period_bounds is None:
|
|
210
|
+
continue
|
|
211
|
+
target_min_col, target_min_row, target_max_col, target_max_row = (
|
|
212
|
+
target_bounds
|
|
213
|
+
)
|
|
214
|
+
period_min_col, period_min_row, period_max_col, period_max_row = (
|
|
215
|
+
period_bounds
|
|
216
|
+
)
|
|
217
|
+
target_width = target_max_col - target_min_col + 1
|
|
218
|
+
target_height = target_max_row - target_min_row + 1
|
|
219
|
+
period_width = period_max_col - period_min_col + 1
|
|
220
|
+
period_height = period_max_row - period_min_row + 1
|
|
221
|
+
if period_width > 1 and period_height > 1:
|
|
222
|
+
issues.append(
|
|
223
|
+
LintIssue(
|
|
224
|
+
"error",
|
|
225
|
+
rule_where,
|
|
226
|
+
"periods must be a single row or single column",
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
elif period_height == 1 and period_width > 1 and period_width != target_width:
|
|
230
|
+
issues.append(
|
|
231
|
+
LintIssue(
|
|
232
|
+
"error",
|
|
233
|
+
rule_where,
|
|
234
|
+
(
|
|
235
|
+
f"period range width {period_width} does not match "
|
|
236
|
+
f"target range width {target_width}"
|
|
237
|
+
),
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
elif period_width == 1 and period_height > 1 and period_height != target_height:
|
|
241
|
+
issues.append(
|
|
242
|
+
LintIssue(
|
|
243
|
+
"error",
|
|
244
|
+
rule_where,
|
|
245
|
+
(
|
|
246
|
+
f"period range height {period_height} does not match "
|
|
247
|
+
f"target range height {target_height}"
|
|
248
|
+
),
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
if workbook is not None:
|
|
252
|
+
try:
|
|
253
|
+
availability_sheet = workbook.sheet(sheet_name)
|
|
254
|
+
except KeyError:
|
|
255
|
+
availability_sheet = None
|
|
256
|
+
required_period = parse_period(rule.required_through)
|
|
257
|
+
if availability_sheet is not None and required_period is not None:
|
|
258
|
+
observed_kinds: set[str] = set()
|
|
259
|
+
unparseable = 0
|
|
260
|
+
for row in range(period_min_row, period_max_row + 1):
|
|
261
|
+
for column in range(period_min_col, period_max_col + 1):
|
|
262
|
+
cell = availability_sheet.cells.get((row, column))
|
|
263
|
+
if cell is None or cell.value in {None, ""}:
|
|
264
|
+
continue
|
|
265
|
+
period = parse_period(cell.value)
|
|
266
|
+
if period is None:
|
|
267
|
+
unparseable += 1
|
|
268
|
+
else:
|
|
269
|
+
observed_kinds.add(period.kind)
|
|
270
|
+
if unparseable:
|
|
271
|
+
issues.append(
|
|
272
|
+
LintIssue(
|
|
273
|
+
"error",
|
|
274
|
+
rule_where,
|
|
275
|
+
(
|
|
276
|
+
f"period range contains {unparseable} "
|
|
277
|
+
"unparseable nonblank labels"
|
|
278
|
+
),
|
|
279
|
+
)
|
|
280
|
+
)
|
|
281
|
+
mismatched_kinds = observed_kinds - {required_period.kind}
|
|
282
|
+
if mismatched_kinds:
|
|
283
|
+
issues.append(
|
|
284
|
+
LintIssue(
|
|
285
|
+
"error",
|
|
286
|
+
rule_where,
|
|
287
|
+
(
|
|
288
|
+
f"period range kinds {sorted(observed_kinds)} "
|
|
289
|
+
"do not match required_through kind "
|
|
290
|
+
f"{required_period.kind!r}"
|
|
291
|
+
),
|
|
292
|
+
)
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
controls = profile.excel.controls
|
|
296
|
+
for group_name, group in (
|
|
297
|
+
("required_ranges", controls.required_ranges),
|
|
298
|
+
("unique_ranges", controls.unique_ranges),
|
|
299
|
+
("numeric_bounds", controls.numeric_bounds),
|
|
300
|
+
):
|
|
301
|
+
for control in group:
|
|
302
|
+
where = f"excel.controls.{group_name}[{control.name or control.sheet}]"
|
|
303
|
+
check_sheet_exists(where, control.sheet)
|
|
304
|
+
_check_range(where, control.cell_range, issues)
|
|
305
|
+
if workbook is not None:
|
|
306
|
+
_check_sheet_range(
|
|
307
|
+
where,
|
|
308
|
+
f"{control.sheet}!{control.cell_range}",
|
|
309
|
+
workbook,
|
|
310
|
+
issues,
|
|
311
|
+
numeric=group_name == "numeric_bounds",
|
|
312
|
+
)
|
|
313
|
+
if group_name == "numeric_bounds":
|
|
314
|
+
minimum = getattr(control, "minimum", None)
|
|
315
|
+
maximum = getattr(control, "maximum", None)
|
|
316
|
+
if minimum is None and maximum is None:
|
|
317
|
+
issues.append(
|
|
318
|
+
LintIssue("error", where, "numeric bounds define no minimum or maximum")
|
|
319
|
+
)
|
|
320
|
+
elif minimum is not None and maximum is not None and minimum > maximum:
|
|
321
|
+
issues.append(
|
|
322
|
+
LintIssue(
|
|
323
|
+
"error",
|
|
324
|
+
where,
|
|
325
|
+
f"minimum {minimum} exceeds maximum {maximum}",
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
for tie_out in controls.tie_outs:
|
|
329
|
+
where = f"excel.controls.tie_outs[{tie_out.name}]"
|
|
330
|
+
_check_sheet_cell(where, tie_out.target, workbook, issues, numeric=True)
|
|
331
|
+
if not tie_out.components:
|
|
332
|
+
issues.append(LintIssue("error", where, "tie-out has no components"))
|
|
333
|
+
for component in tie_out.components:
|
|
334
|
+
_check_sheet_range(where, component, workbook, issues, numeric=True)
|
|
335
|
+
if tie_out.absolute_tolerance < 0 or tie_out.relative_tolerance < 0:
|
|
336
|
+
issues.append(LintIssue("error", where, "tie-out tolerances cannot be negative"))
|
|
337
|
+
|
|
338
|
+
# --- crosscheck mappings -------------------------------------------------
|
|
339
|
+
seen_anchors: set[tuple[str, str, int]] = set()
|
|
340
|
+
deck_occurrences = extract_deck_figures(deck) if deck is not None else None
|
|
341
|
+
for mapping in profile.crosscheck.mappings:
|
|
342
|
+
where = f"crosscheck.mappings[{mapping.label or mapping.line_skeleton}]"
|
|
343
|
+
anchor = (mapping.slide, mapping.line_skeleton, mapping.figure_index)
|
|
344
|
+
if anchor in seen_anchors:
|
|
345
|
+
issues.append(LintIssue("error", where, "duplicate mapping anchor"))
|
|
346
|
+
seen_anchors.add(anchor)
|
|
347
|
+
_check_sheet_cell(
|
|
348
|
+
where,
|
|
349
|
+
f"{mapping.source_sheet}!{mapping.source_cell}",
|
|
350
|
+
workbook,
|
|
351
|
+
issues,
|
|
352
|
+
numeric=True,
|
|
353
|
+
)
|
|
354
|
+
if deck_occurrences is not None and not any(
|
|
355
|
+
occ.slide == mapping.slide
|
|
356
|
+
and occ.line_skeleton == mapping.line_skeleton
|
|
357
|
+
and occ.figure_index == mapping.figure_index
|
|
358
|
+
for occ in deck_occurrences
|
|
359
|
+
):
|
|
360
|
+
issues.append(
|
|
361
|
+
LintIssue(
|
|
362
|
+
"error",
|
|
363
|
+
where,
|
|
364
|
+
"mapping anchor does not resolve in the deck (slide or "
|
|
365
|
+
"wording changed) — re-confirm it",
|
|
366
|
+
)
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
# --- ppt section -----------------------------------------------------------
|
|
370
|
+
for index, rule in enumerate(profile.ppt.availability_rules):
|
|
371
|
+
where = f"ppt.availability_rules[{rule.name or index}]"
|
|
372
|
+
if parse_period(rule.required_through) is None:
|
|
373
|
+
issues.append(
|
|
374
|
+
LintIssue(
|
|
375
|
+
"error",
|
|
376
|
+
where,
|
|
377
|
+
(
|
|
378
|
+
f"required_through {rule.required_through!r} is not "
|
|
379
|
+
"a supported period"
|
|
380
|
+
),
|
|
381
|
+
)
|
|
382
|
+
)
|
|
383
|
+
if deck is not None:
|
|
384
|
+
titles = {slide.title for slide in deck.slides if slide.title}
|
|
385
|
+
slides_by_display = {
|
|
386
|
+
slide.display_name.casefold(): slide for slide in deck.slides
|
|
387
|
+
}
|
|
388
|
+
for required in profile.ppt.required_slides:
|
|
389
|
+
if required not in titles:
|
|
390
|
+
issues.append(
|
|
391
|
+
LintIssue(
|
|
392
|
+
"error",
|
|
393
|
+
"ppt.required_slides",
|
|
394
|
+
f"required slide {required!r} not found in deck",
|
|
395
|
+
)
|
|
396
|
+
)
|
|
397
|
+
for index, rule in enumerate(profile.ppt.availability_rules):
|
|
398
|
+
rule_where = f"ppt.availability_rules[{rule.name or index}]"
|
|
399
|
+
slide = slides_by_display.get(rule.slide.casefold())
|
|
400
|
+
if slide is None:
|
|
401
|
+
issues.append(
|
|
402
|
+
LintIssue(
|
|
403
|
+
"error",
|
|
404
|
+
rule_where,
|
|
405
|
+
f"slide {rule.slide!r} not found in deck",
|
|
406
|
+
)
|
|
407
|
+
)
|
|
408
|
+
continue
|
|
409
|
+
element_series: list[tuple[str, set[str]]] = []
|
|
410
|
+
if rule.scope == "table":
|
|
411
|
+
element_series = [
|
|
412
|
+
(
|
|
413
|
+
table.name or f"table[{table.source_index}]",
|
|
414
|
+
{
|
|
415
|
+
row[0]
|
|
416
|
+
for row in table.rows[1:]
|
|
417
|
+
if row and row[0]
|
|
418
|
+
},
|
|
419
|
+
)
|
|
420
|
+
for table in slide.tables
|
|
421
|
+
]
|
|
422
|
+
elif rule.scope == "chart":
|
|
423
|
+
element_series = [
|
|
424
|
+
(
|
|
425
|
+
chart.title
|
|
426
|
+
or chart.name
|
|
427
|
+
or f"chart[{chart.source_index}]",
|
|
428
|
+
{
|
|
429
|
+
series.name
|
|
430
|
+
for series in chart.all_series
|
|
431
|
+
if series.name
|
|
432
|
+
},
|
|
433
|
+
)
|
|
434
|
+
for chart in slide.charts
|
|
435
|
+
]
|
|
436
|
+
matching_elements = [
|
|
437
|
+
(element, series_names)
|
|
438
|
+
for element, series_names in element_series
|
|
439
|
+
if not rule.element
|
|
440
|
+
or element.casefold() == rule.element.casefold()
|
|
441
|
+
]
|
|
442
|
+
if rule.element and not matching_elements:
|
|
443
|
+
issues.append(
|
|
444
|
+
LintIssue(
|
|
445
|
+
"error",
|
|
446
|
+
rule_where,
|
|
447
|
+
(
|
|
448
|
+
f"{rule.scope} element {rule.element!r} not found "
|
|
449
|
+
f"on slide {rule.slide!r}"
|
|
450
|
+
),
|
|
451
|
+
)
|
|
452
|
+
)
|
|
453
|
+
continue
|
|
454
|
+
if rule.series and not any(
|
|
455
|
+
rule.series.casefold()
|
|
456
|
+
in {name.casefold() for name in series_names}
|
|
457
|
+
for _, series_names in matching_elements
|
|
458
|
+
):
|
|
459
|
+
issues.append(
|
|
460
|
+
LintIssue(
|
|
461
|
+
"error",
|
|
462
|
+
rule_where,
|
|
463
|
+
(
|
|
464
|
+
f"series/row {rule.series!r} not found in matching "
|
|
465
|
+
f"{rule.scope} elements"
|
|
466
|
+
),
|
|
467
|
+
)
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
# --- waivers ------------------------------------------------------------------
|
|
471
|
+
today = dt.date.today()
|
|
472
|
+
for waiver in profile.waivers:
|
|
473
|
+
where = f"waivers[{waiver.finding_class.value}]"
|
|
474
|
+
if waiver.expires < today:
|
|
475
|
+
issues.append(
|
|
476
|
+
LintIssue(
|
|
477
|
+
"warning", where, f"waiver expired on {waiver.expires.isoformat()}"
|
|
478
|
+
)
|
|
479
|
+
)
|
|
480
|
+
return issues
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Deliverable profile: named, reusable per-deliverable QC configuration.
|
|
2
|
+
|
|
3
|
+
Profiles are YAML documents validated by these models. Every setting has a
|
|
4
|
+
sensible default so a run with no profile still works; recurring
|
|
5
|
+
deliverables save their corrections (region pins, keys, tolerances) once
|
|
6
|
+
and reuse them each cycle.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import datetime as dt
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
from qc_tool.findings import FindingClass, Severity
|
|
17
|
+
from qc_tool.security import private_directory, private_file
|
|
18
|
+
|
|
19
|
+
Orientation = Literal["long", "wide", "block"]
|
|
20
|
+
CadenceKind = Literal["month", "week", "quarter", "date"]
|
|
21
|
+
SeriesRole = Literal["actual", "forecast", "target"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NumericTolerance(BaseModel):
|
|
25
|
+
"""A numeric difference is a finding only if it exceeds both bounds."""
|
|
26
|
+
|
|
27
|
+
absolute: float = 0.0
|
|
28
|
+
relative: float = 0.0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RegionOverride(BaseModel):
|
|
32
|
+
"""Pins one table region, replacing auto-detection for its sheet."""
|
|
33
|
+
|
|
34
|
+
cell_range: str = Field(alias="range")
|
|
35
|
+
orientation: Orientation
|
|
36
|
+
header_row: int | None = None
|
|
37
|
+
key_column: str | None = None # column letter holding row identity
|
|
38
|
+
|
|
39
|
+
model_config = {"populate_by_name": True}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CadenceBand(BaseModel):
|
|
43
|
+
"""An explicit contiguous period-axis range for one cadence kind."""
|
|
44
|
+
|
|
45
|
+
cell_range: str = Field(alias="range")
|
|
46
|
+
kind: CadenceKind
|
|
47
|
+
|
|
48
|
+
model_config = {"populate_by_name": True}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ExcelAvailabilityRule(BaseModel):
|
|
52
|
+
"""Period-aligned Excel cells whose future blankness is explicitly allowed."""
|
|
53
|
+
|
|
54
|
+
name: str = ""
|
|
55
|
+
cell_range: str = Field(alias="range")
|
|
56
|
+
period_range: str = Field(alias="periods")
|
|
57
|
+
required_through: str
|
|
58
|
+
allow_blank_after: bool = True
|
|
59
|
+
role: SeriesRole | None = None
|
|
60
|
+
|
|
61
|
+
model_config = {"populate_by_name": True}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class SheetProfile(BaseModel):
|
|
65
|
+
ignore: bool = False
|
|
66
|
+
ignore_ranges: list[str] = Field(default_factory=list)
|
|
67
|
+
#: Ranges holding cycle-snapshot aggregates: value changes there are
|
|
68
|
+
#: expected each cadence (still reported, classed as expected), while
|
|
69
|
+
#: format/style/formula findings remain fully active.
|
|
70
|
+
refresh_ranges: list[str] = Field(default_factory=list)
|
|
71
|
+
regions: list[RegionOverride] = Field(default_factory=list)
|
|
72
|
+
cadence_bands: list[CadenceBand] = Field(default_factory=list)
|
|
73
|
+
availability_rules: list[ExcelAvailabilityRule] = Field(default_factory=list)
|
|
74
|
+
#: Chart-title or ``chart[N]`` overrides for rolling/full source windows.
|
|
75
|
+
chart_windows: dict[str, Literal["rolling", "full"]] = Field(default_factory=dict)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class RangeControl(BaseModel):
|
|
79
|
+
name: str = ""
|
|
80
|
+
sheet: str
|
|
81
|
+
cell_range: str = Field(alias="range")
|
|
82
|
+
|
|
83
|
+
model_config = {"populate_by_name": True}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class UniqueRangeControl(RangeControl):
|
|
87
|
+
skip_header: bool = True
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class NumericBoundsControl(RangeControl):
|
|
91
|
+
minimum: float | None = None
|
|
92
|
+
maximum: float | None = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class TieOutControl(BaseModel):
|
|
96
|
+
name: str
|
|
97
|
+
target: str
|
|
98
|
+
components: list[str]
|
|
99
|
+
absolute_tolerance: float = 0.0
|
|
100
|
+
relative_tolerance: float = 0.0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ExcelControls(BaseModel):
|
|
104
|
+
required_ranges: list[RangeControl] = Field(default_factory=list)
|
|
105
|
+
unique_ranges: list[UniqueRangeControl] = Field(default_factory=list)
|
|
106
|
+
numeric_bounds: list[NumericBoundsControl] = Field(default_factory=list)
|
|
107
|
+
tie_outs: list[TieOutControl] = Field(default_factory=list)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ExcelProfile(BaseModel):
|
|
111
|
+
ignore_sheets: list[str] = Field(default_factory=list)
|
|
112
|
+
sheets: dict[str, SheetProfile] = Field(default_factory=dict)
|
|
113
|
+
controls: ExcelControls = Field(default_factory=ExcelControls)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class PptAvailabilityRule(BaseModel):
|
|
117
|
+
"""Blankness boundary for a table row or chart series on one slide."""
|
|
118
|
+
|
|
119
|
+
name: str = ""
|
|
120
|
+
slide: str
|
|
121
|
+
scope: Literal["table", "chart"]
|
|
122
|
+
element: str = ""
|
|
123
|
+
series: str | None = None
|
|
124
|
+
required_through: str
|
|
125
|
+
allow_blank_after: bool = True
|
|
126
|
+
role: SeriesRole | None = None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class PptProfile(BaseModel):
|
|
130
|
+
#: Force slide pairings (baseline title -> current title); wins over fuzzy.
|
|
131
|
+
slide_pins: dict[str, str] = Field(default_factory=dict)
|
|
132
|
+
#: Minimum similarity score (0-100) to accept a fuzzy slide pair.
|
|
133
|
+
match_threshold: float = 55.0
|
|
134
|
+
#: Per-slide chart window override: "rolling" or "full" (else inferred).
|
|
135
|
+
chart_windows: dict[str, Literal["rolling", "full"]] = Field(default_factory=dict)
|
|
136
|
+
availability_rules: list[PptAvailabilityRule] = Field(default_factory=list)
|
|
137
|
+
#: Slide titles that must exist in a final deck.
|
|
138
|
+
required_slides: list[str] = Field(default_factory=list)
|
|
139
|
+
#: Case-insensitive tokens that indicate unfinished presentation content.
|
|
140
|
+
draft_tokens: list[str] = Field(
|
|
141
|
+
default_factory=lambda: ["TBD", "XXX", "TODO", "PLACEHOLDER", "DRAFT"]
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class CrosscheckMapping(BaseModel):
|
|
146
|
+
"""Analyst-confirmed link from a deck figure to its workbook source cell.
|
|
147
|
+
|
|
148
|
+
The figure itself changes every cycle, so the anchor is the stable
|
|
149
|
+
numeric skeleton of its line (or ``table:<row>/<col>`` for table cells)
|
|
150
|
+
plus the figure's ordinal within that line.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
slide: str
|
|
154
|
+
line_skeleton: str
|
|
155
|
+
figure_index: int = 0
|
|
156
|
+
label: str = ""
|
|
157
|
+
source_sheet: str
|
|
158
|
+
source_cell: str
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class CrosscheckProfile(BaseModel):
|
|
162
|
+
mappings: list[CrosscheckMapping] = Field(default_factory=list)
|
|
163
|
+
max_candidates: int = 5
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class FindingWaiver(BaseModel):
|
|
167
|
+
finding_class: FindingClass
|
|
168
|
+
reason: str = Field(min_length=1)
|
|
169
|
+
expires: dt.date
|
|
170
|
+
sheet: str | None = None
|
|
171
|
+
slide: str | None = None
|
|
172
|
+
location: str | None = None
|
|
173
|
+
element: str | None = None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class DeliverableProfile(BaseModel):
|
|
177
|
+
name: str
|
|
178
|
+
description: str = ""
|
|
179
|
+
tolerance: NumericTolerance = Field(default_factory=NumericTolerance)
|
|
180
|
+
excel: ExcelProfile = Field(default_factory=ExcelProfile)
|
|
181
|
+
ppt: PptProfile = Field(default_factory=PptProfile)
|
|
182
|
+
crosscheck: CrosscheckProfile = Field(default_factory=CrosscheckProfile)
|
|
183
|
+
waivers: list[FindingWaiver] = Field(default_factory=list)
|
|
184
|
+
#: Per-class severity overrides applied to non-expected findings.
|
|
185
|
+
severity: dict[FindingClass, Severity] = Field(default_factory=dict)
|
|
186
|
+
|
|
187
|
+
def sheet_profile(self, sheet_name: str) -> SheetProfile | None:
|
|
188
|
+
return self.excel.sheets.get(sheet_name)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def default_profile(name: str = "default") -> DeliverableProfile:
|
|
192
|
+
return DeliverableProfile(name=name)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def load_profile(path: Path) -> DeliverableProfile:
|
|
196
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
197
|
+
if not isinstance(raw, dict):
|
|
198
|
+
raise ValueError(f"{path}: profile YAML must be a mapping")
|
|
199
|
+
return DeliverableProfile.model_validate(raw)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def save_profile(profile: DeliverableProfile, path: Path) -> None:
|
|
203
|
+
private_directory(path.parent)
|
|
204
|
+
payload = profile.model_dump(by_alias=True, exclude_defaults=True)
|
|
205
|
+
payload["name"] = profile.name
|
|
206
|
+
path.write_text(yaml.safe_dump(payload, sort_keys=False), encoding="utf-8")
|
|
207
|
+
private_file(path)
|