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
|
@@ -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
|