cellproof 0.2.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.
- cellproof/__init__.py +1 -0
- cellproof/__main__.py +3 -0
- cellproof/cli.py +53 -0
- cellproof/compiler.py +408 -0
- cellproof/compute.py +62 -0
- cellproof/emitter.py +160 -0
- cellproof/expr.py +233 -0
- cellproof/fenwick.py +47 -0
- cellproof/fn/__init__.py +2 -0
- cellproof/fn/aggregate.py +181 -0
- cellproof/fn/criteria.py +188 -0
- cellproof/fn/dates.py +171 -0
- cellproof/fn/logic.py +131 -0
- cellproof/fn/lookup.py +302 -0
- cellproof/fn/maths.py +118 -0
- cellproof/fn/text.py +257 -0
- cellproof/functions.py +114 -0
- cellproof/graph.py +150 -0
- cellproof/model.py +72 -0
- cellproof/parser.py +172 -0
- cellproof/r1c1.py +202 -0
- cellproof/rangeindex.py +97 -0
- cellproof/reader.py +83 -0
- cellproof/refs.py +58 -0
- cellproof/region.py +392 -0
- cellproof/regions.py +248 -0
- cellproof/report.py +51 -0
- cellproof/verifier.py +62 -0
- cellproof-0.2.0.dist-info/METADATA +177 -0
- cellproof-0.2.0.dist-info/RECORD +33 -0
- cellproof-0.2.0.dist-info/WHEEL +4 -0
- cellproof-0.2.0.dist-info/entry_points.txt +2 -0
- cellproof-0.2.0.dist-info/licenses/LICENSE +21 -0
cellproof/region.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""Compile a Region into one set-based statement.
|
|
2
|
+
|
|
3
|
+
A region becomes a wide model (a short pivot of the columns it reads, then one CTE per formula column holding the
|
|
4
|
+
readable expression) and a mechanical unpivot of that model into long `cells` rows, which is what later batches read
|
|
5
|
+
and what the verifier compares. The expressions come from the same Compiler as cell mode, with a different reader for
|
|
6
|
+
same-row references, so every Excel rule carries over unchanged.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from functools import lru_cache
|
|
12
|
+
|
|
13
|
+
import duckdb
|
|
14
|
+
from openpyxl.utils.cell import get_column_letter
|
|
15
|
+
|
|
16
|
+
from cellproof.compiler import DEEP, INPUT_COLUMNS, Compiler, Step, a1, cell_row
|
|
17
|
+
from cellproof.expr import CELLS, NO_ERR, Expr, any_of, errs, finalize, lit_str, num
|
|
18
|
+
from cellproof.functions import note_text_numbers, range_sum
|
|
19
|
+
from cellproof.model import Book, Unsupported
|
|
20
|
+
from cellproof.r1c1 import ROWREF, RUNREF
|
|
21
|
+
from cellproof.refs import Area
|
|
22
|
+
from cellproof.regions import Region, column_order
|
|
23
|
+
|
|
24
|
+
# every row from the region's first row to this one (rows are unique and contiguous)
|
|
25
|
+
UPTO = "OVER (ORDER BY _.row_num ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _earliest(err: str, pick: str = "arg_min") -> str:
|
|
29
|
+
"""The error of the first (arg_min) or last (arg_max) row up to this one that has one, else NULL."""
|
|
30
|
+
return f"{pick}({err}, CASE WHEN {err} IS NOT NULL THEN _.row_num END) {UPTO}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
EPS = 2.0 ** -53
|
|
34
|
+
ORDER_REASON = ("a running total whose numbers cancel out (adding and subtracting back to near zero): SQL adds them "
|
|
35
|
+
"in another order than Excel, which can change the last digits")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _order_safe(steps) -> bool:
|
|
39
|
+
"""Whether adding these terms in any order stays within the verifier's tolerance of Excel's left-to-right order.
|
|
40
|
+
|
|
41
|
+
steps: (running total so far, sum of absolute values so far) per row. Two summation orders of n terms differ by at
|
|
42
|
+
most about 2 n eps sum|x|; the verifier accepts 1e-9 relative (1e-12 absolute). With terms of one sign sum|x| is
|
|
43
|
+
|total| and the test always holds; a balance that cancels back to zero fails it and stays in cell mode, which adds
|
|
44
|
+
in Excel's order."""
|
|
45
|
+
n = 0
|
|
46
|
+
for total, absolute in steps:
|
|
47
|
+
n += 1
|
|
48
|
+
if 4 * n * EPS * absolute > max(1e-9 * abs(total), 1e-12):
|
|
49
|
+
return False
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _saved_number(cell):
|
|
54
|
+
"""Excel's saved number for a cell, 0 for empty, None when it is an error, text or missing (stop checking there:
|
|
55
|
+
from an error down a running total is an error, so its value no longer matters)."""
|
|
56
|
+
if cell is None or cell.kind == "blank" and cell.formula is None:
|
|
57
|
+
return 0.0
|
|
58
|
+
if cell.kind == "n":
|
|
59
|
+
return cell.value
|
|
60
|
+
if cell.kind == "b":
|
|
61
|
+
return 1.0 if cell.value else 0.0
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def growing_safe(book, region: Region, src: int, start: int) -> bool:
|
|
66
|
+
def steps():
|
|
67
|
+
total = absolute = 0.0
|
|
68
|
+
for r in range(start, region.r2 + 1):
|
|
69
|
+
cell = book.cells.get((region.sheet, r, src))
|
|
70
|
+
if cell is not None and cell.kind == "e":
|
|
71
|
+
return
|
|
72
|
+
if cell is not None and cell.kind == "n": # SUM skips text, TRUE/FALSE and empty cells
|
|
73
|
+
total += cell.value
|
|
74
|
+
absolute += abs(cell.value)
|
|
75
|
+
if r >= region.r1:
|
|
76
|
+
yield total, absolute
|
|
77
|
+
return _order_safe(steps())
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def recurrence_safe(book, region: Region, col: int) -> bool:
|
|
81
|
+
"""The terms are read back from Excel's saved totals: term r = X{r} - X{r-1}."""
|
|
82
|
+
def steps():
|
|
83
|
+
prev = _saved_number(book.cells.get((region.sheet, region.r1 - 1, col)))
|
|
84
|
+
if prev is None:
|
|
85
|
+
return
|
|
86
|
+
absolute = abs(prev)
|
|
87
|
+
for r in range(region.r1, region.r2 + 1):
|
|
88
|
+
x = _saved_number(book.cells.get((region.sheet, r, col)))
|
|
89
|
+
if x is None:
|
|
90
|
+
return
|
|
91
|
+
absolute += abs(x - prev)
|
|
92
|
+
prev = x
|
|
93
|
+
yield x, 2 * absolute # the terms are estimates from rounded totals: count them twice
|
|
94
|
+
return _order_safe(steps())
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def window_sum(comp: Compiler, region: Region, src: int, start: int, s: Expr, name: str, layer: list) -> Expr:
|
|
98
|
+
"""SUM($B$start:B{r}) as a running window over the source column s; adds its columns to `layer`.
|
|
99
|
+
|
|
100
|
+
Like SUM in cell mode: only numbers count (text, TRUE/FALSE skipped), the first error by row wins, and rows above
|
|
101
|
+
the region are one constant read with the cell-mode SUM."""
|
|
102
|
+
if s.type == "num":
|
|
103
|
+
val = s.sql if s.err == NO_ERR else f"CASE WHEN {s.err} IS NULL THEN {s.sql} END"
|
|
104
|
+
elif s.type == "any":
|
|
105
|
+
val = f"CASE WHEN {s.kind} = 'n'{'' if s.err == NO_ERR else f' AND {s.err} IS NULL'} THEN {s.sql} END"
|
|
106
|
+
else: # text, TRUE/FALSE or errors only: nothing is added
|
|
107
|
+
val = "CAST(NULL AS DOUBLE)"
|
|
108
|
+
layer.append(f"COALESCE(SUM({val}) {UPTO}, CAST(0 AS DOUBLE)) AS {name}")
|
|
109
|
+
err = NO_ERR
|
|
110
|
+
if s.err != NO_ERR:
|
|
111
|
+
layer.append(f"{_earliest(s.err)} AS {name}e")
|
|
112
|
+
err = f"_.{name}e"
|
|
113
|
+
if src not in region.cols: # an input column: Excel skips numbers stored as text, tell the user once
|
|
114
|
+
note_text_numbers(comp, comp.ready(Area(region.sheet, region.r1, src, region.r2, src)))
|
|
115
|
+
if start < region.r1:
|
|
116
|
+
above = range_sum(comp, Area(region.sheet, start, src, region.r1 - 1, src))
|
|
117
|
+
return Expr(f"({above.sql} + _.{name})", "num", errs(above.err, err))
|
|
118
|
+
return Expr(f"_.{name}", "num", err)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def recurrence(comp: Compiler, region: Region, col: int, e: Expr, name: str, layer: list) -> Expr:
|
|
122
|
+
"""X{r-1} + E, X{r-1} - E or E + X{r-1}: the seed above the run plus a running SUM of E (converted like `+`).
|
|
123
|
+
|
|
124
|
+
Errors follow Excel's left-to-right rule row by row: with X{r-1} on the left the earliest error wins (the seed's
|
|
125
|
+
first), with it on the right the latest error of E wins (the seed's only when E never failed)."""
|
|
126
|
+
side, op = region.recur[col]
|
|
127
|
+
x = num(e)
|
|
128
|
+
layer.append(f"{x.sql} AS {name}__x")
|
|
129
|
+
werr = NO_ERR
|
|
130
|
+
if x.err != NO_ERR:
|
|
131
|
+
layer.append(f"CAST({x.err} AS VARCHAR) AS {name}__xe")
|
|
132
|
+
werr = _earliest(f"_.{name}__xe", "arg_min" if side == "left" else "arg_max")
|
|
133
|
+
seed = num(comp.cell_value((region.sheet, region.r1 - 1, col)))
|
|
134
|
+
total = f"COALESCE(SUM(_.{name}__x) {UPTO}, CAST(0 AS DOUBLE))"
|
|
135
|
+
return Expr(f"({seed.sql} {op} {total})", "num", errs(seed.err, werr) if side == "left" else errs(werr, seed.err))
|
|
136
|
+
|
|
137
|
+
RESERVED = {"row_num", "sheet", "src", "wide", "t", "num", "txt", "kind", "col_num", "cells", "all_cells"}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@lru_cache(maxsize=1)
|
|
141
|
+
def _keywords() -> frozenset:
|
|
142
|
+
"""DuckDB's own list of words that cannot be bare identifiers."""
|
|
143
|
+
rows = duckdb.connect().execute(
|
|
144
|
+
"SELECT keyword_name FROM duckdb_keywords() WHERE keyword_category <> 'unreserved'").fetchall()
|
|
145
|
+
return frozenset(r[0].lower() for r in rows)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
WIDE = "\x00wide\x00" # stands for the relation the unpivot reads: the wide CTE or the wide dbt model
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def names_for(book: Book, region: Region, cols: list[int]) -> dict[int, str]:
|
|
152
|
+
"""SQL column names: the unique text above the region, else col_<letter>."""
|
|
153
|
+
raw = {}
|
|
154
|
+
for c in cols:
|
|
155
|
+
cell = book.cells.get((region.sheet, region.r1 - 1, c))
|
|
156
|
+
if cell is not None and cell.formula is None and cell.kind == "s":
|
|
157
|
+
raw[c] = re.sub(r"[^0-9a-zA-Z]+", "_", str(cell.value)).strip("_").lower()
|
|
158
|
+
fallback = {c: f"col_{get_column_letter(c).lower()}" for c in cols}
|
|
159
|
+
reserved = RESERVED | _keywords()
|
|
160
|
+
out = {}
|
|
161
|
+
for c in cols:
|
|
162
|
+
name = raw.get(c, "")
|
|
163
|
+
dup = sum(1 for x in raw.values() if x == name) > 1
|
|
164
|
+
bad = (not name or name[0].isdigit() or dup or name in reserved or re.fullmatch(r"col_[a-z]{1,3}", name))
|
|
165
|
+
out[c] = fallback[c] if bad else name
|
|
166
|
+
# last guard: any remaining clash (case-insensitive) sends every colliding header-named column to its fallback
|
|
167
|
+
seen: dict[str, list[int]] = {}
|
|
168
|
+
for c, n in out.items():
|
|
169
|
+
seen.setdefault(n.lower(), []).append(c)
|
|
170
|
+
for group in seen.values():
|
|
171
|
+
if len(group) > 1:
|
|
172
|
+
for c in group:
|
|
173
|
+
out[c] = fallback[c]
|
|
174
|
+
return out
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def source_info(comp: Compiler, region: Region, col: int) -> tuple[str, bool]:
|
|
178
|
+
"""(type, may_err) of a column the region reads but does not compute; Unsupported if it is not plain."""
|
|
179
|
+
types, may = set(), False
|
|
180
|
+
for r in range(region.r1, region.r2 + 1):
|
|
181
|
+
key = (region.sheet, r, col)
|
|
182
|
+
cell = comp.book.cells.get(key)
|
|
183
|
+
if cell is None:
|
|
184
|
+
raise Unsupported(f"column {get_column_letter(col)} has an empty cell in the region's rows")
|
|
185
|
+
if cell.formula is None:
|
|
186
|
+
if cell.kind not in ("n", "s", "b"):
|
|
187
|
+
raise Unsupported(f"column {get_column_letter(col)} holds {cell.kind} cells")
|
|
188
|
+
types.add({"n": "num", "s": "txt", "b": "bool"}[cell.kind])
|
|
189
|
+
else:
|
|
190
|
+
if key not in comp.done:
|
|
191
|
+
raise Unsupported(f"depends on unresolved cell {a1(key)}")
|
|
192
|
+
typ, err = comp.done[key]
|
|
193
|
+
types.add(typ)
|
|
194
|
+
may = may or err
|
|
195
|
+
if len(types) != 1 or next(iter(types)) not in ("num", "txt", "bool"):
|
|
196
|
+
raise Unsupported(f"column {get_column_letter(col)} mixes value kinds")
|
|
197
|
+
return types.pop(), may
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class RegionBatch(list):
|
|
201
|
+
"""The Step of every cell of a region, plus the SQL that computes them all at once."""
|
|
202
|
+
|
|
203
|
+
def __init__(self, steps, model: str, wide_sql: str, rows: list[str]):
|
|
204
|
+
super().__init__(steps)
|
|
205
|
+
self.model, self.wide_sql, self.rows = model, wide_sql, rows
|
|
206
|
+
|
|
207
|
+
def unpivot(self, source: str) -> str:
|
|
208
|
+
return "\nUNION ALL\n".join(row.replace(WIDE, source) for row in self.rows)
|
|
209
|
+
|
|
210
|
+
def render_rows(self, prev: str) -> str:
|
|
211
|
+
"""Only the new long rows, reading the cells table `prev` (what the verifier appends)."""
|
|
212
|
+
return (f"WITH wide AS (\n{self.wide_sql}\n)\n" + self.unpivot("wide")).replace(CELLS, prev)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
ROWREF_REASON = "reads a cell reference directly (functions on single cells are not row-wise yet)"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def model_name(region: Region) -> str:
|
|
219
|
+
slug = re.sub(r"[^0-9a-zA-Z]+", "_", region.sheet).strip("_").lower() or "sheet"
|
|
220
|
+
return f"region_{slug}_{region.r1}_{region.r2}"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def compile_region(comp: Compiler, region: Region):
|
|
224
|
+
"""-> (RegionBatch or None, {column: reason it stays in cell mode}). The compiler must have no open batch."""
|
|
225
|
+
assert not comp.open, "compile_region needs an empty open batch"
|
|
226
|
+
book = comp.book
|
|
227
|
+
letter = get_column_letter
|
|
228
|
+
sources = sorted({s for c in region.cols for s in region.reads[c]} - set(region.cols))
|
|
229
|
+
names = names_for(book, region, sorted(set(region.cols) | set(sources)))
|
|
230
|
+
bad_source, src_types = {}, {}
|
|
231
|
+
for s in sources:
|
|
232
|
+
try:
|
|
233
|
+
src_types[s] = source_info(comp, region, s)
|
|
234
|
+
except Unsupported as err:
|
|
235
|
+
bad_source[s] = str(err)
|
|
236
|
+
q = "_." # every row-wise reference is qualified, so no header name or library alias can shadow it
|
|
237
|
+
rowrefs = {ROWREF.format(s): Expr(q + names[s], t, f"{q}{names[s]}__err" if may else NO_ERR)
|
|
238
|
+
for s, (t, may) in src_types.items()}
|
|
239
|
+
rejected: dict[int, str] = {}
|
|
240
|
+
extras, out_exprs, order = {}, {}, []
|
|
241
|
+
comp.rowrefs = rowrefs
|
|
242
|
+
try:
|
|
243
|
+
for c in column_order(region.cols, region.reads)[0]:
|
|
244
|
+
blocked = sorted(region.reads[c] & (set(rejected) | set(bad_source)))
|
|
245
|
+
if blocked:
|
|
246
|
+
b = blocked[0]
|
|
247
|
+
rejected[c] = bad_source.get(b) or f"reads column {letter(b)}, which stays in cell mode"
|
|
248
|
+
continue
|
|
249
|
+
n = names[c]
|
|
250
|
+
sums: list[str] = [] # the growing SUMs' window columns, then the running total's per-row term
|
|
251
|
+
terms: list[str] = []
|
|
252
|
+
try:
|
|
253
|
+
if not (all(growing_safe(book, region, s, k) for s, k in region.runs.get(c, []))
|
|
254
|
+
and (c not in region.recur or recurrence_safe(book, region, c))):
|
|
255
|
+
raise Unsupported(ORDER_REASON)
|
|
256
|
+
for i, (src, start) in enumerate(region.runs.get(c, []), 1):
|
|
257
|
+
rowrefs[RUNREF.format(src, start)] = window_sum(comp, region, src, start,
|
|
258
|
+
rowrefs[ROWREF.format(src)], f"{n}__s{i}", sums)
|
|
259
|
+
e = finalize(comp.expr(region.template[c], region.sheet))
|
|
260
|
+
if c in region.recur:
|
|
261
|
+
e = recurrence(comp, region, c, e, n, terms)
|
|
262
|
+
except Unsupported as err:
|
|
263
|
+
rejected[c] = ROWREF_REASON if "__ROWREF_" in str(err) else str(err)
|
|
264
|
+
continue
|
|
265
|
+
except RecursionError:
|
|
266
|
+
rejected[c] = DEEP
|
|
267
|
+
continue
|
|
268
|
+
extra = [f"{e.sql} AS {n}"]
|
|
269
|
+
if e.err != NO_ERR:
|
|
270
|
+
extra.append(f"CAST({e.err} AS VARCHAR) AS {n}__err")
|
|
271
|
+
if e.type == "any":
|
|
272
|
+
extra += [f"CAST({e.txt} AS VARCHAR) AS {n}__t", f"CAST({e.kind} AS VARCHAR) AS {n}__k"]
|
|
273
|
+
extras[c] = [layer for layer in (sums, terms) if layer] + [extra]
|
|
274
|
+
out_exprs[c] = e
|
|
275
|
+
order.append(c)
|
|
276
|
+
err = f"{q}{n}__err" if e.err != NO_ERR else NO_ERR
|
|
277
|
+
if e.type == "any":
|
|
278
|
+
rowrefs[ROWREF.format(c)] = any_of(f"{q}{n}__k", q + n, f"{q}{n}__t", err)
|
|
279
|
+
elif e.type == "err":
|
|
280
|
+
rowrefs[ROWREF.format(c)] = Expr("NULL", "err", err)
|
|
281
|
+
else:
|
|
282
|
+
rowrefs[ROWREF.format(c)] = Expr(q + n, e.type, err)
|
|
283
|
+
finally:
|
|
284
|
+
comp.rowrefs = None
|
|
285
|
+
|
|
286
|
+
sheet_lit = lit_str(region.sheet).sql
|
|
287
|
+
|
|
288
|
+
def assemble(accepted: list[int]):
|
|
289
|
+
"""(wide_sql, rows, steps) for the accepted columns, which are in compile (dependency) order."""
|
|
290
|
+
used = sorted({s for c in accepted for s in region.reads[c] if s in src_types})
|
|
291
|
+
if used:
|
|
292
|
+
pivot = []
|
|
293
|
+
for s in used:
|
|
294
|
+
typ, may = src_types[s]
|
|
295
|
+
agg = {"num": "MAX(num)", "txt": "MAX(COALESCE(txt, ''))", "bool": "MAX(num)"}[typ]
|
|
296
|
+
filt = f"{agg} FILTER (WHERE col_num = {s})"
|
|
297
|
+
pivot.append(f"({filt} <> 0) AS {names[s]}" if typ == "bool" else f"{filt} AS {names[s]}")
|
|
298
|
+
if may:
|
|
299
|
+
pivot.append(f"MAX(CASE WHEN kind = 'e' THEN txt END) FILTER (WHERE col_num = {s}) "
|
|
300
|
+
f"AS {names[s]}__err")
|
|
301
|
+
src = ("src AS (\n SELECT row_num,\n " + ",\n ".join(pivot) +
|
|
302
|
+
f"\n FROM {CELLS} WHERE sheet = {sheet_lit} AND row_num BETWEEN {region.r1} AND {region.r2} "
|
|
303
|
+
f"AND col_num IN ({', '.join(str(s) for s in used)}) GROUP BY row_num)")
|
|
304
|
+
else:
|
|
305
|
+
src = f"src AS (SELECT row_num FROM range({region.r1}, {region.r2 + 1}) AS t(row_num))"
|
|
306
|
+
layers, prev = [], "src"
|
|
307
|
+
for c in accepted:
|
|
308
|
+
for i, items in enumerate(extras[c], 1):
|
|
309
|
+
cte = names[c] if i == len(extras[c]) else f"{names[c]}__p{i}" # no SQL name holds "__" otherwise
|
|
310
|
+
layers.append(f"{cte} AS (\n SELECT *, " + ", ".join(items) + f"\n FROM {prev} AS _)")
|
|
311
|
+
prev = cte
|
|
312
|
+
cols = [c for c in region.cols if c in accepted]
|
|
313
|
+
select = ["row_num"]
|
|
314
|
+
rows = []
|
|
315
|
+
for c in cols:
|
|
316
|
+
e, n = out_exprs[c], names[c]
|
|
317
|
+
select.append(n)
|
|
318
|
+
if e.err != NO_ERR:
|
|
319
|
+
select.append(f"{n}__err")
|
|
320
|
+
if e.type == "any":
|
|
321
|
+
select += [f"{n}__t", f"{n}__k"]
|
|
322
|
+
v = n if e.type != "err" else "CAST(NULL AS DOUBLE)"
|
|
323
|
+
kind, num_sql, txt_sql = cell_row(e.type, v, f"{n}__err" if e.err != NO_ERR else NO_ERR,
|
|
324
|
+
f"{n}__t" if e.type == "any" else "NULL",
|
|
325
|
+
f"{n}__k" if e.type == "any" else "NULL")
|
|
326
|
+
rows.append(f"SELECT {sheet_lit}, row_num, {c}, {kind}, {num_sql}, {txt_sql} FROM {WIDE}")
|
|
327
|
+
wide = "WITH\n" + ",\n".join([src] + layers) + f"\nSELECT {', '.join(select)}\nFROM {prev}"
|
|
328
|
+
steps = [Step((region.sheet, r, c), out_exprs[c], "") for c in cols for r in range(region.r1, region.r2 + 1)]
|
|
329
|
+
return wide, rows, steps
|
|
330
|
+
|
|
331
|
+
if not order:
|
|
332
|
+
return None, rejected
|
|
333
|
+
accepted = list(order)
|
|
334
|
+
problem = _validate(*assemble(accepted)[:2])
|
|
335
|
+
if problem: # find the columns DuckDB cannot plan row-wise: add one at a time, keep what plans
|
|
336
|
+
accepted = []
|
|
337
|
+
for c in order:
|
|
338
|
+
blocked = sorted(region.reads[c] & set(rejected))
|
|
339
|
+
if blocked:
|
|
340
|
+
rejected[c] = f"reads column {letter(blocked[0])}, which stays in cell mode"
|
|
341
|
+
continue
|
|
342
|
+
bad = _validate(*assemble(accepted + [c])[:2])
|
|
343
|
+
if bad:
|
|
344
|
+
rejected[c] = f"DuckDB cannot plan this column row-wise ({bad})"
|
|
345
|
+
else:
|
|
346
|
+
accepted.append(c)
|
|
347
|
+
if not accepted:
|
|
348
|
+
return None, rejected
|
|
349
|
+
wide, rows, steps = assemble(accepted)
|
|
350
|
+
return RegionBatch(steps, model_name(region), wide, rows), rejected
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
_CHECK = None
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _validate(wide_sql: str, rows: list[str]):
|
|
357
|
+
"""Plan (EXPLAIN, no data) the region's SQL in both forms it runs in; the first line of the error, or None.
|
|
358
|
+
|
|
359
|
+
Form 1 is the verifier's (the cells table); form 2 is dbt's (the cells behind a CTE named all_cells). DuckDB cannot
|
|
360
|
+
plan some correlated subqueries (the lookups); such a column then stays in cell mode instead of crashing the run.
|
|
361
|
+
"""
|
|
362
|
+
global _CHECK
|
|
363
|
+
unpivot = "\nUNION ALL\n".join(r.replace(WIDE, "wide") for r in rows)
|
|
364
|
+
dbt_wide = wide_sql.replace("WITH\n", "WITH\nall_cells AS (\n SELECT * FROM cells UNION ALL SELECT * FROM cells\n),\n", 1)
|
|
365
|
+
forms = [(f"WITH wide AS (\n{wide_sql}\n)\n{unpivot}", "cells"), # (statement, what the cells are called in it)
|
|
366
|
+
(f"WITH wide AS (\n{dbt_wide}\n)\n{unpivot}", "all_cells")]
|
|
367
|
+
for sql, cells in forms:
|
|
368
|
+
try:
|
|
369
|
+
if _CHECK is None:
|
|
370
|
+
_CHECK = duckdb.connect()
|
|
371
|
+
_CHECK.execute(f"CREATE TABLE cells ({INPUT_COLUMNS})")
|
|
372
|
+
_CHECK.execute("EXPLAIN " + sql.replace(CELLS, cells))
|
|
373
|
+
except Exception as err: # noqa: BLE001 - any DuckDB failure means "keep this column in cell mode"
|
|
374
|
+
try:
|
|
375
|
+
_CHECK.execute("SELECT 1")
|
|
376
|
+
except Exception: # noqa: BLE001 - an internal error can invalidate the connection
|
|
377
|
+
_CHECK = None
|
|
378
|
+
return (str(err).splitlines() or ["unknown error"])[0]
|
|
379
|
+
return None
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def region_note(region: Region, batch, rejected: dict[int, str]) -> list[str]:
|
|
383
|
+
"""Report lines: what became a model and what stayed in cell mode, with the reasons."""
|
|
384
|
+
letters = lambda cols: ", ".join(get_column_letter(c) for c in cols) # noqa: E731
|
|
385
|
+
lines = []
|
|
386
|
+
if batch is not None:
|
|
387
|
+
done = [c for c in region.cols if c not in rejected]
|
|
388
|
+
lines.append(f"{region.sheet} rows {region.r1}-{region.r2}, columns {letters(done)} -> model {batch.model}")
|
|
389
|
+
for c in sorted(rejected):
|
|
390
|
+
lines.append(f"{region.sheet} column {get_column_letter(c)} rows {region.r1}-{region.r2} stays in cell mode: "
|
|
391
|
+
f"{rejected[c]}")
|
|
392
|
+
return lines
|
cellproof/regions.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Find row-wise regions: blocks of columns that repeat one formula down the same rows.
|
|
2
|
+
|
|
3
|
+
A region column is a run of at least MIN_ROWS consecutive formula cells with the same R1C1 key. Columns whose runs
|
|
4
|
+
cover the same rows form one region. A column joins only if every reference in its formula is a same-row cell, or
|
|
5
|
+
reads the same cells on every row; anything that moves with the row (a previous row, a growing range) stays in cell
|
|
6
|
+
mode. The region compiler decides later whether the SQL can really be built.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
from openpyxl.utils.cell import get_column_letter
|
|
13
|
+
|
|
14
|
+
from cellproof.model import Book, Key, Unsupported
|
|
15
|
+
from cellproof.parser import Binary, Call, RefNode, Unary, Postfix, parse, refs
|
|
16
|
+
from cellproof.r1c1 import ROWREF, RUNREF, classify, growing, lexed, map_refs, prev_self
|
|
17
|
+
from cellproof.refs import resolve
|
|
18
|
+
|
|
19
|
+
MIN_ROWS = 3
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Region:
|
|
24
|
+
sheet: str
|
|
25
|
+
r1: int
|
|
26
|
+
r2: int
|
|
27
|
+
cols: list[int]
|
|
28
|
+
template: dict[int, object] = field(default_factory=dict) # col -> AST, same-row refs as __ROWREF_n__
|
|
29
|
+
reads: dict[int, set] = field(default_factory=dict) # col -> source columns read in the same row
|
|
30
|
+
# col -> (side, op) of a running total X{r-1} op E ("left") or E + X{r-1} ("right"); its template is E
|
|
31
|
+
recur: dict[int, tuple] = field(default_factory=dict)
|
|
32
|
+
runs: dict[int, list] = field(default_factory=dict) # col -> [(source col, start row)] of its growing SUMs
|
|
33
|
+
|
|
34
|
+
def keys(self) -> list[Key]:
|
|
35
|
+
return [(self.sheet, r, c) for c in self.cols for r in range(self.r1, self.r2 + 1)]
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def first_key(self) -> Key:
|
|
39
|
+
return (self.sheet, self.r1, min(self.cols))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class RegionScan:
|
|
44
|
+
regions: list[Region]
|
|
45
|
+
rejected: list[str] # why pasted-down columns did not become regions: the map for the next slice
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def column_order(cols, reads) -> tuple[list[int], list[int]]:
|
|
49
|
+
"""Columns in dependency order (smallest ready column first, so the SQL follows the sheet); and the stuck ones."""
|
|
50
|
+
left, order = set(cols), []
|
|
51
|
+
while left:
|
|
52
|
+
ready = sorted(c for c in left if not (reads[c] & left))
|
|
53
|
+
if not ready:
|
|
54
|
+
break
|
|
55
|
+
order.append(ready[0])
|
|
56
|
+
left.discard(ready[0])
|
|
57
|
+
return order, sorted(left)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _runs(book: Book):
|
|
61
|
+
"""(sheet, col, r1, r2) for every maximal run of one pasted-down formula, at least MIN_ROWS rows."""
|
|
62
|
+
lex = lexed(book)
|
|
63
|
+
for (sheet, col), rows in sorted(book._rows_by_col.items()):
|
|
64
|
+
run, key0 = [], None
|
|
65
|
+
for row in rows + [None]:
|
|
66
|
+
cell = book.cells[(sheet, row, col)] if row is not None else None
|
|
67
|
+
ok = cell is not None and cell.formula is not None and not cell.array and cell.kind != "blank"
|
|
68
|
+
shape = lex[(sheet, row, col)][0] if ok else None
|
|
69
|
+
ok, key = shape is not None, shape.key if shape is not None else None
|
|
70
|
+
if ok and run and row == run[-1] + 1 and key == key0:
|
|
71
|
+
run.append(row)
|
|
72
|
+
continue
|
|
73
|
+
if len(run) >= MIN_ROWS:
|
|
74
|
+
yield sheet, col, run[0], run[-1]
|
|
75
|
+
run, key0 = ([row], key) if ok else ([], None)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _place(sheet: str, col: int, r1: int, r2: int) -> str:
|
|
79
|
+
letter = get_column_letter(col)
|
|
80
|
+
return f"{sheet} column {letter} rows {r1}-{r2}"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def find_regions(book: Book) -> RegionScan:
|
|
84
|
+
groups: dict[tuple, list[int]] = {}
|
|
85
|
+
for sheet, col, r1, r2 in _runs(book):
|
|
86
|
+
groups.setdefault((sheet, r1, r2), []).append(col)
|
|
87
|
+
regions, rejected = [], []
|
|
88
|
+
for (sheet, r1, r2), cols in groups.items():
|
|
89
|
+
template, reads, keep, recur, runs = {}, {}, [], {}, {}
|
|
90
|
+
for col in cols:
|
|
91
|
+
cell = book.cells[(sheet, r1, col)]
|
|
92
|
+
try:
|
|
93
|
+
parsed = parse(cell.formula)
|
|
94
|
+
if any(t.upper().startswith(("__ROWREF_", "__RUN_")) for t in refs(parsed)):
|
|
95
|
+
raise Unsupported("uses a name spelled like cellproof's own placeholders")
|
|
96
|
+
ast, rec = _running(parsed, r1, col)
|
|
97
|
+
sums: list = []
|
|
98
|
+
ast = _map_sums(ast, r1, col, sums)
|
|
99
|
+
found, why = {s for s, _ in sums}, None
|
|
100
|
+
for text in refs(ast):
|
|
101
|
+
if text.startswith("__RUN_"):
|
|
102
|
+
continue
|
|
103
|
+
kind, source = classify(text, r1)
|
|
104
|
+
if kind == "bad":
|
|
105
|
+
why = f"reads {text}, which moves with the row (previous row, growing or relative range, other sheet)"
|
|
106
|
+
elif kind == "same_row":
|
|
107
|
+
found.add(source)
|
|
108
|
+
else: # the same cells on every row: must not be the region's own formula cells
|
|
109
|
+
area = resolve(text, sheet, book)
|
|
110
|
+
if area.sheet == sheet and area.r1 <= r2 and area.r2 >= r1:
|
|
111
|
+
lo, hi = max(area.r1, r1), min(area.r2, r2)
|
|
112
|
+
hit = [c for c in cols if area.c1 <= c <= area.c2] # columns first, then rows
|
|
113
|
+
if any(book.cells.get((sheet, r, c)) is not None
|
|
114
|
+
and book.cells[(sheet, r, c)].formula is not None
|
|
115
|
+
for c in hit for r in range(lo, hi + 1)):
|
|
116
|
+
why = f"reads {text}, which includes formula cells of its own rows"
|
|
117
|
+
if not why:
|
|
118
|
+
mapped = map_refs(ast, lambda n: RefNode(ROWREF.format(classify(n.text, r1)[1]))
|
|
119
|
+
if classify(n.text, r1)[0] == "same_row" else n)
|
|
120
|
+
except (Unsupported, RecursionError) as err:
|
|
121
|
+
why = "formula is nested too deeply" if isinstance(err, RecursionError) else str(err)
|
|
122
|
+
if why:
|
|
123
|
+
rejected.append(f"{_place(sheet, col, r1, r2)} stays in cell mode: {why}")
|
|
124
|
+
continue
|
|
125
|
+
template[col] = mapped
|
|
126
|
+
reads[col] = found
|
|
127
|
+
if rec:
|
|
128
|
+
recur[col] = rec
|
|
129
|
+
if sums:
|
|
130
|
+
runs[col] = sums
|
|
131
|
+
keep.append(col)
|
|
132
|
+
_, stuck = column_order(keep, reads)
|
|
133
|
+
for col in stuck:
|
|
134
|
+
rejected.append(f"{_place(sheet, col, r1, r2)} stays in cell mode: its row-wise reads form a cycle")
|
|
135
|
+
keep = [c for c in keep if c not in stuck]
|
|
136
|
+
if keep:
|
|
137
|
+
regions.append(Region(sheet, r1, r2, sorted(keep), {c: template[c] for c in keep},
|
|
138
|
+
{c: reads[c] for c in keep}, {c: recur[c] for c in keep if c in recur},
|
|
139
|
+
{c: runs[c] for c in keep if c in runs}))
|
|
140
|
+
return RegionScan(regions, rejected)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _running(ast, row: int, col: int):
|
|
144
|
+
"""(E, (side, op)) when the formula is X{r-1} op t1 op t2 ... (+/- only, X{r-1} first) or E + X{r-1}; else
|
|
145
|
+
(ast, None). X - a + b is X + (-a + b): same value, and Excel reports the errors in the same order (X, a, b)."""
|
|
146
|
+
if not (isinstance(ast, Binary) and ast.op in ("+", "-")):
|
|
147
|
+
return ast, None
|
|
148
|
+
if ast.op == "+" and isinstance(ast.right, RefNode) and prev_self(ast.right.text, row, col):
|
|
149
|
+
return ast.left, ("right", "+")
|
|
150
|
+
terms, node = [], ast
|
|
151
|
+
while isinstance(node, Binary) and node.op in ("+", "-"):
|
|
152
|
+
terms.append((node.op, node.right))
|
|
153
|
+
node = node.left
|
|
154
|
+
if not (isinstance(node, RefNode) and prev_self(node.text, row, col)):
|
|
155
|
+
return ast, None
|
|
156
|
+
terms.reverse()
|
|
157
|
+
if len(terms) == 1:
|
|
158
|
+
return terms[0][1], ("left", terms[0][0])
|
|
159
|
+
e = terms[0][1] if terms[0][0] == "+" else Unary("-", terms[0][1])
|
|
160
|
+
for op, t in terms[1:]:
|
|
161
|
+
e = Binary(op, e, t)
|
|
162
|
+
return e, ("left", "+")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _map_sums(node, row: int, col: int, found: list):
|
|
166
|
+
"""The tree with every SUM(<growing range>) replaced by a RUNREF placeholder; found gets (source, start)."""
|
|
167
|
+
if isinstance(node, Call):
|
|
168
|
+
if node.name == "SUM" and len(node.args) == 1 and isinstance(node.args[0], RefNode):
|
|
169
|
+
g = growing(node.args[0].text, row, col)
|
|
170
|
+
if g:
|
|
171
|
+
found.append(g)
|
|
172
|
+
return RefNode(RUNREF.format(*g))
|
|
173
|
+
return Call(node.name, tuple(None if a is None else _map_sums(a, row, col, found) for a in node.args))
|
|
174
|
+
if isinstance(node, (Unary, Postfix)):
|
|
175
|
+
return type(node)(node.op, _map_sums(node.operand, row, col, found))
|
|
176
|
+
if isinstance(node, Binary):
|
|
177
|
+
return Binary(node.op, _map_sums(node.left, row, col, found), _map_sums(node.right, row, col, found))
|
|
178
|
+
return node
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _reads_outside_formula(book: Book, region: Region, col: int) -> bool:
|
|
182
|
+
"""Whether a column reads a formula cell outside the region (same-row, or through an absolute cell/range/name)."""
|
|
183
|
+
for s in region.reads[col] - set(region.cols):
|
|
184
|
+
for r in range(region.r1, region.r2 + 1):
|
|
185
|
+
cell = book.cells.get((region.sheet, r, s))
|
|
186
|
+
if cell is not None and cell.formula is not None:
|
|
187
|
+
return True
|
|
188
|
+
seed = book.cells.get((region.sheet, region.r1 - 1, col)) if col in region.recur else None
|
|
189
|
+
if seed is not None and seed.formula is not None:
|
|
190
|
+
return True
|
|
191
|
+
for src, start in region.runs.get(col, []):
|
|
192
|
+
for r in range(start, region.r1):
|
|
193
|
+
cell = book.cells.get((region.sheet, r, src))
|
|
194
|
+
if cell is not None and cell.formula is not None:
|
|
195
|
+
return True
|
|
196
|
+
for text in refs(region.template[col]):
|
|
197
|
+
if text.startswith(("__ROWREF_", "__RUN_")):
|
|
198
|
+
continue
|
|
199
|
+
try:
|
|
200
|
+
area = resolve(text, region.sheet, book)
|
|
201
|
+
except Unsupported:
|
|
202
|
+
return True
|
|
203
|
+
if any(book.cells[k].formula is not None for k in area.present(book)):
|
|
204
|
+
return True
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _pure_columns(book: Book, region: Region) -> list[int]:
|
|
209
|
+
"""Columns that read only plain inputs or other pure columns. They wait for no formula cell outside the region,
|
|
210
|
+
so no cycle can pass through them: they can always stay one block."""
|
|
211
|
+
pure: set[int] = set()
|
|
212
|
+
for c in column_order(region.cols, region.reads)[0]:
|
|
213
|
+
if (region.reads[c] & set(region.cols)) <= pure and not _reads_outside_formula(book, region, c):
|
|
214
|
+
pure.add(c)
|
|
215
|
+
return sorted(pure)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _part(region: Region, cols: list[int]) -> Region:
|
|
219
|
+
return Region(region.sheet, region.r1, region.r2, cols, {c: region.template[c] for c in cols},
|
|
220
|
+
{c: region.reads[c] for c in cols}, {c: region.recur[c] for c in cols if c in region.recur},
|
|
221
|
+
{c: region.runs[c] for c in cols if c in region.runs})
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def order_with_regions(book: Book, regions: list[Region]):
|
|
225
|
+
"""Dependency order with each region as one node: (order, unresolved, regions that survived).
|
|
226
|
+
|
|
227
|
+
A region that cannot be ordered as one block (a cell between its columns: a running total, a total that a share
|
|
228
|
+
column divides by) is split once into its pure columns and the rest; the rest stays a block if it can, else it
|
|
229
|
+
goes back to cell mode. The pure part never cycles, so splitting only ever adds coverage."""
|
|
230
|
+
from cellproof.graph import order_with_groups
|
|
231
|
+
|
|
232
|
+
pending, split = list(regions), set() # split: ids of pieces made by a split, never split again
|
|
233
|
+
while True:
|
|
234
|
+
order, bad, kept = order_with_groups(book, [r.keys() for r in pending], internally_acyclic=True)
|
|
235
|
+
kept_sets = [set(g) for g in kept]
|
|
236
|
+
survivors = [r for r in pending if set(r.keys()) in kept_sets]
|
|
237
|
+
pieces = []
|
|
238
|
+
for r in pending:
|
|
239
|
+
if set(r.keys()) in kept_sets or id(r) in split:
|
|
240
|
+
continue
|
|
241
|
+
pure = _pure_columns(book, r)
|
|
242
|
+
if pure and len(pure) < len(r.cols):
|
|
243
|
+
parts = [_part(r, pure), _part(r, [c for c in r.cols if c not in pure])]
|
|
244
|
+
split.update(id(p) for p in parts)
|
|
245
|
+
pieces += parts
|
|
246
|
+
if not pieces:
|
|
247
|
+
return order, bad, survivors
|
|
248
|
+
pending = survivors + pieces
|