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/emitter.py ADDED
@@ -0,0 +1,160 @@
1
+ """Write the compiled workbook as a dbt project (DuckDB profile included).
2
+
3
+ The models run the SQL the verifier ran: seed input_cells -> cellproof_cells/cells_0001 -> ... Each batch model
4
+ holds only its own rows; a checkpoint model (cp_NNNN) unions everything every 64 batches and after the last
5
+ batch, so no model reads more than ~65 sources. workbook_cells keeps the formula cells only.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ from pathlib import Path
14
+
15
+ from cellproof.compiler import Compiled, Step, render_batch, write_seed
16
+ from cellproof.expr import CELLS
17
+ from cellproof.region import WIDE
18
+ from cellproof.model import Book
19
+
20
+ PROJECT = """name: {name}
21
+ version: "1.0.0"
22
+ config-version: 2
23
+ profile: cellproof
24
+ flags:
25
+ send_anonymous_usage_stats: false
26
+ seeds:
27
+ {name}:
28
+ +column_types:
29
+ sheet: varchar
30
+ row_num: integer
31
+ col_num: integer
32
+ kind: varchar
33
+ num: double
34
+ txt: varchar
35
+ models:
36
+ {name}:
37
+ +materialized: table
38
+ """
39
+
40
+ PROFILES = """cellproof:
41
+ target: dev
42
+ outputs:
43
+ dev:
44
+ type: duckdb
45
+ path: cellproof.duckdb
46
+ """
47
+
48
+ SCHEMA = """version: 2
49
+ models:
50
+ - name: workbook_cells
51
+ description: "Every formula cell of the workbook, recomputed in SQL. One row per cell."
52
+ columns:
53
+ - name: sheet
54
+ data_tests: [not_null]
55
+ - name: row_num
56
+ data_tests: [not_null]
57
+ - name: col_num
58
+ data_tests: [not_null]
59
+ """
60
+
61
+
62
+ def project_name(stem: str) -> str:
63
+ name = re.sub(r"[^A-Za-z0-9]+", "_", stem).strip("_").lower() or "workbook"
64
+ return f"wb_{name}" if name[0].isdigit() else name
65
+
66
+
67
+ def model_name(k: int) -> str:
68
+ return f"cells_{k:04d}"
69
+
70
+
71
+ CHECKPOINT = 64 # a model reads the latest checkpoint plus at most this many batch models
72
+
73
+
74
+ def checkpoint_name(k: int) -> str:
75
+ return f"cp_{k:04d}"
76
+
77
+
78
+ def sources_for(k: int) -> list[str]:
79
+ """Models whose union is every cell known before batch k (1-based): latest checkpoint, then later batches."""
80
+ base = (k - 1) // CHECKPOINT * CHECKPOINT
81
+ first = "input_cells" if base == 0 else checkpoint_name(base)
82
+ return [first] + [model_name(i) for i in range(base + 1, k)]
83
+
84
+
85
+ def _union(models: list[str]) -> str:
86
+ return "\nUNION ALL\n".join("SELECT * FROM {{ ref('" + m + "') }}" for m in models)
87
+
88
+
89
+ def _escape(sql: str) -> str:
90
+ """Braces from workbook text are escaped so Jinja renders them literally."""
91
+ return re.sub(r"[{}]", lambda m: "{{ '" + m.group() + "' }}", sql)
92
+
93
+
94
+ def dbt_sql(steps: list[Step], sources: list[str]) -> str:
95
+ """One batch as a dbt model: the new rows only, reading all earlier cells through the CTE all_cells."""
96
+ body = _escape(render_batch(steps, "all_cells", own_only=True))
97
+ return body.replace("WITH\n", f"WITH\nall_cells AS (\n{_union(sources)}\n),\n", 1)
98
+
99
+
100
+ def dbt_region_wide(batch, sources: list[str]) -> str:
101
+ """The wide, readable model of a region: it reads every earlier cell through the CTE all_cells."""
102
+ body = _escape(batch.wide_sql.replace(CELLS, "all_cells"))
103
+ return body.replace("WITH\n", f"WITH\nall_cells AS (\n{_union(sources)}\n),\n", 1)
104
+
105
+
106
+ def dbt_region_cells(batch) -> str:
107
+ """The long rows of a region: a mechanical unpivot of its wide model."""
108
+ return _escape(batch.unpivot(WIDE)).replace(WIDE, "{{ ref('" + batch.model + "') }}")
109
+
110
+
111
+ def final_sql(last: str) -> str:
112
+ """The formula cells: every row of the last batch that is not an input."""
113
+ return ("SELECT c.* FROM {{ ref('" + last + "') }} AS c\nWHERE NOT EXISTS (\n"
114
+ " SELECT 1 FROM {{ ref('input_cells') }} AS i\n"
115
+ " WHERE i.sheet = c.sheet AND i.row_num = c.row_num AND i.col_num = c.col_num\n)")
116
+
117
+
118
+ def write_project(book: Book, compiled: Compiled, out: Path, stem: str) -> None:
119
+ name = project_name(stem)
120
+ batches_dir = out / "models" / "cellproof_cells"
121
+ (out / "seeds").mkdir(parents=True, exist_ok=True)
122
+ batches_dir.mkdir(parents=True, exist_ok=True)
123
+ for old in batches_dir.glob("*.sql"): # a rerun must not leave batches or checkpoints from a bigger workbook
124
+ old.unlink()
125
+ (out / "dbt_project.yml").write_text(PROJECT.format(name=name), encoding="utf-8")
126
+ (out / "profiles.yml").write_text(PROFILES, encoding="utf-8")
127
+ write_seed(book, out / "seeds" / "input_cells.csv")
128
+ n = len(compiled.batches)
129
+ last = "input_cells"
130
+ schema = SCHEMA
131
+ used: set[str] = set()
132
+ for k, batch in enumerate(compiled.batches, 1):
133
+ if hasattr(batch, "wide_sql"):
134
+ base, i = batch.model, 1
135
+ while batch.model in used: # sheet names that slug alike must not share a model
136
+ i += 1
137
+ batch.model = f"{base}_{i}"
138
+ used.add(batch.model)
139
+ (batches_dir / f"{batch.model}.sql").write_text(dbt_region_wide(batch, sources_for(k)) + "\n", encoding="utf-8")
140
+ (batches_dir / f"{model_name(k)}.sql").write_text(dbt_region_cells(batch) + "\n", encoding="utf-8")
141
+ schema += (f' - name: {batch.model}\n description: "Region: rows of one sheet that repeat one formula down their rows, '
142
+ f'computed in one statement. Wide and readable; its {model_name(k)} twin holds the same values as long rows."\n')
143
+ else:
144
+ (batches_dir / f"{model_name(k)}.sql").write_text(dbt_sql(batch, sources_for(k)) + "\n", encoding="utf-8")
145
+ if k % CHECKPOINT == 0 or k == n:
146
+ last = checkpoint_name(k)
147
+ (batches_dir / f"{last}.sql").write_text(_union(sources_for(k) + [model_name(k)]) + "\n", encoding="utf-8")
148
+ (out / "models" / "workbook_cells.sql").write_text(final_sql(last) + "\n", encoding="utf-8")
149
+ (out / "models" / "schema.yml").write_text(schema, encoding="utf-8")
150
+
151
+
152
+ def run_dbt(out: Path) -> tuple[bool, str]:
153
+ """End-to-end check: dbt seeds the inputs and builds the models on DuckDB."""
154
+ exe = shutil.which("dbt")
155
+ if exe is None:
156
+ return False, "dbt is not installed - run: pip install cellproof[dbt]"
157
+ env = {**os.environ, "DBT_SEND_ANONYMOUS_USAGE_STATS": "False"} # dbt phones home by default; cellproof never does
158
+ proc = subprocess.run([exe, "build", "--project-dir", str(out), "--profiles-dir", str(out)],
159
+ capture_output=True, text=True, cwd=out, env=env)
160
+ return proc.returncode == 0, (proc.stdout + proc.stderr)[-4000:]
cellproof/expr.py ADDED
@@ -0,0 +1,233 @@
1
+ """Typed SQL expressions, Excel-style type coercion and Excel error codes."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+ from cellproof.model import Key, Unsupported
9
+
10
+ CELLS = "__cellproof_cells__" # every cell known before the current batch; replaced when SQL is rendered
11
+ NO_ERR = "NULL" # the err of an expression that cannot fail
12
+ Type = Literal["num", "txt", "bool", "blank", "err", "any"]
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Expr:
17
+ sql: str # the value; for "any" its number part (numbers, TRUE/FALSE as 1/0); meaningless when err is set
18
+ type: Type
19
+ err: str = NO_ERR # SQL giving the Excel error code ('#DIV/0!') or NULL
20
+ txt: str = "NULL" # "any" only: the text part
21
+ kind: str = "NULL" # "any" only: SQL giving 'n', 's' or 'b' - the value's type, known only when the SQL runs
22
+
23
+
24
+ ZERO = Expr("CAST(0 AS DOUBLE)", "num")
25
+ BLANK = Expr("NULL", "blank") # an empty cell; always coerced before use, never rendered raw
26
+
27
+
28
+ def lit_num(v: float) -> Expr:
29
+ if not math.isfinite(v): # 1E+400 in a crafted file: Excel's limit is about 1.8E+308
30
+ raise Unsupported(f"number {v!r} is beyond Excel's range")
31
+ return Expr(f"CAST({v!r} AS DOUBLE)", "num") # bare literals would be DECIMAL in DuckDB
32
+
33
+
34
+ def lit_str(v: str) -> Expr:
35
+ return Expr("'" + v.replace("'", "''") + "'", "txt")
36
+
37
+
38
+ def error(code: str) -> Expr:
39
+ """A value that is always this Excel error."""
40
+ return Expr("NULL", "err", lit_str(code).sql)
41
+
42
+
43
+ def errs(*parts: str) -> str:
44
+ """Excel reports the first error it meets, left to right."""
45
+ live = [p for p in parts if p != NO_ERR]
46
+ if not live:
47
+ return NO_ERR
48
+ return live[0] if len(live) == 1 else f"COALESCE({', '.join(live)})"
49
+
50
+
51
+ def any_of(kind: str, num_sql: str, txt_sql: str, err: str = NO_ERR) -> Expr:
52
+ return Expr(num_sql, "any", err, txt_sql, kind)
53
+
54
+
55
+ def parse_num(t: str) -> str:
56
+ """Text -> number the way Excel's arithmetic reads it (' 3 ', '-1.5e3'), else NULL. 'inf', '1,000' -> NULL."""
57
+ return (f"(CASE WHEN regexp_matches(trim({t}), '^[+-]?([0-9]+[.]?[0-9]*|[.][0-9]+)([eE][+-]?[0-9]+)?$') "
58
+ f"THEN CAST(trim({t}) AS DOUBLE) END)")
59
+
60
+
61
+ # Real Excel writes 1E-11 ... 1E+19 as plain digits (20 digits for 1E+19) and 1E+20 and up as 1E+20.
62
+ # ponytail: PLAIN_FROM is only known to be <= -11; nothing smaller has been tried in real Excel yet.
63
+ PLAIN_FROM, PLAIN_TO = -11, 19
64
+
65
+
66
+ def num_text(x: str) -> str:
67
+ """Number -> text the way Excel writes it in a formula: up to 15 significant digits (0.1+0.2 -> '0.3')."""
68
+ digits = "rtrim(replace(split_part(s, 'e', 1), '.', ''), '0')" # the 15 significant digits, no trailing zeros
69
+ plain = ("CASE WHEN e >= 0 THEN (CASE WHEN length(d) <= e + 1 THEN d || repeat('0', e + 1 - length(d)) "
70
+ "ELSE substr(d, 1, e + 1) || '.' || substr(d, e + 2) END) "
71
+ "ELSE '0.' || repeat('0', -e - 1) || d END")
72
+ return (f"(SELECT CASE WHEN v = 0 THEN '0' "
73
+ f"WHEN e IS NULL OR e < {PLAIN_FROM} OR e > {PLAIN_TO} THEN upper(printf('%.15g', v)) "
74
+ f"ELSE (CASE WHEN v < 0 THEN '-' ELSE '' END) || ({plain}) END "
75
+ f"FROM (SELECT v, {digits} AS d, TRY_CAST(split_part(s, 'e', 2) AS INTEGER) AS e "
76
+ f"FROM (SELECT v, printf('%.14e', abs(v)) AS s FROM (SELECT {x} AS v))))")
77
+
78
+
79
+ def _value_error(bad: str) -> str:
80
+ return f"(CASE WHEN {bad} THEN '#VALUE!' END)"
81
+
82
+
83
+ def num(e: Expr) -> Expr:
84
+ if e.type == "num":
85
+ return e
86
+ if e.type == "blank":
87
+ return ZERO
88
+ if e.type == "bool":
89
+ return Expr(f"CAST({e.sql} AS DOUBLE)", "num", e.err)
90
+ if e.type == "err":
91
+ return Expr("CAST(NULL AS DOUBLE)", "num", e.err)
92
+ if e.type == "txt":
93
+ n = parse_num(e.sql)
94
+ return Expr(n, "num", errs(e.err, _value_error(f"{n} IS NULL")))
95
+ n = parse_num(e.txt)
96
+ return Expr(f"(CASE WHEN {e.kind} = 's' THEN {n} ELSE {e.sql} END)", "num",
97
+ errs(e.err, _value_error(f"{e.kind} = 's' AND {n} IS NULL")))
98
+
99
+
100
+ def _bool_text(b: str) -> str:
101
+ return f"(CASE WHEN {b} THEN 'TRUE' ELSE 'FALSE' END)"
102
+
103
+
104
+ def txt(e: Expr) -> Expr:
105
+ if e.type == "txt":
106
+ return e
107
+ if e.type == "blank":
108
+ return Expr("''", "txt")
109
+ if e.type == "err":
110
+ return Expr("CAST(NULL AS VARCHAR)", "txt", e.err)
111
+ if e.type == "num":
112
+ return Expr(num_text(e.sql), "txt", e.err)
113
+ if e.type == "bool":
114
+ return Expr(_bool_text(e.sql), "txt", e.err)
115
+ return Expr(f"(CASE {e.kind} WHEN 's' THEN {e.txt} WHEN 'b' THEN {_bool_text(f'{e.sql} <> 0')} "
116
+ f"ELSE {num_text(e.sql)} END)", "txt", e.err)
117
+
118
+
119
+ def _text_bool(t: str) -> str:
120
+ return f"(CASE upper({t}) WHEN 'TRUE' THEN TRUE WHEN 'FALSE' THEN FALSE END)"
121
+
122
+
123
+ def as_bool(e: Expr) -> Expr:
124
+ if e.type == "bool":
125
+ return e
126
+ if e.type == "blank":
127
+ return Expr("FALSE", "bool")
128
+ if e.type == "num":
129
+ return Expr(f"({e.sql} <> 0)", "bool", e.err)
130
+ if e.type == "err":
131
+ return Expr("CAST(NULL AS BOOLEAN)", "bool", e.err)
132
+ if e.type == "txt":
133
+ b = _text_bool(e.sql)
134
+ return Expr(b, "bool", errs(e.err, _value_error(f"{b} IS NULL")))
135
+ b = _text_bool(e.txt)
136
+ return Expr(f"(CASE WHEN {e.kind} = 's' THEN {b} ELSE {e.sql} <> 0 END)", "bool",
137
+ errs(e.err, _value_error(f"{e.kind} = 's' AND {b} IS NULL")))
138
+
139
+
140
+ def as_any(e: Expr) -> Expr:
141
+ """Any value as the run-time-typed form."""
142
+ if e.type == "any":
143
+ return e
144
+ if e.type in ("blank", "num"):
145
+ e = num(e)
146
+ return any_of("'n'", e.sql, "CAST(NULL AS VARCHAR)", e.err)
147
+ if e.type == "txt":
148
+ return any_of("'s'", "CAST(NULL AS DOUBLE)", e.sql, e.err)
149
+ if e.type == "bool":
150
+ return any_of("'b'", f"CAST({e.sql} AS DOUBLE)", "CAST(NULL AS VARCHAR)", e.err)
151
+ return any_of("'n'", "CAST(NULL AS DOUBLE)", "CAST(NULL AS VARCHAR)", e.err) # err
152
+
153
+
154
+ COERCE = {"num": num, "txt": txt, "bool": as_bool, "any": as_any}
155
+ FLEX = ("blank", "err") # types that take on the other side's type
156
+
157
+
158
+ def unify(a: Expr, b: Expr) -> tuple[Expr, Expr]:
159
+ """Give two results one type (IF branches, IFERROR). Different types become 'any', as in Excel."""
160
+ return tuple(unify_all([a, b]))
161
+
162
+
163
+ def unify_all(es: list[Expr]) -> list[Expr]:
164
+ fixed = {e.type for e in es} - set(FLEX)
165
+ if not fixed:
166
+ return [num(e) for e in es]
167
+ target = fixed.pop() if len(fixed) == 1 else "any"
168
+ return [COERCE[target](e) for e in es]
169
+
170
+
171
+ def pick(cond: str, yes: Expr, no: Expr, err: str = NO_ERR) -> Expr:
172
+ """CASE WHEN cond THEN yes ELSE no, for two results of one type (see unify), keeping every part."""
173
+ def case(a: str, b: str) -> str:
174
+ return a if a == b else f"(CASE WHEN {cond} THEN {a} ELSE {b} END)"
175
+ return Expr(case(yes.sql, no.sql), yes.type, err, case(yes.txt, no.txt), case(yes.kind, no.kind))
176
+
177
+
178
+ RANK = {"num": 0, "txt": 1, "bool": 2} # Excel orders numbers < text < TRUE/FALSE across types
179
+
180
+
181
+ def _rank(kind: str) -> str:
182
+ return f"(CASE {kind} WHEN 'n' THEN 0 WHEN 's' THEN 1 ELSE 2 END)"
183
+
184
+
185
+ def _blank_like(other: Expr) -> Expr:
186
+ """An empty cell compared with something acts as 0, "" or FALSE - whatever the other side is."""
187
+ if other.type == "txt":
188
+ return Expr("''", "txt")
189
+ if other.type == "bool":
190
+ return Expr("FALSE", "bool")
191
+ if other.type == "any":
192
+ return any_of(other.kind, "CAST(0 AS DOUBLE)", "''")
193
+ return ZERO
194
+
195
+
196
+ def compare(a: Expr, b: Expr, op: str) -> Expr:
197
+ """Excel comparison: text ignores case; different types compare by type (number < text < logical)."""
198
+ if a.type == "blank" and b.type == "blank":
199
+ a, b = ZERO, ZERO
200
+ elif a.type == "blank":
201
+ a = _blank_like(b)
202
+ elif b.type == "blank":
203
+ b = _blank_like(a)
204
+ if a.type == "err":
205
+ a = num(a)
206
+ if b.type == "err":
207
+ b = num(b)
208
+ err = errs(a.err, b.err)
209
+ if a.type == b.type and a.type != "any":
210
+ if a.type == "txt":
211
+ return Expr(f"(LOWER({a.sql}) {op} LOWER({b.sql}))", "bool", err)
212
+ return Expr(f"({a.sql} {op} {b.sql})", "bool", err)
213
+ if a.type != "any" and b.type != "any":
214
+ return Expr(f"({RANK[a.type]} {op} {RANK[b.type]})", "bool", err)
215
+ a, b = as_any(a), as_any(b)
216
+ ra, rb = _rank(a.kind), _rank(b.kind)
217
+ return Expr(f"(CASE WHEN {ra} <> {rb} THEN {ra} {op} {rb} WHEN {ra} = 1 THEN LOWER({a.txt}) {op} LOWER({b.txt}) "
218
+ f"ELSE {a.sql} {op} {b.sql} END)", "bool", err)
219
+
220
+
221
+ def finalize(e: Expr) -> Expr:
222
+ return ZERO if e.type == "blank" else e # =A1 on an empty cell shows 0 in Excel
223
+
224
+
225
+ def where_cell(key: Key) -> str:
226
+ sheet, row, col = key
227
+ return f"sheet = {lit_str(sheet).sql} AND row_num = {row} AND col_num = {col}"
228
+
229
+
230
+ def where_area(area, alias: str = "") -> str:
231
+ p = f"{alias}." if alias else ""
232
+ return (f"{p}sheet = {lit_str(area.sheet).sql} AND {p}row_num BETWEEN {area.r1} AND {area.r2} "
233
+ f"AND {p}col_num BETWEEN {area.c1} AND {area.c2}")
cellproof/fenwick.py ADDED
@@ -0,0 +1,47 @@
1
+ """Prefix sums over positions 0..n-1 with point updates and k-th lookup, all O(log n)."""
2
+ from __future__ import annotations
3
+
4
+
5
+ class Fenwick:
6
+ def __init__(self, values: list[int]):
7
+ n = len(values)
8
+ self.n = n
9
+ t = [0] + list(values)
10
+ for i in range(1, n + 1): # O(n) build
11
+ j = i + (i & -i)
12
+ if j <= n:
13
+ t[j] += t[i]
14
+ self.t = t
15
+ self.top = 1 << n.bit_length()
16
+
17
+ def add(self, i: int, delta: int) -> None:
18
+ """Add delta to position i."""
19
+ i += 1
20
+ t, n = self.t, self.n
21
+ while i <= n:
22
+ t[i] += delta
23
+ i += i & -i
24
+
25
+ def prefix(self, i: int) -> int:
26
+ """Sum of positions 0..i-1."""
27
+ s, t = 0, self.t
28
+ while i > 0:
29
+ s += t[i]
30
+ i -= i & -i
31
+ return s
32
+
33
+ def range(self, i: int, j: int) -> int:
34
+ """Sum of positions i..j-1."""
35
+ return self.prefix(j) - self.prefix(i)
36
+
37
+ def kth(self, k: int) -> int:
38
+ """0-based position of the k-th unit (k >= 1) when every value is a non-negative count."""
39
+ pos, t, n = 0, self.t, self.n
40
+ step = self.top
41
+ while step:
42
+ nxt = pos + step
43
+ if nxt <= n and t[nxt] < k:
44
+ pos = nxt
45
+ k -= t[nxt]
46
+ step >>= 1
47
+ return pos
@@ -0,0 +1,2 @@
1
+ """The Excel function library. Importing this package registers every function in cellproof.functions.FUNCS."""
2
+ from cellproof.fn import aggregate, criteria, dates, logic, lookup, maths, text # noqa: F401
@@ -0,0 +1,181 @@
1
+ """AVERAGE, MIN, MAX, COUNT, COUNTA, COUNTBLANK, PRODUCT, MEDIAN, SUMPRODUCT, SUBTOTAL.
2
+
3
+ Like SUM: in a range only numbers count (text, TRUE/FALSE and empty cells are skipped); a value typed
4
+ straight into the arguments is converted (AVERAGE(1, "3") = 2). An error in a range is the result.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, replace
9
+
10
+ from cellproof.expr import BLANK, CELLS, NO_ERR, Expr, errs, lit_str, num, where_area
11
+ from cellproof.functions import arity, note_text_numbers, register
12
+ from cellproof.model import Unsupported
13
+ from cellproof.parser import Num, RefNode
14
+ from cellproof.refs import Area, resolve
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Rng:
19
+ area: Area
20
+ keys: object # compiler.Keys
21
+ skip: tuple = () # cells left out (SUBTOTAL leaves out other SUBTOTALs)
22
+
23
+ def where(self, alias: str = "") -> str:
24
+ p = f"{alias}." if alias else ""
25
+ return where_area(self.area, alias) + "".join(
26
+ f" AND NOT ({p}row_num = {r} AND {p}col_num = {col})" for _, r, col in self.skip)
27
+
28
+ def agg(self, fn: str, kinds: str = "('n')") -> str:
29
+ return f"(SELECT {fn} FROM {CELLS} WHERE {self.where()} AND kind IN {kinds})"
30
+
31
+ def err(self, c) -> str:
32
+ if self.skip:
33
+ if not any(c.may_err(k) for k in self.keys if k not in self.skip):
34
+ return NO_ERR
35
+ elif not self.keys.may_err:
36
+ return NO_ERR
37
+ return (f"(SELECT txt FROM {CELLS} WHERE {self.where()} AND kind = 'e' "
38
+ f"ORDER BY row_num, col_num LIMIT 1)")
39
+
40
+
41
+ def _rng(c, node, sheet: str, skip_subtotals: bool = False) -> Rng:
42
+ area = resolve(node.text, sheet, c.book)
43
+ keys = c.ready(area)
44
+ note_text_numbers(c, keys)
45
+ skip = ()
46
+ if skip_subtotals:
47
+ skip = tuple(k for k in keys if "SUBTOTAL(" in (c.book.cells[k].formula or "").upper())
48
+ return Rng(area, keys, skip)
49
+
50
+
51
+ def _split(c, args, sheet: str, skip_subtotals: bool = False) -> tuple[list[Rng], list[Expr]]:
52
+ ranges, values = [], []
53
+ for a in args:
54
+ if isinstance(a, RefNode):
55
+ ranges.append(_rng(c, a, sheet, skip_subtotals))
56
+ else:
57
+ values.append(num(c.expr(a, sheet) if a is not None else BLANK))
58
+ return ranges, values
59
+
60
+
61
+ def _errs(c, ranges, values) -> str:
62
+ # Real Excel: MAX(H3, 1/0) is #DIV/0! although H3 holds #N/A. Typed-in expressions are evaluated before the
63
+ # function reads any cell, so their errors come first; errors inside referenced cells follow, in order.
64
+ return errs(*(v.err for v in values), *(r.err(c) for r in ranges))
65
+
66
+
67
+ def _average(c, ranges, values) -> Expr:
68
+ total = " + ".join([*(r.agg("COALESCE(SUM(num), 0)") for r in ranges), *(v.sql for v in values)]) or "0"
69
+ count = " + ".join([*(r.agg("COUNT(*)") for r in ranges), *("1" for _ in values)]) or "0"
70
+ return Expr(f"(({total}) / NULLIF({count}, 0))", "num",
71
+ errs(_errs(c, ranges, values), f"(CASE WHEN ({count}) = 0 THEN '#DIV/0!' END)"))
72
+
73
+
74
+ def _extreme(c, ranges, values, how: str) -> Expr:
75
+ parts = [*(r.agg(f"{how}(num)") for r in ranges), *(v.sql for v in values)]
76
+ fn = "LEAST" if how == "MIN" else "GREATEST" # both skip NULLs in DuckDB; no numbers at all -> 0, like Excel
77
+ return Expr(f"COALESCE({fn}({', '.join(parts)}), CAST(0 AS DOUBLE))", "num", _errs(c, ranges, values))
78
+
79
+
80
+ def _count(c, ranges, values) -> Expr:
81
+ if values:
82
+ raise Unsupported("COUNT of values typed into the formula not supported yet")
83
+ return Expr("(" + " + ".join(r.agg("CAST(COUNT(*) AS DOUBLE)") for r in ranges) + ")", "num")
84
+
85
+
86
+ def _counta(c, ranges, values) -> Expr:
87
+ parts = [r.agg("CAST(COUNT(*) AS DOUBLE)", "('n', 's', 'b', 'e')") for r in ranges] + ["1" for _ in values]
88
+ return Expr("CAST((" + " + ".join(parts) + ") AS DOUBLE)", "num")
89
+
90
+
91
+ def _product(c, ranges, values) -> Expr:
92
+ parts = [f"COALESCE({r.agg('product(num)')}, 1)" for r in ranges] + [v.sql for v in values]
93
+ count = " + ".join([*(r.agg("COUNT(*)") for r in ranges), *("1" for _ in values)])
94
+ return Expr(f"(CASE WHEN ({count}) = 0 THEN CAST(0 AS DOUBLE) ELSE {' * '.join(parts)} END)", "num",
95
+ _errs(c, ranges, values))
96
+
97
+
98
+ def _sum(c, ranges, values) -> Expr:
99
+ parts = [*(r.agg("COALESCE(SUM(num), 0)") for r in ranges), *(v.sql for v in values)]
100
+ return Expr("(" + " + ".join(parts) + ")" if parts else "CAST(0 AS DOUBLE)", "num", _errs(c, ranges, values))
101
+
102
+
103
+ def _plain(name, how):
104
+ def fn(c, args, sheet):
105
+ if not args:
106
+ raise Unsupported(f"{name} needs arguments")
107
+ ranges, values = _split(c, args, sheet)
108
+ result = how(c, ranges, values)
109
+ if name not in ("COUNT", "COUNTA"):
110
+ result = replace(result, err=errs(_errs(c, ranges, values), result.err))
111
+ return result
112
+ register(name)(fn)
113
+
114
+
115
+ _plain("AVERAGE", _average)
116
+ _plain("MIN", lambda c, r, v: _extreme(c, r, v, "MIN"))
117
+ _plain("MAX", lambda c, r, v: _extreme(c, r, v, "MAX"))
118
+ _plain("COUNT", _count)
119
+ _plain("COUNTA", _counta)
120
+ _plain("PRODUCT", _product)
121
+
122
+
123
+ @register("COUNTBLANK")
124
+ def countblank(c, args, sheet):
125
+ arity("COUNTBLANK", args, 1, 1)
126
+ if not isinstance(args[0], RefNode):
127
+ raise Unsupported("COUNTBLANK needs a cell range")
128
+ r = _rng(c, args[0], sheet)
129
+ a = r.area
130
+ size = (a.r2 - a.r1 + 1) * (a.c2 - a.c1 + 1)
131
+ filled = (f"(SELECT COUNT(*) FROM {CELLS} WHERE {r.where()} "
132
+ f"AND NOT (kind = 's' AND COALESCE(txt, '') = ''))") # "" counts as blank here, like Excel
133
+ return Expr(f"CAST({size} - {filled} AS DOUBLE)", "num")
134
+
135
+
136
+ @register("MEDIAN")
137
+ def median(c, args, sheet):
138
+ if not args or not all(isinstance(a, RefNode) for a in args):
139
+ raise Unsupported("MEDIAN of values typed into the formula not supported yet")
140
+ ranges = [_rng(c, a, sheet) for a in args]
141
+ rows = " UNION ALL ".join(f"SELECT num FROM {CELLS} WHERE {r.where()} AND kind = 'n'" for r in ranges)
142
+ values = f"FROM ({rows}) median_values"
143
+ return Expr(f"(SELECT CAST(median(num) AS DOUBLE) {values})", "num",
144
+ errs(_errs(c, ranges, []), f"(CASE WHEN (SELECT COUNT(*) {values}) = 0 THEN '#NUM!' END)"))
145
+
146
+
147
+ @register("SUMPRODUCT")
148
+ def sumproduct(c, args, sheet):
149
+ if not args or not all(isinstance(a, RefNode) for a in args):
150
+ raise Unsupported("SUMPRODUCT of array expressions not supported yet")
151
+ ranges = [_rng(c, a, sheet) for a in args]
152
+ first = ranges[0].area
153
+ shape = (first.r2 - first.r1, first.c2 - first.c1)
154
+ if any((r.area.r2 - r.area.r1, r.area.c2 - r.area.c1) != shape for r in ranges):
155
+ return Expr("NULL", "num", "'#VALUE!'") # Excel: ranges of different sizes
156
+ joins = "".join(
157
+ f" JOIN {CELLS} a{i} ON a{i}.sheet = {lit_str(r.area.sheet).sql} "
158
+ f"AND a{i}.row_num = a0.row_num + {r.area.r1 - first.r1} AND a{i}.col_num = a0.col_num + {r.area.c1 - first.c1} "
159
+ f"AND a{i}.kind = 'n'" for i, r in enumerate(ranges[1:], 1))
160
+ product = " * ".join(f"a{i}.num" for i in range(len(ranges))) # blanks, text and TRUE/FALSE count as 0
161
+ return Expr(f"(SELECT COALESCE(SUM({product}), CAST(0 AS DOUBLE)) FROM {CELLS} a0{joins} "
162
+ f"WHERE {where_area(first, 'a0')} AND a0.kind = 'n')", "num", _errs(c, ranges, []))
163
+
164
+
165
+ SUBTOTALS = {1: _average, 2: _count, 3: _counta, 4: lambda c, r, v: _extreme(c, r, v, "MAX"),
166
+ 5: lambda c, r, v: _extreme(c, r, v, "MIN"), 6: _product, 9: _sum}
167
+
168
+
169
+ @register("SUBTOTAL")
170
+ def subtotal(c, args, sheet):
171
+ if len(args) < 2 or not isinstance(args[0], Num):
172
+ raise Unsupported("SUBTOTAL needs a function number and ranges")
173
+ code = int(args[0].value)
174
+ if code > 100:
175
+ raise Unsupported("SUBTOTAL that ignores hidden rows not supported yet")
176
+ if code not in SUBTOTALS:
177
+ raise Unsupported(f"SUBTOTAL function {code} not supported yet")
178
+ if not all(isinstance(a, RefNode) for a in args[1:]):
179
+ raise Unsupported("SUBTOTAL needs cell ranges")
180
+ ranges = [_rng(c, a, sheet, skip_subtotals=True) for a in args[1:]]
181
+ return SUBTOTALS[code](c, ranges, [])