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/attestation.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Signed QC attestation bundles and tamper verification."""
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import secrets
|
|
9
|
+
import zipfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
|
|
14
|
+
from qc_tool import __version__
|
|
15
|
+
from qc_tool.config.profile import DeliverableProfile
|
|
16
|
+
from qc_tool.engine import QCRunResult
|
|
17
|
+
from qc_tool.report.json_report import result_payload
|
|
18
|
+
from qc_tool.security import private_directory, private_file
|
|
19
|
+
|
|
20
|
+
_KEY_BYTES = 32
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AttestationIssue(BaseModel):
|
|
24
|
+
code: str
|
|
25
|
+
message: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AttestationVerification(BaseModel):
|
|
29
|
+
valid: bool
|
|
30
|
+
key_id: str = ""
|
|
31
|
+
issues: list[AttestationIssue] = Field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
def add(self, code: str, message: str) -> None:
|
|
34
|
+
self.valid = False
|
|
35
|
+
self.issues.append(AttestationIssue(code=code, message=message))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _sha256_bytes(data: bytes) -> str:
|
|
39
|
+
return hashlib.sha256(data).hexdigest()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _canonical(payload: dict) -> bytes:
|
|
43
|
+
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _key_id(key: bytes) -> str:
|
|
47
|
+
return hashlib.sha256(key).hexdigest()[:16]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_or_create_attestation_key(data_dir: Path) -> tuple[Path, bytes]:
|
|
51
|
+
private_directory(data_dir)
|
|
52
|
+
key_path = data_dir / "attestation.key"
|
|
53
|
+
if key_path.exists():
|
|
54
|
+
key = key_path.read_bytes()
|
|
55
|
+
else:
|
|
56
|
+
key = secrets.token_bytes(_KEY_BYTES)
|
|
57
|
+
key_path.write_bytes(key)
|
|
58
|
+
private_file(key_path)
|
|
59
|
+
if len(key) < _KEY_BYTES:
|
|
60
|
+
raise ValueError(f"attestation key {key_path} must contain at least {_KEY_BYTES} bytes")
|
|
61
|
+
if os.name == "posix" and key_path.stat().st_mode & 0o077:
|
|
62
|
+
raise ValueError(f"attestation key {key_path} must not be group/world accessible")
|
|
63
|
+
return key_path, key
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_attestation_key(path: Path) -> bytes:
|
|
67
|
+
key = path.read_bytes()
|
|
68
|
+
if len(key) < _KEY_BYTES:
|
|
69
|
+
raise ValueError(f"attestation key {path} must contain at least {_KEY_BYTES} bytes")
|
|
70
|
+
if os.name == "posix" and path.stat().st_mode & 0o077:
|
|
71
|
+
raise ValueError(f"attestation key {path} must not be group/world accessible")
|
|
72
|
+
return key
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def create_attestation(
|
|
76
|
+
output: Path,
|
|
77
|
+
*,
|
|
78
|
+
result: QCRunResult,
|
|
79
|
+
profile: DeliverableProfile,
|
|
80
|
+
input_files: dict[str, Path],
|
|
81
|
+
report_paths: dict[str, Path],
|
|
82
|
+
key: bytes,
|
|
83
|
+
) -> Path:
|
|
84
|
+
"""Create a private signed bundle containing evidence and report members."""
|
|
85
|
+
profile_bytes = json.dumps(
|
|
86
|
+
profile.model_dump(mode="json", by_alias=True), indent=2
|
|
87
|
+
).encode("utf-8")
|
|
88
|
+
findings_bytes = json.dumps(
|
|
89
|
+
result_payload(result, include_context=True), indent=2
|
|
90
|
+
).encode("utf-8")
|
|
91
|
+
members: dict[str, bytes] = {
|
|
92
|
+
"evidence/profile.json": profile_bytes,
|
|
93
|
+
"evidence/findings.json": findings_bytes,
|
|
94
|
+
}
|
|
95
|
+
for kind, path in sorted(report_paths.items()):
|
|
96
|
+
members[f"reports/qc_report.{path.suffix.lstrip('.') or kind}"] = path.read_bytes()
|
|
97
|
+
|
|
98
|
+
member_manifest = {
|
|
99
|
+
name: {"sha256": _sha256_bytes(data), "size": len(data)}
|
|
100
|
+
for name, data in sorted(members.items())
|
|
101
|
+
}
|
|
102
|
+
inputs = {
|
|
103
|
+
role: {
|
|
104
|
+
"name": path.name,
|
|
105
|
+
"sha256": _sha256_bytes(path.read_bytes()),
|
|
106
|
+
"size": path.stat().st_size,
|
|
107
|
+
}
|
|
108
|
+
for role, path in sorted(input_files.items())
|
|
109
|
+
}
|
|
110
|
+
analyst_decisions = [
|
|
111
|
+
{
|
|
112
|
+
"finding_id": finding.finding_id,
|
|
113
|
+
"severity": finding.severity.value if finding.severity else None,
|
|
114
|
+
"severity_overridden": finding.severity_overridden,
|
|
115
|
+
"comment": finding.analyst_comment,
|
|
116
|
+
"waiver_reason": finding.waiver_reason,
|
|
117
|
+
"waiver_expires": finding.waiver_expires,
|
|
118
|
+
}
|
|
119
|
+
for finding in result.findings
|
|
120
|
+
if finding.severity_overridden or finding.analyst_comment or finding.waiver_reason
|
|
121
|
+
]
|
|
122
|
+
unsigned = {
|
|
123
|
+
"schema_version": 1,
|
|
124
|
+
"generated_at": dt.datetime.now(dt.UTC).isoformat(timespec="seconds"),
|
|
125
|
+
"tool": {"distribution": "cadence-diff", "version": __version__},
|
|
126
|
+
"run": {
|
|
127
|
+
"mode": result.mode.value,
|
|
128
|
+
"profile": result.profile_name,
|
|
129
|
+
"counts": {severity.value: count for severity, count in result.counts.items()},
|
|
130
|
+
"coverage": [item.model_dump(mode="json") for item in result.coverage],
|
|
131
|
+
"mapping_coverage": (
|
|
132
|
+
result.mapping_coverage.model_dump(mode="json")
|
|
133
|
+
if result.mapping_coverage is not None
|
|
134
|
+
else None
|
|
135
|
+
),
|
|
136
|
+
"verified_crosschecks": result.verified_crosschecks,
|
|
137
|
+
},
|
|
138
|
+
"profile_sha256": _sha256_bytes(profile_bytes),
|
|
139
|
+
"inputs": inputs,
|
|
140
|
+
"members": member_manifest,
|
|
141
|
+
"waivers": [item.model_dump(mode="json") for item in profile.waivers],
|
|
142
|
+
"analyst_decisions": analyst_decisions,
|
|
143
|
+
}
|
|
144
|
+
signature = hmac.new(key, _canonical(unsigned), hashlib.sha256).hexdigest()
|
|
145
|
+
manifest = {
|
|
146
|
+
**unsigned,
|
|
147
|
+
"signature": {
|
|
148
|
+
"algorithm": "HMAC-SHA256",
|
|
149
|
+
"key_id": _key_id(key),
|
|
150
|
+
"value": signature,
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
private_directory(output.parent)
|
|
154
|
+
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
|
155
|
+
archive.writestr("manifest.json", json.dumps(manifest, indent=2))
|
|
156
|
+
for name, data in members.items():
|
|
157
|
+
archive.writestr(name, data)
|
|
158
|
+
private_file(output)
|
|
159
|
+
return output
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def verify_attestation(path: Path, *, key: bytes) -> AttestationVerification:
|
|
163
|
+
result = AttestationVerification(valid=True, key_id=_key_id(key))
|
|
164
|
+
try:
|
|
165
|
+
archive = zipfile.ZipFile(path)
|
|
166
|
+
except zipfile.BadZipFile:
|
|
167
|
+
result.add("bad-zip", "attestation is not a valid ZIP archive")
|
|
168
|
+
return result
|
|
169
|
+
with archive:
|
|
170
|
+
names = set(archive.namelist())
|
|
171
|
+
if "manifest.json" not in names:
|
|
172
|
+
result.add("missing-manifest", "manifest.json is missing")
|
|
173
|
+
return result
|
|
174
|
+
try:
|
|
175
|
+
manifest = json.loads(archive.read("manifest.json"))
|
|
176
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
177
|
+
result.add("invalid-manifest", "manifest.json is not valid JSON")
|
|
178
|
+
return result
|
|
179
|
+
signature = manifest.pop("signature", None)
|
|
180
|
+
if not isinstance(signature, dict):
|
|
181
|
+
result.add("missing-signature", "manifest signature is missing")
|
|
182
|
+
return result
|
|
183
|
+
if signature.get("algorithm") != "HMAC-SHA256":
|
|
184
|
+
result.add("signature-algorithm", "unsupported signature algorithm")
|
|
185
|
+
if signature.get("key_id") != _key_id(key):
|
|
186
|
+
result.add("key-id", "attestation was signed with a different key")
|
|
187
|
+
expected = hmac.new(key, _canonical(manifest), hashlib.sha256).hexdigest()
|
|
188
|
+
if not hmac.compare_digest(str(signature.get("value", "")), expected):
|
|
189
|
+
result.add("signature", "manifest signature does not verify")
|
|
190
|
+
members = manifest.get("members")
|
|
191
|
+
if not isinstance(members, dict):
|
|
192
|
+
result.add("member-manifest", "member hash manifest is invalid")
|
|
193
|
+
return result
|
|
194
|
+
expected_names = {"manifest.json", *members}
|
|
195
|
+
unexpected = names - expected_names
|
|
196
|
+
missing = expected_names - names
|
|
197
|
+
if unexpected:
|
|
198
|
+
result.add("unexpected-members", f"unexpected members: {sorted(unexpected)}")
|
|
199
|
+
if missing:
|
|
200
|
+
result.add("missing-members", f"missing members: {sorted(missing)}")
|
|
201
|
+
for name, metadata in members.items():
|
|
202
|
+
if name not in names or not isinstance(metadata, dict):
|
|
203
|
+
continue
|
|
204
|
+
data = archive.read(name)
|
|
205
|
+
if metadata.get("sha256") != _sha256_bytes(data):
|
|
206
|
+
result.add("member-hash", f"hash mismatch for {name}")
|
|
207
|
+
if metadata.get("size") != len(data):
|
|
208
|
+
result.add("member-size", f"size mismatch for {name}")
|
|
209
|
+
return result
|
qc_tool/availability.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Profile-driven blank availability boundaries shared by Excel and PowerPoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from openpyxl.utils.cell import range_boundaries
|
|
6
|
+
|
|
7
|
+
from qc_tool.config.profile import DeliverableProfile, PptProfile, SheetProfile
|
|
8
|
+
from qc_tool.coverage import CoverageItem, CoverageState
|
|
9
|
+
from qc_tool.excel.periods import is_period_after, parse_period
|
|
10
|
+
from qc_tool.io.model import SheetSnapshot, WorkbookSnapshot
|
|
11
|
+
from qc_tool.ppt.model import DeckSnapshot
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _bounds(cell_range: str) -> tuple[int, int, int, int] | None:
|
|
15
|
+
try:
|
|
16
|
+
min_col, min_row, max_col, max_row = range_boundaries(cell_range)
|
|
17
|
+
except ValueError:
|
|
18
|
+
return None
|
|
19
|
+
if min_col is None or min_row is None or max_col is None or max_row is None:
|
|
20
|
+
return None
|
|
21
|
+
return min_col, min_row, max_col, max_row
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def cell_in_ranges(row: int, column: int, ranges: list[str]) -> bool:
|
|
25
|
+
"""Whether a cell belongs to any configured A1 range."""
|
|
26
|
+
for cell_range in ranges:
|
|
27
|
+
bounds = _bounds(cell_range)
|
|
28
|
+
if bounds is None:
|
|
29
|
+
continue
|
|
30
|
+
min_col, min_row, max_col, max_row = bounds
|
|
31
|
+
if min_row <= row <= max_row and min_col <= column <= max_col:
|
|
32
|
+
return True
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _excel_period_value(
|
|
37
|
+
sheet: SheetSnapshot,
|
|
38
|
+
target_range: str,
|
|
39
|
+
period_range: str,
|
|
40
|
+
row: int,
|
|
41
|
+
column: int,
|
|
42
|
+
) -> object | None:
|
|
43
|
+
target = _bounds(target_range)
|
|
44
|
+
periods = _bounds(period_range)
|
|
45
|
+
if target is None or periods is None:
|
|
46
|
+
return None
|
|
47
|
+
target_min_col, target_min_row, target_max_col, target_max_row = target
|
|
48
|
+
if not (
|
|
49
|
+
target_min_row <= row <= target_max_row
|
|
50
|
+
and target_min_col <= column <= target_max_col
|
|
51
|
+
):
|
|
52
|
+
return None
|
|
53
|
+
period_min_col, period_min_row, period_max_col, period_max_row = periods
|
|
54
|
+
period_width = period_max_col - period_min_col + 1
|
|
55
|
+
period_height = period_max_row - period_min_row + 1
|
|
56
|
+
target_width = target_max_col - target_min_col + 1
|
|
57
|
+
target_height = target_max_row - target_min_row + 1
|
|
58
|
+
if period_width == period_height == 1:
|
|
59
|
+
period_row, period_column = period_min_row, period_min_col
|
|
60
|
+
elif period_height == 1 and period_width == target_width:
|
|
61
|
+
period_row = period_min_row
|
|
62
|
+
period_column = period_min_col + (column - target_min_col)
|
|
63
|
+
elif period_width == 1 and period_height == target_height:
|
|
64
|
+
period_row = period_min_row + (row - target_min_row)
|
|
65
|
+
period_column = period_min_col
|
|
66
|
+
else:
|
|
67
|
+
return None
|
|
68
|
+
cell = sheet.cells.get((period_row, period_column))
|
|
69
|
+
return None if cell is None else cell.value
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def excel_blank_allowed(
|
|
73
|
+
sheet: SheetSnapshot,
|
|
74
|
+
profile: SheetProfile | None,
|
|
75
|
+
row: int,
|
|
76
|
+
column: int,
|
|
77
|
+
) -> bool:
|
|
78
|
+
"""Whether every applicable resolved Excel rule permits this blank."""
|
|
79
|
+
if profile is None:
|
|
80
|
+
return False
|
|
81
|
+
decisions: list[bool] = []
|
|
82
|
+
for rule in profile.availability_rules:
|
|
83
|
+
period_value = _excel_period_value(
|
|
84
|
+
sheet,
|
|
85
|
+
rule.cell_range,
|
|
86
|
+
rule.period_range,
|
|
87
|
+
row,
|
|
88
|
+
column,
|
|
89
|
+
)
|
|
90
|
+
if period_value is None:
|
|
91
|
+
continue
|
|
92
|
+
period = parse_period(period_value)
|
|
93
|
+
required = parse_period(rule.required_through)
|
|
94
|
+
if period is None or required is None or period.kind != required.kind:
|
|
95
|
+
decisions.append(False)
|
|
96
|
+
continue
|
|
97
|
+
decisions.append(
|
|
98
|
+
bool(rule.allow_blank_after and is_period_after(period, required))
|
|
99
|
+
)
|
|
100
|
+
return bool(decisions) and all(decisions)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _matches(value: str, configured: str) -> bool:
|
|
104
|
+
return not configured or value.casefold() == configured.casefold()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def ppt_blank_allowed(
|
|
108
|
+
profile: PptProfile,
|
|
109
|
+
*,
|
|
110
|
+
slide: str,
|
|
111
|
+
scope: str,
|
|
112
|
+
element: str,
|
|
113
|
+
series: str,
|
|
114
|
+
period_label: str,
|
|
115
|
+
) -> bool:
|
|
116
|
+
"""Whether every matching PowerPoint rule permits this future blank."""
|
|
117
|
+
period = parse_period(period_label)
|
|
118
|
+
if period is None:
|
|
119
|
+
return False
|
|
120
|
+
decisions: list[bool] = []
|
|
121
|
+
for rule in profile.availability_rules:
|
|
122
|
+
if (
|
|
123
|
+
rule.scope != scope
|
|
124
|
+
or rule.slide.casefold() != slide.casefold()
|
|
125
|
+
or not _matches(element, rule.element)
|
|
126
|
+
or not _matches(series, rule.series or "")
|
|
127
|
+
):
|
|
128
|
+
continue
|
|
129
|
+
required = parse_period(rule.required_through)
|
|
130
|
+
if required is None or required.kind != period.kind:
|
|
131
|
+
decisions.append(False)
|
|
132
|
+
continue
|
|
133
|
+
decisions.append(
|
|
134
|
+
bool(rule.allow_blank_after and is_period_after(period, required))
|
|
135
|
+
)
|
|
136
|
+
return bool(decisions) and all(decisions)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def availability_coverage(
|
|
140
|
+
*,
|
|
141
|
+
artifact: str,
|
|
142
|
+
rule_count: int,
|
|
143
|
+
issues: list[str] | None = None,
|
|
144
|
+
) -> CoverageItem:
|
|
145
|
+
"""Disclose configured boundaries or the strict no-inference default."""
|
|
146
|
+
issues = issues or []
|
|
147
|
+
return CoverageItem(
|
|
148
|
+
check_id=f"{artifact}-availability",
|
|
149
|
+
label="Availability boundaries",
|
|
150
|
+
artifact=artifact,
|
|
151
|
+
state=CoverageState.DEGRADED if issues else CoverageState.CHECKED,
|
|
152
|
+
detail=(
|
|
153
|
+
(
|
|
154
|
+
f"{rule_count} availability rules configured; "
|
|
155
|
+
f"{len(issues)} unresolved: {'; '.join(issues)}"
|
|
156
|
+
)
|
|
157
|
+
if issues
|
|
158
|
+
else f"{rule_count} explicit availability rules applied"
|
|
159
|
+
if rule_count
|
|
160
|
+
else "No availability rules configured; strict blank requirements applied"
|
|
161
|
+
),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def excel_availability_issues(
|
|
166
|
+
workbook: WorkbookSnapshot,
|
|
167
|
+
profile: DeliverableProfile,
|
|
168
|
+
) -> list[str]:
|
|
169
|
+
"""Runtime resolution failures for Excel availability rules."""
|
|
170
|
+
issues: list[str] = []
|
|
171
|
+
for sheet_name, sheet_profile in profile.excel.sheets.items():
|
|
172
|
+
if sheet_name in profile.excel.ignore_sheets or sheet_profile.ignore:
|
|
173
|
+
continue
|
|
174
|
+
if not sheet_profile.availability_rules:
|
|
175
|
+
continue
|
|
176
|
+
try:
|
|
177
|
+
sheet = workbook.sheet(sheet_name)
|
|
178
|
+
except KeyError:
|
|
179
|
+
issues.append(f"sheet {sheet_name!r} not found")
|
|
180
|
+
continue
|
|
181
|
+
for index, rule in enumerate(sheet_profile.availability_rules):
|
|
182
|
+
label = rule.name or str(index)
|
|
183
|
+
target = _bounds(rule.cell_range)
|
|
184
|
+
periods = _bounds(rule.period_range)
|
|
185
|
+
required = parse_period(rule.required_through)
|
|
186
|
+
if target is None or periods is None or required is None:
|
|
187
|
+
issues.append(f"{sheet_name}/{label} has invalid ranges or period")
|
|
188
|
+
continue
|
|
189
|
+
target_min_col, target_min_row, target_max_col, target_max_row = target
|
|
190
|
+
period_min_col, period_min_row, period_max_col, period_max_row = periods
|
|
191
|
+
target_width = target_max_col - target_min_col + 1
|
|
192
|
+
target_height = target_max_row - target_min_row + 1
|
|
193
|
+
period_width = period_max_col - period_min_col + 1
|
|
194
|
+
period_height = period_max_row - period_min_row + 1
|
|
195
|
+
aligned = (
|
|
196
|
+
period_width == period_height == 1
|
|
197
|
+
or (period_height == 1 and period_width == target_width)
|
|
198
|
+
or (period_width == 1 and period_height == target_height)
|
|
199
|
+
)
|
|
200
|
+
if not aligned:
|
|
201
|
+
issues.append(f"{sheet_name}/{label} period and target ranges misalign")
|
|
202
|
+
continue
|
|
203
|
+
observed = []
|
|
204
|
+
for row in range(period_min_row, period_max_row + 1):
|
|
205
|
+
for column in range(period_min_col, period_max_col + 1):
|
|
206
|
+
cell = sheet.cells.get((row, column))
|
|
207
|
+
if cell is not None and cell.value not in {None, ""}:
|
|
208
|
+
observed.append(parse_period(cell.value))
|
|
209
|
+
if not observed:
|
|
210
|
+
issues.append(f"{sheet_name}/{label} resolves no period labels")
|
|
211
|
+
elif any(
|
|
212
|
+
period is None or period.kind != required.kind
|
|
213
|
+
for period in observed
|
|
214
|
+
):
|
|
215
|
+
issues.append(
|
|
216
|
+
f"{sheet_name}/{label} period labels do not match "
|
|
217
|
+
f"{required.kind!r} required_through"
|
|
218
|
+
)
|
|
219
|
+
return sorted(set(issues))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def ppt_availability_issues(
|
|
223
|
+
deck: DeckSnapshot,
|
|
224
|
+
profile: PptProfile,
|
|
225
|
+
) -> list[str]:
|
|
226
|
+
"""Runtime resolution failures for PowerPoint availability rules."""
|
|
227
|
+
issues: list[str] = []
|
|
228
|
+
slides = {slide.display_name.casefold(): slide for slide in deck.slides}
|
|
229
|
+
for index, rule in enumerate(profile.availability_rules):
|
|
230
|
+
label = rule.name or str(index)
|
|
231
|
+
if parse_period(rule.required_through) is None:
|
|
232
|
+
issues.append(f"{label} has invalid required_through")
|
|
233
|
+
continue
|
|
234
|
+
slide = slides.get(rule.slide.casefold())
|
|
235
|
+
if slide is None:
|
|
236
|
+
issues.append(f"{label} slide {rule.slide!r} not found")
|
|
237
|
+
continue
|
|
238
|
+
if rule.scope == "table":
|
|
239
|
+
candidates = [
|
|
240
|
+
(
|
|
241
|
+
table.name or f"table[{table.source_index}]",
|
|
242
|
+
{
|
|
243
|
+
row[0].casefold()
|
|
244
|
+
for row in table.rows[1:]
|
|
245
|
+
if row and row[0]
|
|
246
|
+
},
|
|
247
|
+
)
|
|
248
|
+
for table in slide.tables
|
|
249
|
+
]
|
|
250
|
+
else:
|
|
251
|
+
candidates = [
|
|
252
|
+
(
|
|
253
|
+
chart.title or chart.name or f"chart[{chart.source_index}]",
|
|
254
|
+
{
|
|
255
|
+
series.name.casefold()
|
|
256
|
+
for series in chart.all_series
|
|
257
|
+
if series.name
|
|
258
|
+
},
|
|
259
|
+
)
|
|
260
|
+
for chart in slide.charts
|
|
261
|
+
]
|
|
262
|
+
matching = [
|
|
263
|
+
(element, names)
|
|
264
|
+
for element, names in candidates
|
|
265
|
+
if not rule.element or element.casefold() == rule.element.casefold()
|
|
266
|
+
]
|
|
267
|
+
if rule.element and not matching:
|
|
268
|
+
issues.append(f"{label} element {rule.element!r} not found")
|
|
269
|
+
elif rule.series and not any(
|
|
270
|
+
rule.series.casefold() in names for _, names in matching
|
|
271
|
+
):
|
|
272
|
+
issues.append(f"{label} series/row {rule.series!r} not found")
|
|
273
|
+
return sorted(set(issues))
|