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/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0.dev0"
|
cellproof/__main__.py
ADDED
cellproof/cli.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""cellproof convert WORKBOOK [--out DIR] [--dbt]"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from cellproof.compiler import NO_VALUE, compile_book
|
|
9
|
+
from cellproof.compute import fill_missing
|
|
10
|
+
from cellproof.emitter import run_dbt, write_project
|
|
11
|
+
from cellproof.reader import UnreadableWorkbook, load
|
|
12
|
+
from cellproof.report import write_report
|
|
13
|
+
from cellproof.verifier import SqlFailed, verify
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main(argv: list[str] | None = None) -> int:
|
|
17
|
+
for stream in (sys.stdout, sys.stderr):
|
|
18
|
+
if hasattr(stream, "reconfigure"):
|
|
19
|
+
stream.reconfigure(errors="replace") # Windows consoles can't print every character
|
|
20
|
+
parser = argparse.ArgumentParser(prog="cellproof", description="Turn an Excel workbook into a verified dbt project.")
|
|
21
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
22
|
+
convert = sub.add_parser("convert", help="convert a workbook and verify every formula cell")
|
|
23
|
+
convert.add_argument("workbook", type=Path)
|
|
24
|
+
convert.add_argument("--out", type=Path, help="output folder (default: <workbook>_dbt next to the file)")
|
|
25
|
+
convert.add_argument("--dbt", action="store_true", help="also run `dbt build` on the result")
|
|
26
|
+
args = parser.parse_args(argv)
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
book = load(args.workbook)
|
|
30
|
+
except UnreadableWorkbook as err:
|
|
31
|
+
print(f"error: {err}", file=sys.stderr)
|
|
32
|
+
return 2
|
|
33
|
+
book, computed, why = fill_missing(book, args.workbook)
|
|
34
|
+
compiled = compile_book(book)
|
|
35
|
+
if why:
|
|
36
|
+
compiled.unresolved.update({k: f"{r} ({why})" for k, r in compiled.unresolved.items() if r == NO_VALUE})
|
|
37
|
+
try:
|
|
38
|
+
verdicts = verify(book, compiled)
|
|
39
|
+
except SqlFailed as err:
|
|
40
|
+
print(f"error: {err}", file=sys.stderr)
|
|
41
|
+
return 3
|
|
42
|
+
out = args.out or args.workbook.with_name(args.workbook.stem + "_dbt")
|
|
43
|
+
write_project(book, compiled, out, args.workbook.stem)
|
|
44
|
+
counts = write_report(verdicts, compiled.findings, out, args.workbook.name, computed, compiled.notes)
|
|
45
|
+
if computed:
|
|
46
|
+
print(f"{len(computed)} expected values computed by the formulas library (the file had none saved)")
|
|
47
|
+
print(f"{counts['PASS']} pass, {counts['FAIL']} fail, {counts['UNRESOLVED']} unresolved -> {out / 'REPORT.md'}")
|
|
48
|
+
code = 1 if counts["FAIL"] else 0
|
|
49
|
+
if args.dbt:
|
|
50
|
+
ok, log = run_dbt(out)
|
|
51
|
+
print("dbt build: ok" if ok else f"dbt build failed:\n{log}")
|
|
52
|
+
code = code if ok else 1
|
|
53
|
+
return code
|
cellproof/compiler.py
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""Formula AST -> SQL. Cell mode, run in batches.
|
|
2
|
+
|
|
3
|
+
Every cell known so far (inputs, then the results of earlier batches) lives in one table with the
|
|
4
|
+
columns of INPUT_COLUMNS: kind is n/s/b/e, num holds numbers and TRUE/FALSE as 1/0, txt holds text
|
|
5
|
+
and Excel error codes. A batch is up to BATCH consecutive formula cells (or BATCH_WEIGHT characters of SQL) in dependency order. It
|
|
6
|
+
becomes one SQL statement: the cells it computes are materialized CTEs (v = value, e = error code
|
|
7
|
+
or NULL), appended to the table it read from. One statement per workbook broke DuckDB at ~1k
|
|
8
|
+
formula cells; batches keep every statement small.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import csv
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from dataclasses import field as dc_field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import cellproof.fn # noqa: F401 (registers the function library)
|
|
18
|
+
from cellproof.expr import (BLANK, CELLS, NO_ERR, Expr, any_of, compare, error, errs, finalize, lit_num, lit_str, num,
|
|
19
|
+
txt, where_cell)
|
|
20
|
+
from cellproof.functions import FUNCS
|
|
21
|
+
from cellproof.graph import formula_order
|
|
22
|
+
from cellproof.model import Book, Cell, Key, Unsupported, a1
|
|
23
|
+
from cellproof.parser import Binary, Bool, Call, ErrLit, Num, Postfix, RefNode, Str, Unary, parse
|
|
24
|
+
from cellproof.rangeindex import RangeIndex, field
|
|
25
|
+
from cellproof.refs import Area, resolve
|
|
26
|
+
|
|
27
|
+
INPUT_COLUMNS = "sheet VARCHAR, row_num INTEGER, col_num INTEGER, kind VARCHAR, num DOUBLE, txt VARCHAR"
|
|
28
|
+
SEED_HEADER = ["sheet", "row_num", "col_num", "kind", "num", "txt"]
|
|
29
|
+
READ_SEED = ("read_csv(?, header = true, quote = '\"', escape = '\"', columns = {'sheet': 'VARCHAR', "
|
|
30
|
+
"'row_num': 'INTEGER', 'col_num': 'INTEGER', 'kind': 'VARCHAR', 'num': 'DOUBLE', 'txt': 'VARCHAR'})")
|
|
31
|
+
VOLATILE = {"NOW", "TODAY", "RAND", "RANDBETWEEN", "OFFSET", "INDIRECT"}
|
|
32
|
+
ARITH = {"+", "-", "*"}
|
|
33
|
+
KIND = {"num": "n", "txt": "s", "bool": "b", "err": "e", "any": "a"} # a: decided when the SQL runs
|
|
34
|
+
TYPE = {"n": "num", "s": "txt", "b": "bool"}
|
|
35
|
+
NO_VALUE = "no saved value in the file - open and save it in Excel"
|
|
36
|
+
DEEP = "formula is nested too deeply to translate"
|
|
37
|
+
TOO_BIG = "formula's SQL would be too large to run"
|
|
38
|
+
# IFERROR/division repeat their arguments in the value and the error SQL, so SQL can double per
|
|
39
|
+
# nesting level; checking every node keeps memory near this bound (200k-cell workbooks stay < 10 KB/cell)
|
|
40
|
+
MAX_SQL = 1_000_000
|
|
41
|
+
# A batch closes at BATCH cells or when its SQL weighs BATCH_WEIGHT characters, whichever comes first. Time inside one
|
|
42
|
+
# statement grows faster than linearly with its cell count, and lookups are far heavier than plain arithmetic, so a
|
|
43
|
+
# count alone makes lookup-heavy batches slow (measured on DuckDB 1.5.6: 25 VLOOKUP cells 0.2 s, 250 cells 17.8 s).
|
|
44
|
+
# BATCH also keeps the UNION ALL chain below DuckDB's expression depth limit of 1000.
|
|
45
|
+
BATCH = 250
|
|
46
|
+
# Sweep (tests/bench_shapes.py, verify seconds / batches; vlookup 500, running_input 2000, flat 5000; sum):
|
|
47
|
+
# W=5000: 3.3/167 3.2/167 7.7/358 sum 14.2
|
|
48
|
+
# W=10000: 3.0/84 3.1/84 7.2/186 sum 13.3 <- chosen (smallest sum)
|
|
49
|
+
# W=20000: 3.2/46 3.9/43 8.3/93 sum 15.4
|
|
50
|
+
# W=40000: 4.0/24 4.8/22 12.0/47 sum 20.8
|
|
51
|
+
# W=80000: 5.2/12 10.1/11 25.9/24 sum 41.2
|
|
52
|
+
BATCH_WEIGHT = 10_000 # characters
|
|
53
|
+
ROW_COST = 250 # characters a cell adds besides its expression: its CTE wrapper and the row it emits
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Step:
|
|
58
|
+
key: Key
|
|
59
|
+
expr: Expr
|
|
60
|
+
cte: str # its CTE name inside its batch
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def step_weight(e: Expr) -> int:
|
|
64
|
+
return ROW_COST + len(e.sql) + len(e.err) + len(e.txt) + len(e.kind)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class Compiled:
|
|
69
|
+
batches: list[list[Step]]
|
|
70
|
+
unresolved: dict[Key, str]
|
|
71
|
+
findings: list[str]
|
|
72
|
+
notes: list[str] = dc_field(default_factory=list)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def steps(self) -> list[Step]:
|
|
76
|
+
return [s for batch in self.batches for s in batch]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class NewBatch(Exception):
|
|
80
|
+
"""A range reads a cell of the open batch. Ranges read the table, so the batch must close first."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Keys:
|
|
84
|
+
"""The non-empty cells of a range, summarised in O(log n). Iterating lists them (O(range): avoid on big ranges)."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, comp: "Compiler", area: Area, present: int, total: int):
|
|
87
|
+
self._comp, self.area, self._n = comp, area, present
|
|
88
|
+
self.kinds = frozenset(name for name in ("n", "s", "b", "e", "a", "blank") if field(total, name))
|
|
89
|
+
self.may_err = field(total, "merr") > 0
|
|
90
|
+
|
|
91
|
+
def __len__(self) -> int:
|
|
92
|
+
return self._n
|
|
93
|
+
|
|
94
|
+
def __iter__(self):
|
|
95
|
+
return iter(self.area.present(self._comp.book))
|
|
96
|
+
|
|
97
|
+
def text_cells(self) -> list:
|
|
98
|
+
"""Input text cells in the range, row-major (the only cells SUM-like functions must warn about)."""
|
|
99
|
+
a = self.area
|
|
100
|
+
return self._comp.index.text_cells(a.sheet, a.r1, a.c1, a.r2, a.c2)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Compiler:
|
|
104
|
+
def __init__(self, book: Book):
|
|
105
|
+
self.book = book
|
|
106
|
+
self.done: dict[Key, tuple[str, bool]] = {} # formula cell in a closed batch -> (type, may be an error)
|
|
107
|
+
self.open: dict[Key, tuple[str, str, bool]] = {} # formula cell in the open batch -> (cte, type, may err)
|
|
108
|
+
self.findings: list[str] = []
|
|
109
|
+
self.index = RangeIndex(book)
|
|
110
|
+
self.rowrefs: dict[str, Expr] | None = None # same-row placeholders of a region (see regions)
|
|
111
|
+
|
|
112
|
+
def compile_cell(self, cell: Cell) -> Expr:
|
|
113
|
+
if cell.array:
|
|
114
|
+
raise Unsupported("array formula")
|
|
115
|
+
return finalize(self.expr(parse(cell.formula), cell.sheet))
|
|
116
|
+
|
|
117
|
+
def expr(self, node, sheet: str) -> Expr:
|
|
118
|
+
e = self._expr(node, sheet)
|
|
119
|
+
if sum(len(part) for part in (e.sql, e.err, e.txt, e.kind)) > MAX_SQL:
|
|
120
|
+
raise Unsupported(TOO_BIG)
|
|
121
|
+
return e
|
|
122
|
+
|
|
123
|
+
def _expr(self, node, sheet: str) -> Expr:
|
|
124
|
+
if node is None:
|
|
125
|
+
return BLANK
|
|
126
|
+
if isinstance(node, Num):
|
|
127
|
+
return lit_num(node.value)
|
|
128
|
+
if isinstance(node, Str):
|
|
129
|
+
return lit_str(node.value)
|
|
130
|
+
if isinstance(node, Bool):
|
|
131
|
+
return Expr("TRUE" if node.value else "FALSE", "bool")
|
|
132
|
+
if isinstance(node, ErrLit):
|
|
133
|
+
return error(node.value)
|
|
134
|
+
if isinstance(node, RefNode):
|
|
135
|
+
if self.rowrefs is not None and node.text in self.rowrefs:
|
|
136
|
+
return self.rowrefs[node.text]
|
|
137
|
+
area = resolve(node.text, sheet, self.book)
|
|
138
|
+
if not area.is_single:
|
|
139
|
+
raise Unsupported(f"range {node.text} used as a single value")
|
|
140
|
+
return self.cell_value(area.key)
|
|
141
|
+
if isinstance(node, Unary):
|
|
142
|
+
x = num(self.expr(node.operand, sheet))
|
|
143
|
+
return Expr(f"(-{x.sql})", "num", x.err)
|
|
144
|
+
if isinstance(node, Postfix):
|
|
145
|
+
x = num(self.expr(node.operand, sheet))
|
|
146
|
+
return Expr(f"({x.sql} / 100)", "num", x.err)
|
|
147
|
+
if isinstance(node, Binary):
|
|
148
|
+
return self.binary(node, sheet)
|
|
149
|
+
if isinstance(node, Call):
|
|
150
|
+
if node.name in VOLATILE:
|
|
151
|
+
raise Unsupported(f"volatile function {node.name}")
|
|
152
|
+
fn = FUNCS.get(node.name)
|
|
153
|
+
if fn is None:
|
|
154
|
+
raise Unsupported(f"function {node.name} not supported yet")
|
|
155
|
+
return fn(self, node.args, sheet)
|
|
156
|
+
raise Unsupported(f"cannot translate {node!r}")
|
|
157
|
+
|
|
158
|
+
def binary(self, node: Binary, sheet: str) -> Expr:
|
|
159
|
+
a, b, op = self.expr(node.left, sheet), self.expr(node.right, sheet), node.op
|
|
160
|
+
if op in ARITH or op in ("/", "^"):
|
|
161
|
+
x, y = num(a), num(b)
|
|
162
|
+
if op in ARITH:
|
|
163
|
+
return Expr(f"({x.sql} {op} {y.sql})", "num", errs(x.err, y.err))
|
|
164
|
+
if op == "/": # DuckDB gives inf, Excel #DIV/0!
|
|
165
|
+
return Expr(f"({x.sql} / NULLIF({y.sql}, 0))", "num",
|
|
166
|
+
errs(x.err, y.err, f"(CASE WHEN {y.sql} = 0 THEN '#DIV/0!' END)"))
|
|
167
|
+
# DuckDB POWER returns inf or nan where Excel returns an error (verified on 1.5.6)
|
|
168
|
+
# Real Excel: (-8)^(1/3) is -1.9999999999999998, the real cube root: a negative number to the power of
|
|
169
|
+
# 1/odd is minus the root of its absolute value. Any other fractional power of a negative number is #NUM!.
|
|
170
|
+
root = f"(1 / NULLIF({y.sql}, 0))"
|
|
171
|
+
odd_root = (f"({x.sql} < 0 AND {y.sql} <> FLOOR({y.sql}) AND abs({root} - round({root})) < 1e-9 "
|
|
172
|
+
f"AND abs(round({root})) % 2 = 1)")
|
|
173
|
+
bad = (f"(CASE WHEN {x.sql} = 0 AND {y.sql} < 0 THEN '#DIV/0!' "
|
|
174
|
+
f"WHEN {x.sql} = 0 AND {y.sql} = 0 THEN '#NUM!' "
|
|
175
|
+
f"WHEN {x.sql} < 0 AND {y.sql} <> FLOOR({y.sql}) AND NOT {odd_root} THEN '#NUM!' END)")
|
|
176
|
+
return Expr(f"(CASE WHEN {odd_root} THEN -POWER(-{x.sql}, {y.sql}) ELSE POWER({x.sql}, {y.sql}) END)",
|
|
177
|
+
"num", errs(x.err, y.err, bad))
|
|
178
|
+
if op == "&":
|
|
179
|
+
x, y = txt(a), txt(b)
|
|
180
|
+
return Expr(f"({x.sql} || {y.sql})", "txt", errs(x.err, y.err))
|
|
181
|
+
return compare(a, b, op)
|
|
182
|
+
|
|
183
|
+
def cell_value(self, key: Key) -> Expr:
|
|
184
|
+
cell = self.book.cells.get(key)
|
|
185
|
+
if cell is None or (cell.formula is None and cell.kind == "blank"):
|
|
186
|
+
return BLANK
|
|
187
|
+
if key in self.open:
|
|
188
|
+
cte, typ, may_err = self.open[key]
|
|
189
|
+
err = f"(SELECT e FROM {cte})" if may_err else NO_ERR
|
|
190
|
+
if typ == "any":
|
|
191
|
+
return any_of(f"(SELECT k FROM {cte})", f"(SELECT v FROM {cte})", f"(SELECT t FROM {cte})", err)
|
|
192
|
+
return Expr(f"(SELECT v FROM {cte})", typ, err)
|
|
193
|
+
if cell.formula is None:
|
|
194
|
+
if cell.kind == "e":
|
|
195
|
+
return error(cell.value)
|
|
196
|
+
typ, may_err = TYPE[cell.kind], False
|
|
197
|
+
elif key in self.done:
|
|
198
|
+
typ, may_err = self.done[key]
|
|
199
|
+
else:
|
|
200
|
+
raise Unsupported(f"depends on unresolved cell {a1(key)}")
|
|
201
|
+
where = f"FROM {CELLS} WHERE {where_cell(key)}"
|
|
202
|
+
err = f"(SELECT CASE WHEN kind = 'e' THEN txt END {where})" if may_err else NO_ERR
|
|
203
|
+
if typ == "num":
|
|
204
|
+
return Expr(f"(SELECT num {where})", "num", err)
|
|
205
|
+
if typ == "txt":
|
|
206
|
+
return Expr(f"(SELECT COALESCE(txt, '') {where})", "txt", err) # the seed CSV reads '' back as NULL
|
|
207
|
+
if typ == "bool":
|
|
208
|
+
return Expr(f"((SELECT num {where}) <> 0)", "bool", err)
|
|
209
|
+
if typ == "any":
|
|
210
|
+
return any_of(f"(SELECT kind {where})", f"(SELECT num {where})", f"(SELECT COALESCE(txt, '') {where})", err)
|
|
211
|
+
return Expr("NULL", "err", err)
|
|
212
|
+
|
|
213
|
+
def ready(self, area: Area) -> Keys:
|
|
214
|
+
"""The non-empty cells of a range that SQL will read from the table. All must be computed already."""
|
|
215
|
+
s = self.index.summary(area.sheet, area.r1, area.c1, area.r2, area.c2)
|
|
216
|
+
if field(s.total, "fdone") < s.formulas:
|
|
217
|
+
self._blocked(area)
|
|
218
|
+
return Keys(self, area, s.present, s.total)
|
|
219
|
+
|
|
220
|
+
def _blocked(self, area: Area) -> None:
|
|
221
|
+
"""Some formula cell of the range is unfinished: raise for the first one, in row-major order."""
|
|
222
|
+
for key in area.present(self.book):
|
|
223
|
+
if self.book.cells[key].formula is None:
|
|
224
|
+
continue
|
|
225
|
+
if key in self.open:
|
|
226
|
+
raise NewBatch
|
|
227
|
+
if key not in self.done:
|
|
228
|
+
raise Unsupported(f"depends on unresolved cell {a1(key)}")
|
|
229
|
+
raise AssertionError("range index is out of step with the compiler")
|
|
230
|
+
|
|
231
|
+
def kind_of(self, key: Key) -> str:
|
|
232
|
+
"""Value kind (n/s/b/e) a cell has in the table when it isn't an error. Call ready() first."""
|
|
233
|
+
cell = self.book.cells[key]
|
|
234
|
+
return KIND[self.done[key][0]] if cell.formula is not None else cell.kind
|
|
235
|
+
|
|
236
|
+
def may_err(self, key: Key) -> bool:
|
|
237
|
+
"""Whether the table can hold an error for this cell. Call ready() first."""
|
|
238
|
+
cell = self.book.cells[key]
|
|
239
|
+
return self.done[key][1] if cell.formula is not None else cell.kind == "e"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _finish(comp: Compiler, batches: list, batch) -> None:
|
|
243
|
+
"""A batch is finished: later cells read its results from the table. `batch` is a list of Steps or a region batch."""
|
|
244
|
+
batches.append(batch)
|
|
245
|
+
comp.done.update((s.key, (s.expr.type, s.expr.err != NO_ERR)) for s in batch)
|
|
246
|
+
for s in batch:
|
|
247
|
+
comp.index.complete(s.key, KIND[s.expr.type], s.expr.err != NO_ERR)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _close(comp: Compiler, batches: list[list[Step]], current: list[Step]) -> None:
|
|
251
|
+
"""Seal the open batch: later cells read its results from the table."""
|
|
252
|
+
if current:
|
|
253
|
+
_finish(comp, batches, list(current))
|
|
254
|
+
comp.open.clear()
|
|
255
|
+
current.clear()
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def compile_book(book: Book, regions: bool = True) -> Compiled:
|
|
259
|
+
from openpyxl.utils import get_column_letter
|
|
260
|
+
|
|
261
|
+
from cellproof.region import compile_region, region_note
|
|
262
|
+
from cellproof.regions import RegionScan, column_order, find_regions, order_with_regions
|
|
263
|
+
|
|
264
|
+
scan = find_regions(book) if regions else RegionScan([], [])
|
|
265
|
+
order, unresolved, kept = order_with_regions(book, scan.regions)
|
|
266
|
+
comp = Compiler(book)
|
|
267
|
+
batches: list = []
|
|
268
|
+
current: list[Step] = []
|
|
269
|
+
weight = 0
|
|
270
|
+
first = {r.first_key: r for r in kept}
|
|
271
|
+
inside = {k for r in kept for k in r.keys()} - set(first)
|
|
272
|
+
notes = list(scan.rejected)
|
|
273
|
+
kept_sets = [set(r.keys()) for r in kept]
|
|
274
|
+
for r in scan.regions:
|
|
275
|
+
if set(r.keys()) in kept_sets:
|
|
276
|
+
continue
|
|
277
|
+
letters = lambda cols: ", ".join(get_column_letter(c) for c in cols) # noqa: E731
|
|
278
|
+
pieces = [k for k in kept if (k.sheet, k.r1, k.r2) == (r.sheet, r.r1, r.r2) and set(k.cols) <= set(r.cols)]
|
|
279
|
+
lost = sorted(set(r.cols) - {c for k in pieces for c in k.cols})
|
|
280
|
+
why = "another cell sits between them in the dependency order"
|
|
281
|
+
if pieces:
|
|
282
|
+
blocks = (f"split into blocks ({' | '.join(letters(k.cols) for k in pieces)})" if len(pieces) > 1
|
|
283
|
+
else f"kept columns {letters(pieces[0].cols)} as a block")
|
|
284
|
+
notes.append(f"{r.sheet} rows {r.r1}-{r.r2} {blocks}: {why}"
|
|
285
|
+
+ (f"; columns {letters(lost)} stay in cell mode" if lost else ""))
|
|
286
|
+
else:
|
|
287
|
+
notes.append(f"{r.sheet} rows {r.r1}-{r.r2}, columns {letters(r.cols)} stay in cell mode: they could not "
|
|
288
|
+
f"be ordered as one block ({why})")
|
|
289
|
+
used_models: set[str] = set()
|
|
290
|
+
|
|
291
|
+
def add(key) -> None:
|
|
292
|
+
nonlocal weight
|
|
293
|
+
cell = book.cells[key]
|
|
294
|
+
if cell.kind == "blank":
|
|
295
|
+
unresolved[key] = NO_VALUE
|
|
296
|
+
return
|
|
297
|
+
if current and (len(current) >= BATCH or weight >= BATCH_WEIGHT):
|
|
298
|
+
_close(comp, batches, current)
|
|
299
|
+
weight = 0
|
|
300
|
+
try:
|
|
301
|
+
try:
|
|
302
|
+
e = comp.compile_cell(cell)
|
|
303
|
+
except NewBatch:
|
|
304
|
+
_close(comp, batches, current)
|
|
305
|
+
weight = 0
|
|
306
|
+
e = comp.compile_cell(cell) # the open batch is empty now, so this can't raise NewBatch
|
|
307
|
+
except Unsupported as err:
|
|
308
|
+
unresolved[key] = str(err)
|
|
309
|
+
return
|
|
310
|
+
except RecursionError: # Python's stack, not DuckDB's: DuckDB took 900 nesting levels in tests
|
|
311
|
+
unresolved[key] = DEEP
|
|
312
|
+
return
|
|
313
|
+
step = Step(key, e, f"c{len(current) + 1}")
|
|
314
|
+
comp.open[key] = (step.cte, e.type, e.err != NO_ERR)
|
|
315
|
+
current.append(step)
|
|
316
|
+
weight += step_weight(e)
|
|
317
|
+
|
|
318
|
+
for key in order:
|
|
319
|
+
if key in inside:
|
|
320
|
+
continue
|
|
321
|
+
region = first.get(key)
|
|
322
|
+
if region is None:
|
|
323
|
+
add(key)
|
|
324
|
+
continue
|
|
325
|
+
_close(comp, batches, current) # the region reads finished cells from the table
|
|
326
|
+
weight = 0
|
|
327
|
+
batch, rejected = compile_region(comp, region)
|
|
328
|
+
if batch is not None:
|
|
329
|
+
_finish(comp, batches, batch)
|
|
330
|
+
if batch is not None:
|
|
331
|
+
base, i = batch.model, 1
|
|
332
|
+
while batch.model in used_models:
|
|
333
|
+
i += 1
|
|
334
|
+
batch.model = f"{base}_{i}"
|
|
335
|
+
used_models.add(batch.model)
|
|
336
|
+
notes += region_note(region, batch, rejected)
|
|
337
|
+
for col in column_order(region.cols, region.reads)[0]:
|
|
338
|
+
if col in rejected:
|
|
339
|
+
for row in range(region.r1, region.r2 + 1):
|
|
340
|
+
add((region.sheet, row, col))
|
|
341
|
+
_close(comp, batches, current)
|
|
342
|
+
return Compiled(batches, unresolved, comp.findings, notes)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def cell_row(typ: str, v: str, e: str, t: str, k: str) -> tuple[str, str, str]:
|
|
346
|
+
"""The kind, num and txt SQL of one result row, from SQL for its value v, error e, text t and kind k."""
|
|
347
|
+
clean = e == NO_ERR # the cell cannot hold an error: no CASE needed
|
|
348
|
+
if typ == "any": # the kind is only known when the SQL runs
|
|
349
|
+
if clean:
|
|
350
|
+
return k, f"CASE WHEN {k} <> 's' THEN CAST({v} AS DOUBLE) END", f"CASE WHEN {k} = 's' THEN {t} END"
|
|
351
|
+
return (f"CASE WHEN {e} IS NULL THEN {k} ELSE 'e' END",
|
|
352
|
+
f"CASE WHEN {e} IS NULL AND {k} <> 's' THEN CAST({v} AS DOUBLE) END",
|
|
353
|
+
f"CASE WHEN {e} IS NOT NULL THEN {e} WHEN {k} = 's' THEN {t} END")
|
|
354
|
+
if clean:
|
|
355
|
+
num_sql = "CAST(NULL AS DOUBLE)" if typ in ("txt", "err") else f"CAST({v} AS DOUBLE)"
|
|
356
|
+
return f"'{KIND[typ]}'", num_sql, (f"CAST({v} AS VARCHAR)" if typ == "txt" else "NULL")
|
|
357
|
+
num_sql = ("CAST(NULL AS DOUBLE)" if typ in ("txt", "err")
|
|
358
|
+
else f"CASE WHEN {e} IS NULL THEN CAST({v} AS DOUBLE) END")
|
|
359
|
+
txt_sql = f"COALESCE({e}, CAST({v} AS VARCHAR))" if typ == "txt" else e
|
|
360
|
+
return f"CASE WHEN {e} IS NULL THEN '{KIND[typ]}' ELSE 'e' END", num_sql, txt_sql
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def render_batch(steps: list[Step], prev: str, own_only: bool = False) -> str:
|
|
364
|
+
"""One batch as a SELECT: the new row of every cell this batch computes, after every row of `prev` unless own_only."""
|
|
365
|
+
ctes = ",\n".join(f"{s.cte} AS MATERIALIZED (SELECT {s.expr.sql} AS v, CAST({s.expr.err} AS VARCHAR) AS e, "
|
|
366
|
+
f"CAST({s.expr.txt} AS VARCHAR) AS t, CAST({s.expr.kind} AS VARCHAR) AS k)" for s in steps)
|
|
367
|
+
rows = []
|
|
368
|
+
for s in steps:
|
|
369
|
+
sheet, row, col = s.key
|
|
370
|
+
typ = s.expr.type
|
|
371
|
+
v, e, t, k = (f"(SELECT {c} FROM {s.cte})" for c in "vetk")
|
|
372
|
+
kind, num_sql, txt_sql = cell_row(typ, v, e, t, k)
|
|
373
|
+
rows.append(f"SELECT {lit_str(sheet).sql}, {row}, {col}, {kind}, {num_sql}, {txt_sql}")
|
|
374
|
+
head = "" if own_only else f"SELECT * FROM {CELLS}\nUNION ALL\n"
|
|
375
|
+
sql = f"WITH\n{ctes}\n{head}" + "\nUNION ALL\n".join(rows)
|
|
376
|
+
return sql.replace(CELLS, prev)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def batch_rows(batch, prev: str) -> str:
|
|
380
|
+
"""SQL yielding only the new rows of a batch: a list of Steps (cell batch) or a region batch."""
|
|
381
|
+
if hasattr(batch, "render_rows"):
|
|
382
|
+
return batch.render_rows(prev)
|
|
383
|
+
return render_batch(batch, prev, own_only=True)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def input_rows(book: Book) -> list[tuple]:
|
|
387
|
+
"""Every non-formula cell as a row of the cells table (see INPUT_COLUMNS)."""
|
|
388
|
+
rows = []
|
|
389
|
+
for (sheet, row, col), cell in sorted(book.cells.items()):
|
|
390
|
+
if cell.formula is not None or cell.kind == "blank":
|
|
391
|
+
continue
|
|
392
|
+
if cell.kind == "b":
|
|
393
|
+
num_v, txt_v = (1.0 if cell.value else 0.0), None
|
|
394
|
+
elif cell.kind == "n":
|
|
395
|
+
num_v, txt_v = cell.value, None
|
|
396
|
+
else:
|
|
397
|
+
num_v, txt_v = None, cell.value
|
|
398
|
+
rows.append((sheet, row, col, cell.kind, num_v, txt_v))
|
|
399
|
+
return rows
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def write_seed(book: Book, path: Path) -> None:
|
|
403
|
+
"""The input cells as CSV: the dbt seed, and what the verifier loads (0.1 s for 200k rows)."""
|
|
404
|
+
with open(path, "w", newline="", encoding="utf-8") as f:
|
|
405
|
+
w = csv.writer(f)
|
|
406
|
+
w.writerow(SEED_HEADER)
|
|
407
|
+
for row in input_rows(book):
|
|
408
|
+
w.writerow(["" if x is None else x for x in row])
|
cellproof/compute.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Answer key for formula cells the file has no saved value for (written by a script, never opened in Excel).
|
|
2
|
+
|
|
3
|
+
Computed by the `formulas` library: pip install cellproof[compute]. Only cells without a saved value are
|
|
4
|
+
filled; Excel's own answers always win. The report labels these answers 'computed'.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import replace
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from openpyxl.utils.cell import coordinate_to_tuple
|
|
12
|
+
|
|
13
|
+
from cellproof.model import Book, Key, Kind, Value
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _answer(x) -> tuple[Value, Kind]:
|
|
17
|
+
if hasattr(x, "item"): # numpy scalar -> plain Python value
|
|
18
|
+
x = x.item()
|
|
19
|
+
if isinstance(x, bool):
|
|
20
|
+
return x, "b"
|
|
21
|
+
if isinstance(x, (int, float)):
|
|
22
|
+
return float(x), "n"
|
|
23
|
+
if type(x).__name__ == "XlError": # before str: formulas' tokens subclass str
|
|
24
|
+
return str(x), "e"
|
|
25
|
+
if type(x) is str:
|
|
26
|
+
return x, "s"
|
|
27
|
+
return None, "blank" # formulas' empty token (or anything unknown): leave the cell unresolved
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def fill_missing(book: Book, path: Path) -> tuple[Book, set[Key], str]:
|
|
31
|
+
"""Returns (book with computed values, keys computed, why not - empty when it ran)."""
|
|
32
|
+
missing = {k for k, c in book.cells.items() if c.formula is not None and c.kind == "blank"}
|
|
33
|
+
if not missing:
|
|
34
|
+
return book, set(), ""
|
|
35
|
+
if any("'" in s for s in book.sheets): # formulas 1.3.4 answers #REF! for every formula on these sheets
|
|
36
|
+
return book, set(), "cannot compute answers when a sheet name contains an apostrophe"
|
|
37
|
+
try:
|
|
38
|
+
import formulas
|
|
39
|
+
except ImportError:
|
|
40
|
+
return book, set(), "or install cellproof[compute] to compute it"
|
|
41
|
+
try:
|
|
42
|
+
solution = formulas.ExcelModel().loads(str(path)).finish().calculate()
|
|
43
|
+
except Exception as err: # formulas raises many types on workbooks it can't model
|
|
44
|
+
return book, set(), f"the formulas library failed: {err}"
|
|
45
|
+
sheets = {s.upper(): s for s in book.sheets} # formulas upper-cases sheet names
|
|
46
|
+
cells, computed = dict(book.cells), set()
|
|
47
|
+
for ref, ranges in solution.items():
|
|
48
|
+
where, _, addr = ref.rpartition("!") # "'[book.xlsx]SHEET'!A1"
|
|
49
|
+
sheet = sheets.get(where.split("]", 1)[-1].rstrip("'"))
|
|
50
|
+
if sheet is None or ":" in addr:
|
|
51
|
+
continue
|
|
52
|
+
try:
|
|
53
|
+
row, col = coordinate_to_tuple(addr.replace("$", ""))
|
|
54
|
+
except ValueError:
|
|
55
|
+
continue
|
|
56
|
+
key = (sheet, row, col)
|
|
57
|
+
if key in missing:
|
|
58
|
+
value, kind = _answer(ranges.value[0][0])
|
|
59
|
+
if kind != "blank":
|
|
60
|
+
cells[key] = replace(cells[key], value=value, kind=kind)
|
|
61
|
+
computed.add(key)
|
|
62
|
+
return replace(book, cells=cells), computed, ""
|