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 ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0.dev0"
cellproof/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from cellproof.cli import main
2
+
3
+ raise SystemExit(main())
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, ""