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.
Files changed (78) hide show
  1. cadence_diff-0.1.0.dist-info/METADATA +245 -0
  2. cadence_diff-0.1.0.dist-info/RECORD +78 -0
  3. cadence_diff-0.1.0.dist-info/WHEEL +4 -0
  4. cadence_diff-0.1.0.dist-info/entry_points.txt +3 -0
  5. cadence_diff-0.1.0.dist-info/licenses/LICENSE +190 -0
  6. qc_tool/__init__.py +3 -0
  7. qc_tool/__main__.py +6 -0
  8. qc_tool/attestation.py +209 -0
  9. qc_tool/availability.py +273 -0
  10. qc_tool/cli.py +769 -0
  11. qc_tool/config/__init__.py +1 -0
  12. qc_tool/config/lint.py +480 -0
  13. qc_tool/config/profile.py +207 -0
  14. qc_tool/coverage.py +35 -0
  15. qc_tool/crosscheck/__init__.py +1 -0
  16. qc_tool/crosscheck/numbers.py +146 -0
  17. qc_tool/crosscheck/package.py +154 -0
  18. qc_tool/crosscheck/trace.py +374 -0
  19. qc_tool/engine.py +978 -0
  20. qc_tool/excel/__init__.py +1 -0
  21. qc_tool/excel/align.py +452 -0
  22. qc_tool/excel/charts.py +931 -0
  23. qc_tool/excel/context.py +106 -0
  24. qc_tool/excel/controls.py +266 -0
  25. qc_tool/excel/dependency.py +252 -0
  26. qc_tool/excel/diff_structure.py +326 -0
  27. qc_tool/excel/diff_values.py +327 -0
  28. qc_tool/excel/formulas.py +516 -0
  29. qc_tool/excel/interaction.py +548 -0
  30. qc_tool/excel/periods.py +117 -0
  31. qc_tool/excel/preflight.py +615 -0
  32. qc_tool/excel/references.py +436 -0
  33. qc_tool/excel/regions.py +201 -0
  34. qc_tool/findings.py +220 -0
  35. qc_tool/fingerprint.py +369 -0
  36. qc_tool/fingerprint.schema.json +15 -0
  37. qc_tool/history/__init__.py +1 -0
  38. qc_tool/history/store.py +287 -0
  39. qc_tool/io/__init__.py +1 -0
  40. qc_tool/io/decrypt.py +64 -0
  41. qc_tool/io/excel_formula.py +256 -0
  42. qc_tool/io/excel_formula_worker.py +280 -0
  43. qc_tool/io/formula_enrichment.py +95 -0
  44. qc_tool/io/libreoffice_formula.py +232 -0
  45. qc_tool/io/loader.py +973 -0
  46. qc_tool/io/model.py +356 -0
  47. qc_tool/io/ooxml_chart.py +530 -0
  48. qc_tool/io/ooxml_interaction.py +400 -0
  49. qc_tool/io/ooxml_worksheet.py +405 -0
  50. qc_tool/io/xlsb_formula.py +313 -0
  51. qc_tool/package-sanitize.schema.json +31 -0
  52. qc_tool/package_sanitize.py +255 -0
  53. qc_tool/ppt/__init__.py +1 -0
  54. qc_tool/ppt/chart_xml.py +422 -0
  55. qc_tool/ppt/diff.py +120 -0
  56. qc_tool/ppt/element_diff.py +878 -0
  57. qc_tool/ppt/element_match.py +317 -0
  58. qc_tool/ppt/extract.py +212 -0
  59. qc_tool/ppt/match.py +137 -0
  60. qc_tool/ppt/model.py +132 -0
  61. qc_tool/ppt/preflight.py +307 -0
  62. qc_tool/privacy.py +392 -0
  63. qc_tool/progress.py +78 -0
  64. qc_tool/report/__init__.py +1 -0
  65. qc_tool/report/excel_report.py +205 -0
  66. qc_tool/report/findings.schema.json +32 -0
  67. qc_tool/report/html_report.py +38 -0
  68. qc_tool/report/json_report.py +55 -0
  69. qc_tool/report/templates/report.html.j2 +117 -0
  70. qc_tool/sanitize.py +598 -0
  71. qc_tool/security.py +34 -0
  72. qc_tool/server_config.py +86 -0
  73. qc_tool/triage/__init__.py +1 -0
  74. qc_tool/triage/rules.py +211 -0
  75. qc_tool/ui/__init__.py +1 -0
  76. qc_tool/ui/app.py +1261 -0
  77. qc_tool/ui/guide.py +452 -0
  78. 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)