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