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
qc_tool/coverage.py ADDED
@@ -0,0 +1,35 @@
1
+ """Explicit run-mode and check-coverage contracts shared by every surface."""
2
+
3
+ from enum import StrEnum
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class QCRunMode(StrEnum):
9
+ CYCLE_COMPARISON = "cycle_comparison"
10
+ CURRENT_FILE_PREFLIGHT = "current_file_preflight"
11
+ FINAL_PACKAGE = "final_package"
12
+
13
+
14
+ class CoverageState(StrEnum):
15
+ CHECKED = "checked"
16
+ UNAVAILABLE = "unavailable"
17
+ DEGRADED = "degraded"
18
+
19
+
20
+ class CoverageItem(BaseModel):
21
+ check_id: str
22
+ label: str
23
+ artifact: str
24
+ state: CoverageState
25
+ findings: int = 0
26
+ detail: str = ""
27
+
28
+
29
+ class MappingCoverage(BaseModel):
30
+ eligible: int = 0
31
+ mapped: int = 0
32
+ verified: int = 0
33
+ mismatched: int = 0
34
+ unresolved: int = 0
35
+ unmapped: int = 0
@@ -0,0 +1 @@
1
+ """Excel-to-PPT figure cross-checking: format-aware matching and source tracing."""
@@ -0,0 +1,146 @@
1
+ """Format-aware figure parsing and display-precision matching.
2
+
3
+ Deck figures are rounded, scaled, and decorated ("$1.2M", "12%",
4
+ "(1,234)"). A figure matches a workbook value when the value, projected
5
+ into the figure's display space (scale applied, percent multiplied out),
6
+ falls within half a unit of the figure's last displayed decimal — robust
7
+ to the different rounding modes of Excel and Python.
8
+ """
9
+
10
+ import re
11
+ from dataclasses import dataclass
12
+
13
+ _SUFFIX_SCALE = {
14
+ "k": 1e3,
15
+ "m": 1e6,
16
+ "mn": 1e6,
17
+ "b": 1e9,
18
+ "bn": 1e9,
19
+ }
20
+
21
+ _FIGURE_RE = re.compile(
22
+ r"(?<![\w.,-])" # not glued to a word, number, or hyphenated label (Jan-26)
23
+ r"(?P<open>\()?\s*"
24
+ r"(?P<currency>[$\u20ac\u00a3\u20b9])?\s*"
25
+ r"(?P<sign>-)?"
26
+ r"(?P<number>\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?)"
27
+ r"\s*(?P<suffix>%|bn|mn|k|m|b)?"
28
+ r"(?P<close>\))?"
29
+ r"(?![\w%])",
30
+ re.IGNORECASE,
31
+ )
32
+
33
+ #: Numeric tokens blanked out — used for line skeletons that stay stable
34
+ #: while figures refresh each cycle.
35
+ _NUMERIC_TOKEN_RE = re.compile(r"[-+]?[\d.,]+\s*%?")
36
+
37
+
38
+ def numeric_skeleton(text: str) -> str:
39
+ """Text with numeric tokens blanked; equal skeletons = same wording."""
40
+ return _NUMERIC_TOKEN_RE.sub("#", text)
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class ParsedFigure:
45
+ raw: str
46
+ mantissa: float # the number as displayed (1.2 for "$1.2M")
47
+ scale: float # 1e6 for M, 1e3 for k, 1 otherwise
48
+ is_percent: bool
49
+ decimals: int # displayed decimal places of the mantissa
50
+ negative: bool
51
+
52
+ @property
53
+ def value(self) -> float:
54
+ """Normalized numeric value ($1.2M -> 1_200_000; 12% -> 0.12)."""
55
+ signed = -self.mantissa if self.negative else self.mantissa
56
+ if self.is_percent:
57
+ return signed / 100.0
58
+ return signed * self.scale
59
+
60
+
61
+ def _from_match(match: re.Match[str]) -> ParsedFigure:
62
+ number = match["number"].replace(",", "")
63
+ mantissa = float(number)
64
+ decimals = len(number.rsplit(".", 1)[1]) if "." in number else 0
65
+ suffix = (match["suffix"] or "").lower()
66
+ negative = bool(match["sign"]) or bool(match["open"] and match["close"])
67
+ return ParsedFigure(
68
+ raw=match.group(0).strip(),
69
+ mantissa=mantissa,
70
+ scale=_SUFFIX_SCALE.get(suffix, 1.0),
71
+ is_percent=suffix == "%",
72
+ decimals=decimals,
73
+ negative=negative,
74
+ )
75
+
76
+
77
+ def parse_figure(text: str) -> ParsedFigure | None:
78
+ """Parse a single figure token; None if the text is not one figure."""
79
+ match = _FIGURE_RE.search(text.strip())
80
+ if match is None or match.group(0).strip() != text.strip():
81
+ return None
82
+ return _from_match(match)
83
+
84
+
85
+ def extract_figures(text: str) -> list[ParsedFigure]:
86
+ """All figure tokens in a line of display text."""
87
+ return [_from_match(m) for m in _FIGURE_RE.finditer(text)]
88
+
89
+
90
+ def display_matches(figure: ParsedFigure, cell_value: float) -> bool:
91
+ """True if ``cell_value`` displays as ``figure`` at its shown precision."""
92
+ scaled = cell_value * 100.0 if figure.is_percent else cell_value / figure.scale
93
+ shown = -figure.mantissa if figure.negative else figure.mantissa
94
+ tolerance = 0.5 * 10.0 ** (-figure.decimals) + 1e-9
95
+ return abs(scaled - shown) <= tolerance
96
+
97
+
98
+ def relative_difference(figure: ParsedFigure, cell_value: float) -> float:
99
+ """Relative distance between a figure and a value (for near-miss ranking)."""
100
+ reference = figure.value
101
+ if reference == 0:
102
+ return abs(cell_value)
103
+ return abs(cell_value - reference) / abs(reference)
104
+
105
+
106
+ def format_figure_like(figure: ParsedFigure, value: float) -> str:
107
+ """Render ``value`` with the source figure's currency/scale/precision style."""
108
+ raw = figure.raw.strip()
109
+ currency_match = re.search(r"[$\u20ac\u00a3\u20b9]", raw)
110
+ currency = currency_match.group(0) if currency_match else ""
111
+ suffix_match = re.search(r"(%|bn|mn|k|m|b)\s*\)?$", raw, re.IGNORECASE)
112
+ suffix = suffix_match.group(1) if suffix_match else ""
113
+ scaled = value * 100.0 if figure.is_percent else value / figure.scale
114
+ negative = scaled < 0
115
+ magnitude = abs(scaled)
116
+ use_grouping = "," in raw
117
+ number = (
118
+ f"{magnitude:,.{figure.decimals}f}"
119
+ if use_grouping
120
+ else f"{magnitude:.{figure.decimals}f}"
121
+ )
122
+ rendered = f"{currency}{number}{suffix}"
123
+ if negative:
124
+ return f"({rendered})" if raw.startswith("(") else f"-{rendered}"
125
+ return rendered
126
+
127
+
128
+ def replace_figure_ordinals(
129
+ text: str, replacements: dict[int, str], *, start_index: int = 0
130
+ ) -> tuple[str, int]:
131
+ """Replace global figure ordinals in one text fragment; return next index."""
132
+ index = start_index
133
+
134
+ def replace(match: re.Match[str]) -> str:
135
+ nonlocal index
136
+ current = index
137
+ index += 1
138
+ replacement = replacements.get(current)
139
+ if replacement is None:
140
+ return match.group(0)
141
+ raw = match.group(0)
142
+ leading = raw[: len(raw) - len(raw.lstrip())]
143
+ trailing = raw[len(raw.rstrip()) :]
144
+ return f"{leading}{replacement}{trailing}"
145
+
146
+ return _FIGURE_RE.sub(replace, text), index
@@ -0,0 +1,154 @@
1
+ """Current Excel plus current PowerPoint final-package reconciliation."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ from qc_tool.config.profile import CrosscheckProfile
6
+ from qc_tool.coverage import CoverageItem, CoverageState, MappingCoverage
7
+ from qc_tool.crosscheck.trace import (
8
+ MappingSuggestion,
9
+ SuggestedSource,
10
+ extract_deck_figures,
11
+ mapping_identity,
12
+ occurrence_identity,
13
+ suggest_sources,
14
+ verify_mappings,
15
+ )
16
+ from qc_tool.excel.periods import Period, is_period_after, parse_period
17
+ from qc_tool.findings import Finding, FindingClass
18
+ from qc_tool.io.model import WorkbookSnapshot
19
+ from qc_tool.ppt.extract import DeckSnapshot
20
+ from qc_tool.ppt.preflight import contextual_periods
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class PackageQCResult:
25
+ findings: list[Finding] = field(default_factory=list)
26
+ coverage: list[CoverageItem] = field(default_factory=list)
27
+ mapping_coverage: MappingCoverage = field(default_factory=MappingCoverage)
28
+ suggestions: list[MappingSuggestion] = field(default_factory=list)
29
+
30
+
31
+ def _latest_workbook_periods(workbook: WorkbookSnapshot) -> dict[str, Period]:
32
+ latest: dict[str, Period] = {}
33
+ for sheet in workbook.sheets:
34
+ for cell in sheet.cells.values():
35
+ period = parse_period(cell.value)
36
+ if period is None:
37
+ continue
38
+ previous = latest.get(period.kind)
39
+ if previous is None or is_period_after(period, previous):
40
+ latest[period.kind] = period
41
+ return latest
42
+
43
+
44
+ def _period_reconciliation(
45
+ workbook: WorkbookSnapshot, deck: DeckSnapshot
46
+ ) -> list[Finding]:
47
+ findings: list[Finding] = []
48
+ latest = _latest_workbook_periods(workbook)
49
+ for (kind, sort_key), sources in contextual_periods(deck).items():
50
+ workbook_period = latest.get(kind)
51
+ if workbook_period is None or workbook_period.sort_key == sort_key:
52
+ continue
53
+ findings.append(
54
+ Finding(
55
+ artifact="crosscheck",
56
+ finding_class=FindingClass.PACKAGE_PERIOD_MISMATCH,
57
+ element="reporting period",
58
+ baseline_value=workbook_period.label,
59
+ current_value="; ".join(sorted(sources)),
60
+ message=(
61
+ "PowerPoint contextual reporting period does not match the "
62
+ f"latest {kind} period in Excel ({workbook_period.label})"
63
+ ),
64
+ )
65
+ )
66
+ return findings
67
+
68
+
69
+ def reconcile_package(
70
+ workbook: WorkbookSnapshot, deck: DeckSnapshot, profile: CrosscheckProfile
71
+ ) -> PackageQCResult:
72
+ result = PackageQCResult()
73
+ occurrences = extract_deck_figures(deck)
74
+ mapped_identities = {mapping_identity(mapping) for mapping in profile.mappings}
75
+ mapped_occurrences = [
76
+ occurrence
77
+ for occurrence in occurrences
78
+ if occurrence_identity(occurrence) in mapped_identities
79
+ ]
80
+ unmapped_occurrences = [
81
+ occurrence
82
+ for occurrence in occurrences
83
+ if occurrence_identity(occurrence) not in mapped_identities
84
+ ]
85
+
86
+ verified = verify_mappings(deck, workbook, profile)
87
+ result.findings.extend(verified.findings)
88
+ mismatched = sum(
89
+ finding.finding_class is FindingClass.CROSSCHECK_MISMATCH
90
+ for finding in verified.findings
91
+ )
92
+ unresolved = sum(
93
+ finding.finding_class is FindingClass.CROSSCHECK_UNRESOLVED
94
+ for finding in verified.findings
95
+ )
96
+ result.mapping_coverage = MappingCoverage(
97
+ eligible=len(occurrences),
98
+ mapped=len(mapped_occurrences),
99
+ verified=len(verified.verified),
100
+ mismatched=mismatched,
101
+ unresolved=unresolved,
102
+ unmapped=len(unmapped_occurrences),
103
+ )
104
+ for occurrence in unmapped_occurrences:
105
+ candidates = suggest_sources(
106
+ occurrence, workbook, limit=profile.max_candidates
107
+ )
108
+ result.suggestions.append(
109
+ MappingSuggestion(
110
+ slide=occurrence.slide,
111
+ line=occurrence.line,
112
+ line_skeleton=occurrence.line_skeleton,
113
+ figure_index=occurrence.figure_index,
114
+ figure_raw=occurrence.figure.raw,
115
+ candidates=[SuggestedSource.from_candidate(candidate) for candidate in candidates],
116
+ )
117
+ )
118
+
119
+ period_findings = _period_reconciliation(workbook, deck)
120
+ result.findings.extend(period_findings)
121
+ mapping = result.mapping_coverage
122
+ result.coverage.extend(
123
+ [
124
+ CoverageItem(
125
+ check_id="excel-ppt-crosscheck",
126
+ label="Excel to PowerPoint figure mappings",
127
+ artifact="package",
128
+ state=(
129
+ CoverageState.CHECKED
130
+ if deck.charts_available
131
+ else CoverageState.DEGRADED
132
+ ),
133
+ findings=len(verified.findings),
134
+ detail=(
135
+ f"{mapping.eligible} eligible; {mapping.mapped} mapped; "
136
+ f"{mapping.verified} verified; {mapping.mismatched} mismatched; "
137
+ f"{mapping.unresolved} unresolved; {mapping.unmapped} unmapped"
138
+ + (
139
+ ""
140
+ if deck.charts_available
141
+ else "; visible native chart labels unavailable"
142
+ )
143
+ ),
144
+ ),
145
+ CoverageItem(
146
+ check_id="package-period-reconciliation",
147
+ label="Excel and PowerPoint reporting-period reconciliation",
148
+ artifact="package",
149
+ state=CoverageState.CHECKED,
150
+ findings=len(period_findings),
151
+ ),
152
+ ]
153
+ )
154
+ return result