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
+ """Excel comparison: regions, alignment, value/structure/formula QC, dependencies."""
qc_tool/excel/align.py ADDED
@@ -0,0 +1,452 @@
1
+ """Alignment engine: cell-level correspondence between baseline and current.
2
+
3
+ Per paired region, each axis (rows, columns) is aligned by identity keys:
4
+
5
+ - long regions: rows keyed by leading label/period columns, columns by
6
+ header text;
7
+ - wide regions: columns keyed by header labels (periods), rows by the
8
+ label column;
9
+ - block regions: rows keyed by label-column text, columns positionally.
10
+
11
+ Unmatched current entries whose period sorts *after* the last baseline
12
+ period are classified as expected cadence growth; any other unmatched
13
+ current entry is an unexpected insertion, and unmatched baseline entries
14
+ are deletions of historical data. If key matching pairs less than half of
15
+ the baseline axis, the axis falls back to positional alignment rather
16
+ than producing unreliable correspondences.
17
+ """
18
+
19
+ import logging
20
+ import re
21
+ from collections import Counter
22
+ from collections.abc import Iterator
23
+ from dataclasses import dataclass, field
24
+
25
+ from qc_tool.config.profile import DeliverableProfile
26
+ from qc_tool.excel.periods import Period, is_period_after, is_period_label, parse_period
27
+ from qc_tool.excel.regions import TableRegion, detect_regions
28
+ from qc_tool.io.model import SheetSnapshot, WorkbookSnapshot
29
+ from qc_tool.progress import CancellationToken, check_cancelled
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ _MIN_KEY_MATCH_RATIO = 0.5
34
+ _MIN_CONFIDENCE_AXIS_SIZE = 4
35
+
36
+ AxisKey = tuple[object, ...]
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class AxisEntry:
41
+ index: int # absolute row or column index in its sheet
42
+ key: AxisKey
43
+ periods: tuple[Period | None, ...] # period parse per key component
44
+
45
+
46
+ @dataclass(slots=True)
47
+ class AxisAlignment:
48
+ pairs: list[tuple[int, int]] = field(default_factory=list)
49
+ deleted: list[int] = field(default_factory=list) # baseline indices
50
+ inserted: list[int] = field(default_factory=list) # current indices, unexpected
51
+ growth: list[int] = field(default_factory=list) # current indices, expected
52
+ method: str = "keys" # "keys" | "positional"
53
+ low_confidence_fallback: bool = False
54
+
55
+
56
+ @dataclass(slots=True)
57
+ class RegionAlignment:
58
+ baseline: TableRegion
59
+ current: TableRegion
60
+ rows: AxisAlignment
61
+ columns: AxisAlignment
62
+
63
+ def cell_pairs(self) -> Iterator[tuple[tuple[int, int], tuple[int, int]]]:
64
+ """Yield ((baseline_row, baseline_col), (current_row, current_col))."""
65
+ for base_row, curr_row in self.rows.pairs:
66
+ for base_col, curr_col in self.columns.pairs:
67
+ yield ((base_row, base_col), (curr_row, curr_col))
68
+
69
+ @property
70
+ def row_growth_count(self) -> int:
71
+ return len(self.rows.growth)
72
+
73
+ @property
74
+ def column_growth_count(self) -> int:
75
+ return len(self.columns.growth)
76
+
77
+ @property
78
+ def low_confidence(self) -> bool:
79
+ return (
80
+ self.rows.low_confidence_fallback
81
+ or self.columns.low_confidence_fallback
82
+ )
83
+
84
+
85
+ @dataclass(slots=True)
86
+ class WorkbookAlignment:
87
+ common_sheets: list[str] = field(default_factory=list)
88
+ added_sheets: list[str] = field(default_factory=list)
89
+ removed_sheets: list[str] = field(default_factory=list)
90
+ regions: dict[str, list[RegionAlignment]] = field(default_factory=dict)
91
+ unpaired_baseline_regions: list[TableRegion] = field(default_factory=list)
92
+ unpaired_current_regions: list[TableRegion] = field(default_factory=list)
93
+ low_confidence_regions: list[str] = field(default_factory=list)
94
+
95
+
96
+ # --- axis key extraction --------------------------------------------------
97
+
98
+
99
+ def _column_is_labelish(sheet: SheetSnapshot, col: int, rows: range) -> bool:
100
+ values = [
101
+ sheet.cells[(row, col)].value for row in rows if (row, col) in sheet.cells
102
+ ]
103
+ if not values:
104
+ return False
105
+ non_numeric = sum(1 for v in values if not isinstance(v, int | float | bool))
106
+ return non_numeric / len(values) >= 0.8
107
+
108
+
109
+ def _long_key_columns(sheet: SheetSnapshot, region: TableRegion) -> list[int]:
110
+ """Leading label/period columns of a long region (identity columns)."""
111
+ data_rows = range((region.header_row or region.min_row) + 1, region.max_row + 1)
112
+ key_cols: list[int] = []
113
+ for col in range(region.min_col, region.max_col + 1):
114
+ if _column_is_labelish(sheet, col, data_rows):
115
+ key_cols.append(col)
116
+ else:
117
+ break
118
+ return key_cols or [region.key_col or region.min_col]
119
+
120
+
121
+ def _row_entries(
122
+ sheet: SheetSnapshot, region: TableRegion, key_cols: list[int], first_row: int
123
+ ) -> list[AxisEntry]:
124
+ entries = []
125
+ seen: Counter[AxisKey] = Counter()
126
+ for row in range(first_row, region.max_row + 1):
127
+ raw = tuple(
128
+ sheet.cells[(row, col)].value if (row, col) in sheet.cells else None
129
+ for col in key_cols
130
+ )
131
+ occurrence = seen[raw]
132
+ seen[raw] += 1
133
+ entries.append(
134
+ AxisEntry(
135
+ index=row,
136
+ key=(*raw, occurrence),
137
+ periods=tuple(parse_period(v) for v in raw),
138
+ )
139
+ )
140
+ return entries
141
+
142
+
143
+ def _col_entries(
144
+ sheet: SheetSnapshot, region: TableRegion, header_row: int, first_col: int
145
+ ) -> list[AxisEntry]:
146
+ entries = []
147
+ seen: Counter[AxisKey] = Counter()
148
+ for col in range(first_col, region.max_col + 1):
149
+ value = (
150
+ sheet.cells[(header_row, col)].value
151
+ if (header_row, col) in sheet.cells
152
+ else None
153
+ )
154
+ raw = (value,)
155
+ occurrence = seen[raw]
156
+ seen[raw] += 1
157
+ entries.append(
158
+ AxisEntry(index=col, key=(*raw, occurrence), periods=(parse_period(value),))
159
+ )
160
+ return entries
161
+
162
+
163
+ def _positional_entries(indices: range) -> list[AxisEntry]:
164
+ return [
165
+ AxisEntry(index=index, key=(offset,), periods=(None,))
166
+ for offset, index in enumerate(indices)
167
+ ]
168
+
169
+
170
+ # --- axis alignment -------------------------------------------------------
171
+
172
+
173
+ def _max_periods(entries: list[AxisEntry]) -> dict[tuple[int, str], Period]:
174
+ """Per key-component position and cadence kind, the latest baseline period."""
175
+ result: dict[tuple[int, str], Period] = {}
176
+ for entry in entries:
177
+ for position, period in enumerate(entry.periods):
178
+ if period is None:
179
+ continue
180
+ key = (position, period.kind)
181
+ best = result.get(key)
182
+ if best is None or period.sort_key > best.sort_key:
183
+ result[key] = period
184
+ return result
185
+
186
+
187
+ def _is_growth(entry: AxisEntry, max_periods: dict[tuple[int, str], Period]) -> bool:
188
+ for position, period in enumerate(entry.periods):
189
+ if period is None:
190
+ continue
191
+ baseline_max = max_periods.get((position, period.kind))
192
+ if baseline_max is not None and is_period_after(period, baseline_max):
193
+ return True
194
+ return False
195
+
196
+
197
+ def _align_axis(
198
+ baseline: list[AxisEntry], current: list[AxisEntry], *, method: str = "keys"
199
+ ) -> AxisAlignment:
200
+ current_by_key = {entry.key: entry for entry in current}
201
+ matched_current: set[int] = set()
202
+ alignment = AxisAlignment(method=method)
203
+
204
+ for base_entry in baseline:
205
+ match = current_by_key.get(base_entry.key)
206
+ if match is not None:
207
+ alignment.pairs.append((base_entry.index, match.index))
208
+ matched_current.add(match.index)
209
+ else:
210
+ alignment.deleted.append(base_entry.index)
211
+
212
+ max_periods = _max_periods(baseline)
213
+ for curr_entry in current:
214
+ if curr_entry.index in matched_current:
215
+ continue
216
+ if _is_growth(curr_entry, max_periods):
217
+ alignment.growth.append(curr_entry.index)
218
+ else:
219
+ alignment.inserted.append(curr_entry.index)
220
+
221
+ if baseline and len(alignment.pairs) / len(baseline) < _MIN_KEY_MATCH_RATIO:
222
+ return _align_positionally(
223
+ baseline,
224
+ current,
225
+ low_confidence=(
226
+ len(baseline) >= _MIN_CONFIDENCE_AXIS_SIZE
227
+ and len(current) >= _MIN_CONFIDENCE_AXIS_SIZE
228
+ ),
229
+ )
230
+ return alignment
231
+
232
+
233
+ def _align_positionally(
234
+ baseline: list[AxisEntry],
235
+ current: list[AxisEntry],
236
+ *,
237
+ low_confidence: bool = False,
238
+ ) -> AxisAlignment:
239
+ alignment = AxisAlignment(
240
+ method="positional",
241
+ low_confidence_fallback=low_confidence,
242
+ )
243
+ shared = min(len(baseline), len(current))
244
+ alignment.pairs = [
245
+ (baseline[i].index, current[i].index) for i in range(shared)
246
+ ]
247
+ alignment.deleted = [entry.index for entry in baseline[shared:]]
248
+ max_periods = _max_periods(baseline)
249
+ for entry in current[shared:]:
250
+ if _is_growth(entry, max_periods):
251
+ alignment.growth.append(entry.index)
252
+ else:
253
+ alignment.inserted.append(entry.index)
254
+ return alignment
255
+
256
+
257
+ # --- region alignment -----------------------------------------------------
258
+
259
+
260
+ def _align_long(
261
+ base_sheet: SheetSnapshot,
262
+ curr_sheet: SheetSnapshot,
263
+ base_region: TableRegion,
264
+ curr_region: TableRegion,
265
+ ) -> RegionAlignment:
266
+ base_keys = _long_key_columns(base_sheet, base_region)
267
+ curr_keys = _long_key_columns(curr_sheet, curr_region)
268
+ base_header = base_region.header_row or base_region.min_row
269
+ curr_header = curr_region.header_row or curr_region.min_row
270
+ rows = _align_axis(
271
+ _row_entries(base_sheet, base_region, base_keys, base_header + 1),
272
+ _row_entries(curr_sheet, curr_region, curr_keys, curr_header + 1),
273
+ )
274
+ columns = _align_axis(
275
+ _col_entries(base_sheet, base_region, base_header, base_region.min_col),
276
+ _col_entries(curr_sheet, curr_region, curr_header, curr_region.min_col),
277
+ )
278
+ return RegionAlignment(base_region, curr_region, rows, columns)
279
+
280
+
281
+ def _align_wide(
282
+ base_sheet: SheetSnapshot,
283
+ curr_sheet: SheetSnapshot,
284
+ base_region: TableRegion,
285
+ curr_region: TableRegion,
286
+ ) -> RegionAlignment:
287
+ base_header = base_region.header_row or base_region.min_row
288
+ curr_header = curr_region.header_row or curr_region.min_row
289
+ label_col_base = [base_region.key_col or base_region.min_col]
290
+ label_col_curr = [curr_region.key_col or curr_region.min_col]
291
+ rows = _align_axis(
292
+ _row_entries(base_sheet, base_region, label_col_base, base_header),
293
+ _row_entries(curr_sheet, curr_region, label_col_curr, curr_header),
294
+ )
295
+ columns = _align_axis(
296
+ _col_entries(base_sheet, base_region, base_header, base_region.min_col),
297
+ _col_entries(curr_sheet, curr_region, curr_header, curr_region.min_col),
298
+ )
299
+ return RegionAlignment(base_region, curr_region, rows, columns)
300
+
301
+
302
+ def _align_block(
303
+ base_sheet: SheetSnapshot,
304
+ curr_sheet: SheetSnapshot,
305
+ base_region: TableRegion,
306
+ curr_region: TableRegion,
307
+ ) -> RegionAlignment:
308
+ label_base = [base_region.key_col or base_region.min_col]
309
+ label_curr = [curr_region.key_col or curr_region.min_col]
310
+ rows = _align_axis(
311
+ _row_entries(base_sheet, base_region, label_base, base_region.min_row),
312
+ _row_entries(curr_sheet, curr_region, label_curr, curr_region.min_row),
313
+ )
314
+ columns = _align_axis(
315
+ _positional_entries(range(base_region.min_col, base_region.max_col + 1)),
316
+ _positional_entries(range(curr_region.min_col, curr_region.max_col + 1)),
317
+ method="positional",
318
+ )
319
+ return RegionAlignment(base_region, curr_region, rows, columns)
320
+
321
+
322
+ def align_regions(
323
+ base_sheet: SheetSnapshot,
324
+ curr_sheet: SheetSnapshot,
325
+ base_region: TableRegion,
326
+ curr_region: TableRegion,
327
+ ) -> RegionAlignment:
328
+ orientation = curr_region.orientation
329
+ if orientation == "long":
330
+ return _align_long(base_sheet, curr_sheet, base_region, curr_region)
331
+ if orientation == "wide":
332
+ return _align_wide(base_sheet, curr_sheet, base_region, curr_region)
333
+ return _align_block(base_sheet, curr_sheet, base_region, curr_region)
334
+
335
+
336
+ # --- workbook alignment ---------------------------------------------------
337
+
338
+
339
+ def _pair_regions(
340
+ base_sheet: SheetSnapshot,
341
+ curr_sheet: SheetSnapshot,
342
+ base_regions: list[TableRegion],
343
+ curr_regions: list[TableRegion],
344
+ ) -> tuple[list[tuple[TableRegion, TableRegion]], list[TableRegion], list[TableRegion]]:
345
+ """Pair regions by stable content and structure, not document order."""
346
+
347
+ def labels(sheet: SheetSnapshot, region: TableRegion) -> frozenset[str]:
348
+ result: set[str] = set()
349
+ for (row, col), cell in sheet.cells.items():
350
+ if not (
351
+ region.min_row <= row <= region.max_row
352
+ and region.min_col <= col <= region.max_col
353
+ ):
354
+ continue
355
+ if not isinstance(cell.value, str) or is_period_label(cell.value):
356
+ continue
357
+ text = " ".join(cell.value.casefold().split())
358
+ if text and not text.startswith("="):
359
+ result.add(re.sub(r"\d+(?:[.,]\d+)*", "#", text))
360
+ return frozenset(result)
361
+
362
+ def similarity(base: TableRegion, curr: TableRegion) -> float:
363
+ if base.orientation != curr.orientation:
364
+ return -1.0
365
+ base_labels = labels(base_sheet, base)
366
+ curr_labels = labels(curr_sheet, curr)
367
+ if base_labels or curr_labels:
368
+ shared = len(base_labels & curr_labels)
369
+ label_score = 2 * shared / max(len(base_labels) + len(curr_labels), 1)
370
+ else:
371
+ label_score = 0.25
372
+ base_rows = base.max_row - base.min_row + 1
373
+ curr_rows = curr.max_row - curr.min_row + 1
374
+ base_cols = base.max_col - base.min_col + 1
375
+ curr_cols = curr.max_col - curr.min_col + 1
376
+ shape_score = (
377
+ min(base_rows, curr_rows) / max(base_rows, curr_rows)
378
+ + min(base_cols, curr_cols) / max(base_cols, curr_cols)
379
+ ) / 2
380
+ distance = abs(base.min_row - curr.min_row) + abs(base.min_col - curr.min_col)
381
+ position_score = 1 / (1 + distance)
382
+ return 0.65 * label_score + 0.25 * shape_score + 0.10 * position_score
383
+
384
+ candidates = sorted(
385
+ (
386
+ (similarity(base, curr), base_index, curr_index)
387
+ for base_index, base in enumerate(base_regions)
388
+ for curr_index, curr in enumerate(curr_regions)
389
+ ),
390
+ key=lambda item: (-item[0], item[1], item[2]),
391
+ )
392
+ used_base: set[int] = set()
393
+ used_curr: set[int] = set()
394
+ indexed_pairs: list[tuple[int, int]] = []
395
+ for score, base_index, curr_index in candidates:
396
+ if score < 0.20:
397
+ break
398
+ if base_index in used_base or curr_index in used_curr:
399
+ continue
400
+ used_base.add(base_index)
401
+ used_curr.add(curr_index)
402
+ indexed_pairs.append((base_index, curr_index))
403
+ indexed_pairs.sort()
404
+ return (
405
+ [(base_regions[b], curr_regions[c]) for b, c in indexed_pairs],
406
+ [region for index, region in enumerate(base_regions) if index not in used_base],
407
+ [region for index, region in enumerate(curr_regions) if index not in used_curr],
408
+ )
409
+
410
+
411
+ def align_workbooks(
412
+ baseline: WorkbookSnapshot,
413
+ current: WorkbookSnapshot,
414
+ profile: DeliverableProfile | None = None,
415
+ *,
416
+ cancellation_token: CancellationToken | None = None,
417
+ ) -> WorkbookAlignment:
418
+ ignore = set(profile.excel.ignore_sheets) if profile else set()
419
+ base_names = [n for n in baseline.sheet_names if n not in ignore]
420
+ curr_names = [n for n in current.sheet_names if n not in ignore]
421
+
422
+ result = WorkbookAlignment(
423
+ common_sheets=[n for n in base_names if n in set(curr_names)],
424
+ added_sheets=[n for n in curr_names if n not in set(base_names)],
425
+ removed_sheets=[n for n in base_names if n not in set(curr_names)],
426
+ )
427
+
428
+ for sheet_name in result.common_sheets:
429
+ check_cancelled(cancellation_token)
430
+ sheet_profile = profile.sheet_profile(sheet_name) if profile else None
431
+ if sheet_profile is not None and sheet_profile.ignore:
432
+ continue
433
+ base_sheet = baseline.sheet(sheet_name)
434
+ curr_sheet = current.sheet(sheet_name)
435
+ base_regions = detect_regions(base_sheet, sheet_profile)
436
+ curr_regions = detect_regions(curr_sheet, sheet_profile)
437
+ pairs, unpaired_base, unpaired_curr = _pair_regions(
438
+ base_sheet, curr_sheet, base_regions, curr_regions
439
+ )
440
+ result.unpaired_baseline_regions.extend(unpaired_base)
441
+ result.unpaired_current_regions.extend(unpaired_curr)
442
+ region_alignments = [
443
+ align_regions(base_sheet, curr_sheet, base_region, curr_region)
444
+ for base_region, curr_region in pairs
445
+ ]
446
+ result.regions[sheet_name] = region_alignments
447
+ result.low_confidence_regions.extend(
448
+ f"{sheet_name}!{region.current.cell_range}"
449
+ for region in region_alignments
450
+ if region.low_confidence
451
+ )
452
+ return result