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
qc_tool/server_config.py
ADDED
|
@@ -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."""
|
qc_tool/triage/rules.py
ADDED
|
@@ -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."""
|