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,86 @@
1
+ """Fail-safe persisted server exposure configuration."""
2
+
3
+ import datetime as dt
4
+ import logging
5
+ from enum import StrEnum
6
+ from pathlib import Path
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from qc_tool.security import private_directory, private_file
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class NetworkMode(StrEnum):
16
+ LOCAL = "local"
17
+ LAN = "lan"
18
+
19
+
20
+ class ServerConfig(BaseModel):
21
+ network: NetworkMode = NetworkMode.LOCAL
22
+ expires_at: dt.datetime | None = None
23
+
24
+ @property
25
+ def host(self) -> str:
26
+ return "0.0.0.0" if self.network is NetworkMode.LAN else "127.0.0.1"
27
+
28
+ def is_expired(self, *, now: dt.datetime | None = None) -> bool:
29
+ if self.network is not NetworkMode.LAN:
30
+ return False
31
+ current = now or dt.datetime.now(dt.UTC)
32
+ return self.expires_at is None or self.expires_at <= current
33
+
34
+
35
+ def config_path(data_dir: Path) -> Path:
36
+ return data_dir / "server-config.json"
37
+
38
+
39
+ def local_config() -> ServerConfig:
40
+ return ServerConfig(network=NetworkMode.LOCAL)
41
+
42
+
43
+ def temporary_lan_config(minutes: int) -> ServerConfig:
44
+ if minutes < 1 or minutes > 24 * 60:
45
+ raise ValueError("LAN exposure duration must be between 1 and 1440 minutes")
46
+ return ServerConfig(
47
+ network=NetworkMode.LAN,
48
+ expires_at=dt.datetime.now(dt.UTC) + dt.timedelta(minutes=minutes),
49
+ )
50
+
51
+
52
+ def save_server_config(data_dir: Path, config: ServerConfig) -> Path:
53
+ private_directory(data_dir)
54
+ path = config_path(data_dir)
55
+ path.write_text(config.model_dump_json(indent=2), encoding="utf-8")
56
+ private_file(path)
57
+ return path
58
+
59
+
60
+ def load_server_config(
61
+ data_dir: Path, *, now: dt.datetime | None = None
62
+ ) -> ServerConfig:
63
+ path = config_path(data_dir)
64
+ if not path.exists():
65
+ return local_config()
66
+ try:
67
+ config = ServerConfig.model_validate_json(path.read_text(encoding="utf-8"))
68
+ except Exception as exc:
69
+ logger.error("invalid server config %s; falling back to local: %s", path, exc)
70
+ config = local_config()
71
+ save_server_config(data_dir, config)
72
+ return config
73
+ if config.is_expired(now=now):
74
+ config = local_config()
75
+ save_server_config(data_dir, config)
76
+ return config
77
+
78
+
79
+ def lan_config_matches(data_dir: Path, expected_expiry: dt.datetime) -> bool:
80
+ """Whether a running LAN server is still authorized by persisted config."""
81
+ config = load_server_config(data_dir)
82
+ return (
83
+ config.network is NetworkMode.LAN
84
+ and config.expires_at == expected_expiry
85
+ and not config.is_expired()
86
+ )
@@ -0,0 +1 @@
1
+ """Severity triage rules applied to findings."""
@@ -0,0 +1,211 @@
1
+ """Severity triage: classify findings and finalize them for reporting.
2
+
3
+ Rules, in order:
4
+
5
+ 1. Expected cadence growth is always ``expected`` — the whole point of the
6
+ tool is that growth never drowns out real errors. Profile overrides do
7
+ not escalate expected findings (use chart-window / refresh-range /
8
+ region settings to change what counts as expected instead).
9
+ 2. Profile per-class overrides apply to non-expected findings.
10
+ 3. Otherwise the default class severity applies.
11
+
12
+ `triage` also orders findings deterministically (critical first) and
13
+ assigns stable ids — the finalized list is the contract every report
14
+ consumes.
15
+ """
16
+
17
+ import datetime as dt
18
+ from collections import Counter
19
+
20
+ from qc_tool.config.profile import DeliverableProfile
21
+ from qc_tool.findings import Finding, FindingClass, Severity
22
+
23
+ DEFAULT_SEVERITIES: dict[FindingClass, Severity] = {
24
+ # Historical data integrity: critical.
25
+ FindingClass.VALUE_CHANGED: Severity.CRITICAL,
26
+ FindingClass.FORMULA_ERROR: Severity.CRITICAL,
27
+ FindingClass.FORMULA_HARDCODED: Severity.CRITICAL,
28
+ FindingClass.FORMULA_REMOVED: Severity.CRITICAL,
29
+ FindingClass.FORMULA_MISSING: Severity.CRITICAL,
30
+ FindingClass.EXTERNAL_LINK: Severity.CRITICAL,
31
+ FindingClass.NAMED_RANGE_INVALID: Severity.CRITICAL,
32
+ FindingClass.CHART_REFERENCE_INVALID: Severity.CRITICAL,
33
+ FindingClass.PIVOT_SOURCE_INVALID: Severity.CRITICAL,
34
+ FindingClass.PPT_DRAFT_TOKEN: Severity.CRITICAL,
35
+ FindingClass.PPT_REQUIRED_SLIDE_MISSING: Severity.CRITICAL,
36
+ FindingClass.REQUIRED_VALUE_MISSING: Severity.CRITICAL,
37
+ FindingClass.DUPLICATE_KEY: Severity.CRITICAL,
38
+ FindingClass.NUMERIC_BOUND_VIOLATION: Severity.CRITICAL,
39
+ FindingClass.TIE_OUT_MISMATCH: Severity.CRITICAL,
40
+ FindingClass.ROW_DELETED: Severity.CRITICAL,
41
+ FindingClass.COLUMN_DELETED: Severity.CRITICAL,
42
+ FindingClass.SHEET_REMOVED: Severity.CRITICAL,
43
+ FindingClass.CROSSCHECK_MISMATCH: Severity.CRITICAL,
44
+ FindingClass.PACKAGE_PERIOD_MISMATCH: Severity.CRITICAL,
45
+ # Formula and structure drift: warning.
46
+ FindingClass.FORMULA_LOGIC_CHANGED: Severity.WARNING,
47
+ FindingClass.FORMULA_NOT_EXTENDED: Severity.WARNING,
48
+ FindingClass.FORMULA_INCONSISTENT: Severity.WARNING,
49
+ FindingClass.FORMULA_CACHE_MISSING: Severity.WARNING,
50
+ FindingClass.PERIOD_DUPLICATE: Severity.WARNING,
51
+ FindingClass.PERIOD_OUT_OF_ORDER: Severity.WARNING,
52
+ FindingClass.PERIOD_GAP: Severity.WARNING,
53
+ FindingClass.CALCULATION_MODE: Severity.WARNING,
54
+ FindingClass.CHART_LENGTH_MISMATCH: Severity.WARNING,
55
+ FindingClass.PPT_EMPTY_SLIDE: Severity.WARNING,
56
+ FindingClass.PPT_DUPLICATE_TITLE: Severity.WARNING,
57
+ FindingClass.PPT_PERIOD_INCONSISTENT: Severity.WARNING,
58
+ FindingClass.PPT_TABLE_BLANK: Severity.WARNING,
59
+ FindingClass.PPT_CHART_LENGTH_MISMATCH: Severity.WARNING,
60
+ FindingClass.PPT_CHART_VALUE_MISSING: Severity.WARNING,
61
+ FindingClass.CONTROL_INVALID: Severity.WARNING,
62
+ FindingClass.WAIVER_EXPIRED: Severity.WARNING,
63
+ FindingClass.NUMBER_FORMAT_CHANGED: Severity.WARNING,
64
+ FindingClass.NAMED_RANGE_CHANGED: Severity.WARNING,
65
+ FindingClass.TABLE_STRUCTURE_CHANGED: Severity.WARNING,
66
+ FindingClass.DATA_VALIDATION_CHANGED: Severity.WARNING,
67
+ FindingClass.CONDITIONAL_FORMAT_CHANGED: Severity.WARNING,
68
+ FindingClass.CHART_STRUCTURE_CHANGED: Severity.WARNING,
69
+ FindingClass.CHART_PLOT_CHANGED: Severity.WARNING,
70
+ FindingClass.CHART_SERIES_CHANGED: Severity.WARNING,
71
+ FindingClass.CHART_AXIS_CHANGED: Severity.WARNING,
72
+ FindingClass.CHART_LEGEND_CHANGED: Severity.WARNING,
73
+ FindingClass.CHART_LABELS_CHANGED: Severity.WARNING,
74
+ FindingClass.PIVOT_SOURCE_CHANGED: Severity.WARNING,
75
+ FindingClass.HIDDEN_CHANGED: Severity.WARNING,
76
+ FindingClass.ROW_INSERTED: Severity.WARNING,
77
+ FindingClass.COLUMN_INSERTED: Severity.WARNING,
78
+ FindingClass.REGION_UNPAIRED: Severity.WARNING,
79
+ FindingClass.ALIGNMENT_LOW_CONFIDENCE: Severity.WARNING,
80
+ FindingClass.FINDINGS_CAPPED: Severity.WARNING,
81
+ FindingClass.SLIDE_REMOVED: Severity.WARNING,
82
+ FindingClass.SLIDE_TEXT_CHANGED: Severity.WARNING,
83
+ FindingClass.TABLE_VALUE_CHANGED: Severity.WARNING,
84
+ FindingClass.CHART_VALUE_CHANGED: Severity.WARNING,
85
+ FindingClass.PPT_TABLE_STRUCTURE_CHANGED: Severity.WARNING,
86
+ FindingClass.PPT_CHART_STRUCTURE_CHANGED: Severity.WARNING,
87
+ FindingClass.PPT_CHART_PLOT_CHANGED: Severity.WARNING,
88
+ FindingClass.PPT_CHART_SERIES_CHANGED: Severity.WARNING,
89
+ FindingClass.PPT_CHART_AXIS_CHANGED: Severity.WARNING,
90
+ FindingClass.PPT_CHART_LEGEND_CHANGED: Severity.WARNING,
91
+ FindingClass.PPT_CHART_LABELS_CHANGED: Severity.WARNING,
92
+ # Presentation-only and additive events: info.
93
+ FindingClass.STYLE_CHANGED: Severity.INFO,
94
+ FindingClass.CHART_GEOMETRY_CHANGED: Severity.INFO,
95
+ FindingClass.PPT_SHAPE_GEOMETRY_CHANGED: Severity.INFO,
96
+ FindingClass.SHEET_ADDED: Severity.INFO,
97
+ FindingClass.SLIDE_ADDED: Severity.INFO,
98
+ FindingClass.SLIDE_REORDERED: Severity.INFO,
99
+ FindingClass.CROSSCHECK_UNRESOLVED: Severity.INFO,
100
+ FindingClass.HIDDEN_CONTENT: Severity.INFO,
101
+ # Growth classes only ever occur with expected_growth=True.
102
+ FindingClass.ROW_GROWTH: Severity.EXPECTED,
103
+ FindingClass.COLUMN_GROWTH: Severity.EXPECTED,
104
+ }
105
+
106
+ _SEVERITY_RANK = {
107
+ Severity.CRITICAL: 0,
108
+ Severity.WARNING: 1,
109
+ Severity.INFO: 2,
110
+ Severity.EXPECTED: 3,
111
+ }
112
+
113
+
114
+ def assign_severity(finding: Finding, profile: DeliverableProfile | None = None) -> Severity:
115
+ if finding.expected_growth:
116
+ return Severity.EXPECTED
117
+ overrides = profile.severity if profile is not None else {}
118
+ return overrides.get(
119
+ finding.finding_class,
120
+ DEFAULT_SEVERITIES.get(finding.finding_class, Severity.WARNING),
121
+ )
122
+
123
+
124
+ def triage(
125
+ findings: list[Finding], profile: DeliverableProfile | None = None
126
+ ) -> list[Finding]:
127
+ """Assign severities, order deterministically, and number the findings."""
128
+ today = dt.date.today()
129
+ waivers = profile.waivers if profile is not None else []
130
+ for waiver in waivers:
131
+ if waiver.expires >= today:
132
+ continue
133
+ findings.append(
134
+ Finding(
135
+ artifact="profile",
136
+ finding_class=FindingClass.WAIVER_EXPIRED,
137
+ sheet=waiver.sheet,
138
+ slide=waiver.slide,
139
+ location=waiver.location,
140
+ element=waiver.element or waiver.finding_class.value,
141
+ current_value=waiver.expires.isoformat(),
142
+ message=(
143
+ f"waiver for {waiver.finding_class.value} expired on "
144
+ f"{waiver.expires.isoformat()}: {waiver.reason}"
145
+ ),
146
+ )
147
+ )
148
+
149
+ def matches_waiver(finding: Finding, waiver) -> bool:
150
+ return (
151
+ finding.finding_class is waiver.finding_class
152
+ and (waiver.sheet is None or finding.sheet == waiver.sheet)
153
+ and (waiver.slide is None or finding.slide == waiver.slide)
154
+ and (waiver.location is None or finding.location == waiver.location)
155
+ and (waiver.element is None or finding.element == waiver.element)
156
+ )
157
+
158
+ for finding in findings:
159
+ active = next(
160
+ (
161
+ waiver
162
+ for waiver in waivers
163
+ if waiver.expires >= today and matches_waiver(finding, waiver)
164
+ ),
165
+ None,
166
+ )
167
+ if active is not None:
168
+ finding.severity = Severity.EXPECTED
169
+ finding.waiver_reason = active.reason
170
+ finding.waiver_expires = active.expires.isoformat()
171
+ else:
172
+ finding.severity = assign_severity(finding, profile)
173
+
174
+ formula_classes = {
175
+ FindingClass.FORMULA_ERROR,
176
+ FindingClass.FORMULA_HARDCODED,
177
+ FindingClass.FORMULA_REMOVED,
178
+ FindingClass.FORMULA_MISSING,
179
+ FindingClass.FORMULA_NOT_EXTENDED,
180
+ FindingClass.FORMULA_LOGIC_CHANGED,
181
+ FindingClass.FORMULA_INCONSISTENT,
182
+ }
183
+ candidates = [
184
+ (
185
+ finding,
186
+ f"{finding.artifact}:{finding.sheet or finding.slide}:"
187
+ f"{finding.location or finding.element}",
188
+ )
189
+ for finding in findings
190
+ if finding.finding_class in formula_classes
191
+ and (finding.location or finding.element)
192
+ ]
193
+ root_counts = Counter(key for _, key in candidates)
194
+ for finding, key in candidates:
195
+ if root_counts[key] > 1:
196
+ finding.root_cause_key = key
197
+ ordered = sorted(
198
+ findings,
199
+ key=lambda f: (
200
+ _SEVERITY_RANK[f.severity or Severity.WARNING],
201
+ f.artifact,
202
+ f.sheet or f.slide or "",
203
+ f.location or "",
204
+ f.element or "",
205
+ f.finding_class.value,
206
+ f.message,
207
+ ),
208
+ )
209
+ for index, finding in enumerate(ordered, start=1):
210
+ finding.finding_id = f"F{index:04d}"
211
+ return ordered
qc_tool/ui/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """NiceGUI local web application."""