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/fn/lookup.py ADDED
@@ -0,0 +1,302 @@
1
+ """VLOOKUP, HLOOKUP, MATCH, INDEX, XLOOKUP, CHOOSE.
2
+
3
+ The value found is read from the cells table. Its type comes from the cells the range holds: one type ->
4
+ that type; mixed, or text with empty cells -> 'any' (known when the SQL runs). An empty cell found reads
5
+ as 0, like Excel. Exact matching of text ignores case and (except XLOOKUP's default) honours wildcards.
6
+
7
+ Every search is an aggregate (arg_min/arg_max by position) over a table that does not depend on the formula's row;
8
+ only the WHERE does. DuckDB plans such a subquery as one join for a whole region, where ORDER BY ... LIMIT 1 or a
9
+ LEFT JOIN on the row's values cannot be planned at all.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from cellproof.expr import CELLS, NO_ERR, Expr, any_of, errs, lit_str, num, unify_all, where_area
14
+ from cellproof.fn.criteria import like_pattern
15
+ from cellproof.functions import arity, register
16
+ from cellproof.model import Unsupported
17
+ from cellproof.parser import Bool, Num, RefNode, Str, Unary
18
+ from cellproof.refs import Area, resolve
19
+
20
+
21
+ def _area(c, node, sheet: str, fname: str) -> tuple[Area, object]:
22
+ if not isinstance(node, RefNode):
23
+ raise Unsupported(f"{fname} needs a cell range")
24
+ area = resolve(node.text, sheet, c.book)
25
+ return area, c.ready(area)
26
+
27
+
28
+ def _type_of(c, area: Area, keys) -> str:
29
+ kinds = set(keys.kinds) - {"e"} # an error found is returned as that error, whatever the type
30
+ full = len(keys) == (area.r2 - area.r1 + 1) * (area.c2 - area.c1 + 1)
31
+ if kinds <= {"n"}:
32
+ return "num" # an empty cell found reads as 0
33
+ if full and kinds == {"s"}:
34
+ return "txt"
35
+ if full and kinds == {"b"}:
36
+ return "bool"
37
+ return "any"
38
+
39
+
40
+ def _read(pick, typ: str, err: str, r: str = "k.r_") -> Expr:
41
+ """The cell found. pick(x, default) is x of that cell, default when the cell is empty, NULL when nothing is
42
+ found; r prefixes the cell's columns."""
43
+ if typ == "num":
44
+ return Expr(pick(f"{r}num", "0"), "num", err)
45
+ if typ == "txt":
46
+ return Expr(pick(f"{r}txt", "''"), "txt", err)
47
+ if typ == "bool":
48
+ return Expr(pick(f"{r}num <> 0", "FALSE"), "bool", err)
49
+ return any_of(pick(f"{r}kind", "'n'"), pick(f"{r}num", "0"), pick(f"{r}txt", "''"), err)
50
+
51
+
52
+ def _found_error(pick, missing: str) -> str:
53
+ """'missing' when nothing is found, the cell's own error when it holds one, else NULL."""
54
+ found = pick("CASE WHEN k.r_kind = 'e' THEN k.r_txt END", "''")
55
+ return f"(CASE WHEN {found} IS NULL THEN '{missing}' WHEN {found} = '' THEN NULL ELSE {found} END)"
56
+
57
+
58
+ def _static_text(node) -> bool:
59
+ return isinstance(node, Str) and not any(ch in node.value for ch in "*?~")
60
+
61
+
62
+ def _exact(k: str, x: Expr, wild: bool) -> str:
63
+ """SQL true when cell `k` equals the lookup value x (text ignores case; wildcards when `wild`)."""
64
+ text = f"lower(COALESCE({k}.txt, ''))"
65
+ t = (f"{text} LIKE {like_pattern(x.txt if x.type == 'any' else x.sql)} ESCAPE '\\'" if wild
66
+ else f"{text} = lower({x.txt if x.type == 'any' else x.sql})")
67
+ if x.type == "num":
68
+ return f"({k}.kind = 'n' AND {k}.num = {x.sql})"
69
+ if x.type == "txt":
70
+ return f"({k}.kind = 's' AND {t})"
71
+ if x.type == "bool":
72
+ return f"({k}.kind = 'b' AND {k}.num = CAST({x.sql} AS DOUBLE))"
73
+ if x.type == "any":
74
+ return (f"(CASE {x.kind} WHEN 'n' THEN {k}.kind = 'n' AND {k}.num = {x.sql} "
75
+ f"WHEN 's' THEN {k}.kind = 's' AND {t} ELSE {k}.kind = 'b' AND {k}.num = {x.sql} END)")
76
+ raise Unsupported(f"lookup of a value of type {x.type}")
77
+
78
+
79
+ def _near(k: str, x: Expr, cmp: str) -> tuple[str, str]:
80
+ """(condition, sort key) for approximate match: values of x's type that are <= x (cmp '<=') or >= x."""
81
+ if x.type == "num":
82
+ return f"({k}.kind = 'n' AND {k}.num {cmp} {x.sql})", f"{k}.num"
83
+ if x.type == "txt":
84
+ return f"({k}.kind = 's' AND lower({k}.txt) {cmp} lower({x.sql}))", f"lower({k}.txt)"
85
+ raise Unsupported("approximate match on this kind of value not supported yet")
86
+
87
+
88
+ def _require_sorted(c, line: Area, descending: bool, fname: str) -> None:
89
+ """Excel's approximate match is a binary search. On data that is not in order its answer depends on the search
90
+ path (real Excel: MATCH(150, {0,100,500}, -1) is #N/A although 500 >= 150), so cellproof refuses to guess.
91
+ The order is judged from the saved values, which the verifier then holds the computed values to."""
92
+ seen = []
93
+ for key in line.cells():
94
+ cell = c.book.cells.get(key)
95
+ if cell is None or cell.kind == "blank":
96
+ continue
97
+ if cell.kind == "e":
98
+ raise Unsupported(f"{fname} approximate match over a range holding an error")
99
+ seen.append((cell.kind, cell.value.lower() if isinstance(cell.value, str) else cell.value))
100
+ if len({kind for kind, _ in seen}) > 1:
101
+ raise Unsupported(f"{fname} approximate match over a range of mixed types")
102
+ values = [v for _, v in seen]
103
+ if not all(a >= b if descending else a <= b for a, b in zip(values, values[1:])):
104
+ raise Unsupported(f"{fname} approximate match needs {'descending' if descending else 'ascending'} sorted data: "
105
+ "on unsorted data Excel's answer depends on its binary search")
106
+
107
+
108
+ class Search:
109
+ """The cell `k` of the 1-D range `line` that a lookup finds. With `back` = (sheet SQL, join condition on k and r)
110
+ also the cell r it returns, read as k.r_kind, k.r_num, k.r_txt (NULL when r is empty)."""
111
+
112
+ def __init__(self, x: Expr, line: Area, mode: str, wild: bool, last: bool | None = None, back=None):
113
+ self.pos = "k.row_num" if line.c1 == line.c2 else "k.col_num"
114
+ table = f"{CELLS} k"
115
+ if back:
116
+ table = (f"(SELECT k.*, r.kind AS r_kind, r.num AS r_num, r.txt AS r_txt FROM {CELLS} k "
117
+ f"LEFT JOIN {CELLS} r ON r.sheet = {back[0]} AND {back[1]} WHERE {where_area(line, 'k')}) k")
118
+ if mode == "exact": # the first match, the last when `last`
119
+ cond, self.agg, self.key = _exact("k", x, wild), "arg_max" if last else "arg_min", self.pos
120
+ elif mode == "le": # largest value <= x; the last of equal ones, as Excel's search on sorted data
121
+ cond, key = _near("k", x, "<=")
122
+ self.agg, self.key = "arg_max", f"({key}, {'-' if last is False else ''}{self.pos})"
123
+ else: # "ge": smallest value >= x, the first of equal ones
124
+ cond, key = _near("k", x, ">=")
125
+ self.agg, self.key = "arg_min", f"({key}, {'-' if last else ''}{self.pos})"
126
+ self.src = f"FROM {table} WHERE {where_area(line, 'k')} AND {cond}"
127
+
128
+ def pick(self, value: str, default: str | None = None) -> str:
129
+ if default is not None: # arg_min skips a NULL value: the cell would look not found
130
+ value = f"COALESCE({value}, {default})"
131
+ return f"(SELECT {self.agg}({value}, {self.key}) {self.src})"
132
+
133
+ def hit(self) -> str:
134
+ return f"(EXISTS (SELECT 1 {self.src}))"
135
+
136
+
137
+ def int_literal(node) -> int | None:
138
+ """A whole number written in the formula (-1 parses as minus applied to 1), else None."""
139
+ if isinstance(node, Num):
140
+ return int(node.value)
141
+ if isinstance(node, Unary) and isinstance(node.operand, Num):
142
+ return -int(node.operand.value)
143
+ return None
144
+
145
+
146
+ def _exact_flag(node) -> bool:
147
+ """VLOOKUP/HLOOKUP 4th argument: FALSE or 0 -> exact; omitted, TRUE or 1 -> approximate."""
148
+ if node is None:
149
+ return True
150
+ if isinstance(node, Bool):
151
+ return not node.value
152
+ if isinstance(node, Num):
153
+ return node.value == 0
154
+ raise Unsupported("lookup match type must be TRUE/FALSE written in the formula")
155
+
156
+
157
+ def _xlookup_x(c, args, sheet) -> Expr:
158
+ x = c.expr(args[0], sheet)
159
+ if x.type == "blank":
160
+ raise Unsupported("lookup of an empty cell not supported yet")
161
+ return x
162
+
163
+
164
+ def _table_lookup(fname: str, by_row: bool):
165
+ def fn(c, args, sheet):
166
+ arity(fname, args, 3, 4)
167
+ exact = len(args) == 4 and _exact_flag(args[3])
168
+ if not isinstance(args[2], Num):
169
+ raise Unsupported(f"{fname} index must be a number written in the formula")
170
+ table, _ = _area(c, args[1], sheet, fname)
171
+ offset = int(args[2].value) - 1
172
+ if by_row:
173
+ line = Area(table.sheet, table.r1, table.c1, table.r2, table.c1)
174
+ target = Area(table.sheet, table.r1, table.c1 + offset, table.r2, table.c1 + offset)
175
+ ok = table.c1 <= target.c1 <= table.c2
176
+ hit = f"r.row_num = k.row_num AND r.col_num = {target.c1}"
177
+ else:
178
+ line = Area(table.sheet, table.r1, table.c1, table.r1, table.c2)
179
+ target = Area(table.sheet, table.r1 + offset, table.c1, table.r1 + offset, table.c2)
180
+ ok = table.r1 <= target.r1 <= table.r2
181
+ hit = f"r.col_num = k.col_num AND r.row_num = {target.r1}"
182
+ if not ok:
183
+ raise Unsupported(f"{fname} {'column' if by_row else 'row'} index outside the table")
184
+ x = _xlookup_x(c, args, sheet)
185
+ if not exact:
186
+ _require_sorted(c, line, False, fname)
187
+ found = Search(x, line, "exact" if exact else "le", wild=exact, back=("k.sheet", hit))
188
+ keys = c.ready(target)
189
+ return _read(found.pick, _type_of(c, target, keys), errs(x.err, _found_error(found.pick, "#N/A")))
190
+ return fn
191
+
192
+
193
+ register("VLOOKUP")(_table_lookup("VLOOKUP", by_row=True))
194
+ register("HLOOKUP")(_table_lookup("HLOOKUP", by_row=False))
195
+
196
+
197
+ def _line(c, node, sheet: str, fname: str) -> Area:
198
+ area, _ = _area(c, node, sheet, fname)
199
+ if area.r1 != area.r2 and area.c1 != area.c2:
200
+ raise Unsupported(f"{fname} needs a single row or column")
201
+ return area
202
+
203
+
204
+ @register("MATCH")
205
+ def match(c, args, sheet):
206
+ arity("MATCH", args, 2, 3)
207
+ kind = 1 if len(args) == 2 or args[2] is None else int_literal(args[2])
208
+ if kind not in (-1, 0, 1):
209
+ raise Unsupported("MATCH type must be -1, 0 or 1 written in the formula")
210
+ line = _line(c, args[1], sheet, "MATCH")
211
+ x = _xlookup_x(c, args, sheet)
212
+ if kind != 0:
213
+ _require_sorted(c, line, kind == -1, "MATCH")
214
+ found = Search(x, line, {0: "exact", 1: "le", -1: "ge"}[kind], wild=True)
215
+ first = line.r1 if line.c1 == line.c2 else line.c1
216
+ return Expr(found.pick(f"CAST({found.pos} - {first} + 1 AS DOUBLE)"), "num",
217
+ errs(x.err, f"(CASE WHEN NOT {found.hit()} THEN '#N/A' END)"))
218
+
219
+
220
+ @register("XLOOKUP")
221
+ def xlookup(c, args, sheet):
222
+ arity("XLOOKUP", args, 3, 6)
223
+ modes = []
224
+ for i, default in ((4, 0), (5, 1)):
225
+ node = args[i] if len(args) > i else None
226
+ mode = default if node is None else int_literal(node)
227
+ if mode is None:
228
+ raise Unsupported("XLOOKUP modes must be numbers written in the formula")
229
+ modes.append(mode)
230
+ match_mode, search_mode = modes
231
+ if match_mode not in (0, -1, 1, 2) or search_mode not in (1, -1):
232
+ raise Unsupported("XLOOKUP binary search modes not supported yet")
233
+ look = _line(c, args[1], sheet, "XLOOKUP")
234
+ back = _line(c, args[2], sheet, "XLOOKUP")
235
+ by_row = look.c1 == look.c2
236
+ if (by_row and (back.c1 != back.c2 or back.r2 - back.r1 != look.r2 - look.r1)) or \
237
+ (not by_row and (back.r1 != back.r2 or back.c2 - back.c1 != look.c2 - look.c1)):
238
+ raise Unsupported("XLOOKUP return range must be one row or column the size of the lookup range")
239
+ x = _xlookup_x(c, args, sheet)
240
+ mode = {0: "exact", 2: "exact", -1: "le", 1: "ge"}[match_mode]
241
+ hit = (f"r.row_num = k.row_num + {back.r1 - look.r1} AND r.col_num = {back.c1}" if by_row
242
+ else f"r.col_num = k.col_num + {back.c1 - look.c1} AND r.row_num = {back.r1}")
243
+ search = Search(x, look, mode, wild=match_mode == 2, last=search_mode == -1, back=(lit_str(back.sheet).sql, hit))
244
+ keys = c.ready(back)
245
+ found = _read(search.pick, _type_of(c, back, keys), errs(x.err, _found_error(search.pick, "#N/A")))
246
+ if len(args) < 4 or args[3] is None:
247
+ return found
248
+ missing = c.expr(args[3], sheet)
249
+ found, missing = unify_all([found, missing])
250
+ hit_sql = search.hit()
251
+ pick_err = f"(CASE WHEN {hit_sql} THEN {found.err} ELSE {missing.err} END)"
252
+
253
+ def case(a: str, b: str) -> str:
254
+ return f"(CASE WHEN {hit_sql} THEN {a} ELSE {b} END)"
255
+ return Expr(case(found.sql, missing.sql), found.type, errs(x.err, pick_err), case(found.txt, missing.txt),
256
+ case(found.kind, missing.kind))
257
+
258
+
259
+ @register("INDEX")
260
+ def index(c, args, sheet):
261
+ arity("INDEX", args, 2, 3)
262
+ area, keys = _area(c, args[0], sheet, "INDEX")
263
+ height, width = area.r2 - area.r1 + 1, area.c2 - area.c1 + 1
264
+ first = num(c.expr(args[1], sheet))
265
+ second = num(c.expr(args[2], sheet)) if len(args) == 3 and args[2] is not None else None
266
+ if second is None:
267
+ if height == 1:
268
+ row, col = Expr("CAST(1 AS DOUBLE)", "num"), first # one row: the number picks the column
269
+ elif width == 1:
270
+ row, col = first, Expr("CAST(1 AS DOUBLE)", "num")
271
+ else:
272
+ raise Unsupported("INDEX of a block needs a row and a column")
273
+ else:
274
+ row, col = first, second
275
+ r = f"(CASE WHEN trunc({row.sql}) BETWEEN 1 AND {height} THEN CAST(trunc({row.sql}) AS BIGINT) END)"
276
+ k = f"(CASE WHEN trunc({col.sql}) BETWEEN 1 AND {width} THEN CAST(trunc({col.sql}) AS BIGINT) END)"
277
+ src = (f"FROM {CELLS} r WHERE r.sheet = {lit_str(area.sheet).sql} "
278
+ f"AND r.row_num = {area.r1} - 1 + {r} AND r.col_num = {area.c1} - 1 + {k}")
279
+
280
+ def pick(value: str, default: str) -> str: # at most one cell matches; none when it is empty
281
+ return f"COALESCE((SELECT max({value}) {src}), {default})"
282
+ bounds = f"(CASE WHEN {r} IS NULL OR {k} IS NULL OR {r} < 1 OR {r} > {height} OR {k} < 1 OR {k} > {width} THEN '#REF!' END)"
283
+ found = f"(SELECT max(CASE WHEN r.kind = 'e' THEN r.txt END) {src})"
284
+ return _read(pick, _type_of(c, area, keys), errs(row.err, col.err, bounds, found), "r.")
285
+
286
+
287
+ @register("CHOOSE")
288
+ def choose(c, args, sheet):
289
+ if len(args) < 2:
290
+ raise Unsupported("CHOOSE needs an index and values")
291
+ i = num(c.expr(args[0], sheet))
292
+ values = unify_all([c.expr(a, sheet) for a in args[1:]])
293
+ n = f"TRY_CAST(trunc({i.sql}) AS BIGINT)"
294
+
295
+ def case(parts: list[str]) -> str:
296
+ if len(set(parts)) == 1:
297
+ return parts[0]
298
+ return "(CASE " + " ".join(f"WHEN {n} = {j} THEN {p}" for j, p in enumerate(parts, 1)) + " END)"
299
+ bad = f"(CASE WHEN {n} IS NULL OR {n} < 1 OR {n} > {len(values)} THEN '#VALUE!' END)"
300
+ err = errs(i.err, bad, case([v.err for v in values]) if any(v.err != NO_ERR for v in values) else NO_ERR)
301
+ return Expr(case([v.sql for v in values]), values[0].type, err, case([v.txt for v in values]),
302
+ case([v.kind for v in values]))
cellproof/fn/maths.py ADDED
@@ -0,0 +1,118 @@
1
+ """ABS, INT, SIGN, PI, SQRT, EXP, LN, LOG10, LOG, POWER, MOD, TRUNC, ROUNDUP, ROUNDDOWN, CEILING, FLOOR, MROUND.
2
+
3
+ DuckDB throws on ln(0) or sqrt(-1), and one throw would stop the whole batch, so every function with a
4
+ domain is written as CASE WHEN <ok> THEN f(x) END plus the Excel error for the rest (a CASE never runs the
5
+ branch it skips, verified on DuckDB 1.5.6). Rounding to a digit first rounds to 15 significant digits, as
6
+ Excel does, so ROUNDUP(0.1*3, 1) is 0.3, not 0.4.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from cellproof.expr import ZERO, Expr, errs, num
11
+ from cellproof.functions import arity, register
12
+ from cellproof.parser import Binary
13
+
14
+
15
+ def _guarded(value: str, bad: str, code: str) -> tuple[str, str]:
16
+ return f"(CASE WHEN NOT ({bad}) THEN {value} END)", f"(CASE WHEN {bad} THEN '{code}' END)"
17
+
18
+
19
+ def _one(name: str, value, bad=None, code: str = "#NUM!"):
20
+ def fn(c, args, sheet):
21
+ arity(name, args, 1, 1)
22
+ x = num(c.expr(args[0], sheet))
23
+ if bad is None:
24
+ return Expr(value(x.sql), "num", x.err)
25
+ sql, err = _guarded(value(x.sql), bad(x.sql), code)
26
+ return Expr(sql, "num", errs(x.err, err))
27
+ register(name)(fn)
28
+
29
+
30
+ _one("ABS", lambda x: f"abs({x})")
31
+ _one("INT", lambda x: f"floor({x})")
32
+ _one("SIGN", lambda x: f"CAST(sign({x}) AS DOUBLE)")
33
+ _one("SQRT", lambda x: f"sqrt({x})", lambda x: f"{x} < 0")
34
+ _one("EXP", lambda x: f"exp({x})", lambda x: f"{x} > 709.782712893384")
35
+ _one("LN", lambda x: f"ln({x})", lambda x: f"{x} <= 0")
36
+ _one("LOG10", lambda x: f"log10({x})", lambda x: f"{x} <= 0")
37
+
38
+
39
+ @register("PI")
40
+ def pi(c, args, sheet):
41
+ arity("PI", args, 0, 0)
42
+ return Expr("pi()", "num")
43
+
44
+
45
+ @register("LOG")
46
+ def log(c, args, sheet):
47
+ arity("LOG", args, 1, 2)
48
+ x = num(c.expr(args[0], sheet))
49
+ base = num(c.expr(args[1], sheet)) if len(args) == 2 and args[1] is not None else Expr("CAST(10 AS DOUBLE)", "num")
50
+ bad = f"(CASE WHEN {x.sql} <= 0 OR {base.sql} <= 0 THEN '#NUM!' WHEN {base.sql} = 1 THEN '#DIV/0!' END)"
51
+ return Expr(f"(CASE WHEN {x.sql} > 0 AND {base.sql} > 0 AND {base.sql} <> 1 THEN ln({x.sql}) / ln({base.sql}) END)",
52
+ "num", errs(x.err, base.err, bad))
53
+
54
+
55
+ @register("POWER")
56
+ def power(c, args, sheet):
57
+ arity("POWER", args, 2, 2)
58
+ return c.binary(Binary("^", args[0], args[1]), sheet)
59
+
60
+
61
+ def _two(c, args, sheet, name: str, default=None) -> tuple[Expr, Expr]:
62
+ arity(name, args, 1 if default is not None else 2, 2)
63
+ x = num(c.expr(args[0], sheet))
64
+ y = num(c.expr(args[1], sheet)) if len(args) == 2 and args[1] is not None else (default or ZERO)
65
+ return x, y
66
+
67
+
68
+ def xl15(v: str) -> str:
69
+ """v rounded to 15 significant digits, the precision Excel works at."""
70
+ return f"(CASE WHEN {v} = 0 THEN 0 WHEN NOT isfinite({v}) THEN {v} ELSE round({v}, 14 - CAST(floor(log10(abs({v}))) AS INTEGER)) END)"
71
+
72
+
73
+ @register("MOD")
74
+ def mod(c, args, sheet):
75
+ x, y = _two(c, args, sheet, "MOD")
76
+ # Real Excel: MOD(1, 0.1) = 0.09999999999999995, the exact floating-point remainder, not x - y*floor(x/y).
77
+ # DuckDB's % is the C remainder (sign of the dividend); its fmod() function is not, so it is not used.
78
+ rem = f"({x.sql} % {y.sql})"
79
+ sql, err = _guarded(f"(CASE WHEN {rem} <> 0 AND ({rem} < 0) <> ({y.sql} < 0) THEN {rem} + {y.sql} ELSE {rem} END)",
80
+ f"{y.sql} = 0", "#DIV/0!")
81
+ return Expr(sql, "num", errs(x.err, y.err, err)) # Excel: the result takes the divisor's sign
82
+
83
+
84
+ def _digits(name: str, rnd: str):
85
+ def fn(c, args, sheet):
86
+ x, d = _two(c, args, sheet, name, default=ZERO if name == "TRUNC" else None)
87
+ p = f"power(10, trunc({d.sql}))"
88
+ # shortcut: scaling outside DOUBLE gives #NUM!, revisit with Excel boundary fixtures.
89
+ scaled = f"abs({x.sql}) * {p}"
90
+ ok = f"(isfinite({p}) AND {p} > 0 AND isfinite({scaled}))"
91
+ sql = f"(CASE WHEN {d.sql} > 308 THEN {x.sql} WHEN {ok} THEN sign({x.sql}) * {rnd}({xl15(scaled)}) / {p} END)"
92
+ bad = f"(CASE WHEN {d.sql} <= 308 AND NOT {ok} THEN '#NUM!' END)"
93
+ return Expr(sql, "num", errs(x.err, d.err, bad))
94
+ register(name)(fn)
95
+
96
+
97
+ _digits("ROUNDUP", "ceil")
98
+ _digits("ROUNDDOWN", "floor")
99
+ _digits("TRUNC", "floor")
100
+
101
+
102
+ def _multiple(name: str, rnd: str, zero_code: str | None):
103
+ def fn(c, args, sheet):
104
+ x, m = _two(c, args, sheet, name)
105
+ bad = f"({x.sql} > 0 AND {m.sql} < 0)" if name != "MROUND" else f"({x.sql} * {m.sql} < 0)"
106
+ if zero_code:
107
+ bad = f"({bad} OR {m.sql} = 0)"
108
+ value = f"(CASE WHEN {m.sql} = 0 THEN 0 ELSE {rnd}({xl15(f'{x.sql} / {m.sql}')}) * {m.sql} END)"
109
+ sql, _ = _guarded(value, bad, "#NUM!")
110
+ code = (f"(CASE WHEN {m.sql} = 0 THEN '{zero_code}' WHEN {bad} THEN '#NUM!' END)" if zero_code
111
+ else f"(CASE WHEN {bad} THEN '#NUM!' END)")
112
+ return Expr(sql, "num", errs(x.err, m.err, code))
113
+ register(name)(fn)
114
+
115
+
116
+ _multiple("CEILING", "ceil", None)
117
+ _multiple("FLOOR", "floor", "#DIV/0!")
118
+ _multiple("MROUND", "round", None)
cellproof/fn/text.py ADDED
@@ -0,0 +1,257 @@
1
+ """CONCATENATE, CONCAT, TEXTJOIN, LEFT, RIGHT, MID, LEN, TRIM, UPPER, LOWER, PROPER, SUBSTITUTE, REPLACE,
2
+ FIND, SEARCH, REPT, VALUE, TEXT, EXACT, CHAR, CLEAN.
3
+
4
+ Numbers become text the way Excel writes them (expr.num_text: 15 significant digits). Positions are 1-based
5
+ and counted in characters, as in Excel. TEXT supports the number formats in NUMBER_FORMATS; dates.py adds
6
+ date formats to FORMATS.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from cellproof.expr import BLANK, CELLS, NO_ERR, Expr, errs, num, num_text, txt, where_area
11
+ from cellproof.functions import arity, register
12
+ from cellproof.model import Unsupported
13
+ from cellproof.parser import Bool, RefNode, Str
14
+ from cellproof.refs import resolve
15
+
16
+
17
+ def _t(c, node, sheet: str) -> Expr:
18
+ return txt(c.expr(node, sheet) if node is not None else BLANK)
19
+
20
+
21
+ def _n(c, node, sheet: str) -> Expr:
22
+ return num(c.expr(node, sheet) if node is not None else BLANK)
23
+
24
+
25
+ def _bad(cond: str) -> str:
26
+ return f"(CASE WHEN {cond} THEN '#VALUE!' END)"
27
+
28
+
29
+ CELL_TEXT = ("(CASE kind WHEN 's' THEN COALESCE(txt, '') WHEN 'b' THEN (CASE WHEN num <> 0 THEN 'TRUE' ELSE 'FALSE' END) "
30
+ f"ELSE {num_text('num')} END)")
31
+
32
+
33
+ def _range_texts(c, node, sheet: str) -> tuple[str, str, int]:
34
+ """(SQL list of the range's cell texts in row order, first error, number of empty cells in the range)."""
35
+ area = resolve(node.text, sheet, c.book)
36
+ keys = c.ready(area)
37
+ where = f"FROM {CELLS} WHERE {where_area(area)}"
38
+ err = NO_ERR
39
+ if keys.may_err:
40
+ err = f"(SELECT txt {where} AND kind = 'e' ORDER BY row_num, col_num LIMIT 1)"
41
+ blanks = (area.r2 - area.r1 + 1) * (area.c2 - area.c1 + 1) - len(keys)
42
+ return (f"(SELECT COALESCE(list({CELL_TEXT} ORDER BY row_num, col_num), []) {where} AND kind <> 'e')", err,
43
+ blanks)
44
+
45
+
46
+ @register("CONCATENATE")
47
+ def concatenate(c, args, sheet):
48
+ if not args:
49
+ raise Unsupported("CONCATENATE needs arguments")
50
+ parts = [_t(c, a, sheet) for a in args]
51
+ return Expr("(" + " || ".join(p.sql for p in parts) + ")", "txt", errs(*(p.err for p in parts)))
52
+
53
+
54
+ def _pieces(c, args, sheet: str, keep_blanks: bool) -> tuple[list[str], list[str]]:
55
+ lists, err = [], []
56
+ for a in args:
57
+ if isinstance(a, RefNode) and not resolve(a.text, sheet, c.book).is_single:
58
+ items, e, blanks = _range_texts(c, a, sheet)
59
+ if keep_blanks and blanks:
60
+ raise Unsupported("TEXTJOIN keeping the empty cells of a range not supported yet")
61
+ lists.append(items)
62
+ err.append(e)
63
+ else:
64
+ p = _t(c, a, sheet)
65
+ lists.append(f"[{p.sql}]")
66
+ err.append(p.err)
67
+ return lists, err
68
+
69
+
70
+ @register("CONCAT")
71
+ def concat(c, args, sheet):
72
+ if not args:
73
+ raise Unsupported("CONCAT needs arguments")
74
+ lists, err = _pieces(c, args, sheet, keep_blanks=False)
75
+ return Expr(f"array_to_string(list_concat({', '.join(lists)}), '')" if len(lists) > 1
76
+ else f"array_to_string({lists[0]}, '')", "txt", errs(*err))
77
+
78
+
79
+ @register("TEXTJOIN")
80
+ def textjoin(c, args, sheet):
81
+ if len(args) < 3 or not isinstance(args[1], Bool):
82
+ raise Unsupported("TEXTJOIN needs a delimiter, TRUE/FALSE written in the formula, and values")
83
+ delim = _t(c, args[0], sheet)
84
+ lists, err = _pieces(c, args[2:], sheet, keep_blanks=not args[1].value)
85
+ items = f"list_concat({', '.join(lists)})" if len(lists) > 1 else lists[0]
86
+ if args[1].value:
87
+ items = f"list_filter({items}, x -> x <> '')"
88
+ return Expr(f"array_to_string({items}, {delim.sql})", "txt", errs(delim.err, *err))
89
+
90
+
91
+ def _count_arg(c, args, sheet: str, i: int) -> Expr:
92
+ if len(args) > i and args[i] is not None:
93
+ return _n(c, args[i], sheet)
94
+ return Expr("CAST(1 AS DOUBLE)", "num")
95
+
96
+
97
+ def _side(name: str, fn: str):
98
+ def impl(c, args, sheet):
99
+ arity(name, args, 1, 2)
100
+ t, n = _t(c, args[0], sheet), _count_arg(c, args, sheet, 1)
101
+ return Expr(f"{fn}({t.sql}, CAST(trunc(least(greatest({n.sql}, 0), length({t.sql}))) AS BIGINT))", "txt",
102
+ errs(t.err, n.err, _bad(f"{n.sql} < 0")))
103
+ register(name)(impl)
104
+
105
+
106
+ _side("LEFT", "left")
107
+ _side("RIGHT", "right")
108
+
109
+
110
+ @register("MID")
111
+ def mid(c, args, sheet):
112
+ arity("MID", args, 3, 3)
113
+ t, start, n = _t(c, args[0], sheet), _n(c, args[1], sheet), _n(c, args[2], sheet)
114
+ return Expr(f"substr({t.sql}, CAST(trunc(least(greatest({start.sql}, 1), length({t.sql}) + 1)) AS BIGINT), "
115
+ f"CAST(trunc(least(greatest({n.sql}, 0), length({t.sql}))) AS BIGINT))", "txt",
116
+ errs(t.err, start.err, n.err, _bad(f"{start.sql} < 1 OR {n.sql} < 0")))
117
+
118
+
119
+ def _text1(name: str, sql, typ: str = "txt"):
120
+ def impl(c, args, sheet):
121
+ arity(name, args, 1, 1)
122
+ t = _t(c, args[0], sheet)
123
+ return Expr(sql(t.sql), typ, t.err)
124
+ register(name)(impl)
125
+
126
+
127
+ _text1("LEN", lambda t: f"CAST(length({t}) AS DOUBLE)", "num")
128
+ _text1("TRIM", lambda t: f"regexp_replace(trim({t}, ' '), ' +', ' ', 'g')") # Excel also squeezes inner spaces
129
+ _text1("UPPER", lambda t: f"upper({t})")
130
+ _text1("LOWER", lambda t: f"lower({t})")
131
+ _text1("PROPER", lambda t: (f"array_to_string(list_transform(range(length({t})), i -> CASE WHEN i = 0 OR NOT "
132
+ f"regexp_matches({t}[i], '[[:alpha:]]') THEN upper({t}[i + 1]) ELSE lower({t}[i + 1]) END), '')"))
133
+ _text1("CLEAN", lambda t: f"regexp_replace({t}, '[\\x00-\\x1f]', '', 'g')")
134
+
135
+
136
+ @register("SUBSTITUTE")
137
+ def substitute(c, args, sheet):
138
+ arity("SUBSTITUTE", args, 3, 4)
139
+ t, old, new = (_t(c, a, sheet) for a in args[:3])
140
+ if len(args) == 3:
141
+ return Expr(f"(CASE WHEN {old.sql} = '' THEN {t.sql} ELSE replace({t.sql}, {old.sql}, {new.sql}) END)", "txt",
142
+ errs(t.err, old.err, new.err))
143
+ n = _n(c, args[3], sheet)
144
+ parts = f"string_split({t.sql}, {old.sql})"
145
+ k = f"CAST(trunc(least(greatest({n.sql}, 0), len({parts}))) AS BIGINT)"
146
+ return Expr(f"(CASE WHEN {old.sql} = '' OR len({parts}) <= {k} THEN {t.sql} ELSE "
147
+ f"array_to_string({parts}[1:{k}], {old.sql}) || {new.sql} || array_to_string({parts}[{k} + 1:], {old.sql}) END)",
148
+ "txt", errs(t.err, old.err, new.err, n.err, _bad(f"{n.sql} < 1")))
149
+
150
+
151
+ @register("REPLACE")
152
+ def replace_(c, args, sheet):
153
+ arity("REPLACE", args, 4, 4)
154
+ t, start, n, new = _t(c, args[0], sheet), _n(c, args[1], sheet), _n(c, args[2], sheet), _t(c, args[3], sheet)
155
+ s = f"CAST(trunc(least(greatest({start.sql}, 1), length({t.sql}) + 1)) AS BIGINT)"
156
+ k = f"CAST(trunc(least(greatest({n.sql}, 0), length({t.sql}))) AS BIGINT)"
157
+ return Expr(f"(substr({t.sql}, 1, {s} - 1) || {new.sql} || substr({t.sql}, {s} + {k}))", "txt",
158
+ errs(t.err, start.err, n.err, new.err, _bad(f"{start.sql} < 1 OR {n.sql} < 0")))
159
+
160
+
161
+ def _find(name: str, sensitive: bool):
162
+ def impl(c, args, sheet):
163
+ arity(name, args, 2, 3)
164
+ what, within = _t(c, args[0], sheet), _t(c, args[1], sheet)
165
+ start = _count_arg(c, args, sheet, 2)
166
+ s = f"TRY_CAST(trunc({start.sql}) AS BIGINT)"
167
+ rest = f"substr({within.sql}, {s})"
168
+ if sensitive:
169
+ pos = f"strpos({rest}, {what.sql})"
170
+ found = f"{pos} > 0"
171
+ else: # SEARCH: ignores case, * and ? are wildcards, ~ escapes them
172
+ pat = f"regexp_escape(lower({what.sql}))" # escaped: * -> \\*, ? -> \\?, ~ -> \\~
173
+ for a, b in (("'\\~\\*'", "chr(1)"), ("'\\~\\?'", "chr(2)"), ("'\\*'", "'.*'"), ("'\\?'", "'.'"),
174
+ ("chr(1)", "'\\*'"), ("chr(2)", "'\\?'")):
175
+ pat = f"replace({pat}, {a}, {b})"
176
+ found = f"regexp_matches(lower({rest}), {pat})"
177
+ pos = f"(length(regexp_extract(lower({rest}), '(?s)^(.*?)(' || {pat} || ')', 1)) + 1)"
178
+ bad = f"{s} IS NULL OR {s} < 1 OR {s} > length({within.sql}) + 1 OR NOT ({found})"
179
+ return Expr(f"(CASE WHEN NOT ({bad}) THEN CAST({pos} + {s} - 1 AS DOUBLE) END)", "num",
180
+ errs(what.err, within.err, start.err, _bad(bad)))
181
+ register(name)(impl)
182
+
183
+
184
+ _find("FIND", sensitive=True)
185
+ _find("SEARCH", sensitive=False)
186
+
187
+
188
+ @register("REPT")
189
+ def rept(c, args, sheet):
190
+ arity("REPT", args, 2, 2)
191
+ t, n = _t(c, args[0], sheet), _n(c, args[1], sheet)
192
+ bad = f"({n.sql} < 0 OR length({t.sql}) * trunc({n.sql}) > 32767)"
193
+ sql = (f"(CASE WHEN NOT {bad} THEN CASE WHEN {t.sql} = '' THEN '' "
194
+ f"ELSE repeat({t.sql}, CAST(trunc({n.sql}) AS BIGINT)) END END)")
195
+ return Expr(sql, "txt", errs(t.err, n.err, _bad(bad)))
196
+
197
+
198
+ @register("VALUE")
199
+ def value(c, args, sheet):
200
+ arity("VALUE", args, 1, 1)
201
+ return num(c.expr(args[0], sheet))
202
+
203
+
204
+ @register("EXACT")
205
+ def exact(c, args, sheet):
206
+ arity("EXACT", args, 2, 2)
207
+ a, b = _t(c, args[0], sheet), _t(c, args[1], sheet)
208
+ return Expr(f"({a.sql} = {b.sql})", "bool", errs(a.err, b.err))
209
+
210
+
211
+ CP1252 = bytes(range(128, 160)).decode("cp1252", errors="replace")
212
+
213
+
214
+ @register("CHAR")
215
+ def char(c, args, sheet):
216
+ arity("CHAR", args, 1, 1)
217
+ n = _n(c, args[0], sheet)
218
+ k = f"TRY_CAST(trunc({n.sql}) AS INTEGER)"
219
+ table = "'" + CP1252.replace("'", "''") + "'"
220
+ return Expr(f"(CASE WHEN {k} BETWEEN 128 AND 159 THEN substr({table}, {k} - 127, 1) "
221
+ f"WHEN {k} BETWEEN 1 AND 255 THEN chr({k}) END)", "txt",
222
+ errs(n.err, _bad(f"COALESCE({k} < 1 OR {k} > 255, TRUE)"))) # Windows-1252, the character set of Excel for Windows
223
+
224
+
225
+ def _fixed(decimals: int, grouped: bool, percent: bool):
226
+ def sql(x: str) -> str:
227
+ v = f"round({x} * 100, {decimals})" if percent else f"round({x}, {decimals})"
228
+ v = f"(CASE WHEN {v} = 0 THEN CAST(0 AS DOUBLE) ELSE {v} END)" # real Excel: TEXT(-0.001,"0.00") is "0.00", not "-0.00"
229
+ body = f"format('{{:,.{decimals}f}}', {v})" if grouped else f"printf('%.{decimals}f', {v})"
230
+ return f"({body} || '%')" if percent else body
231
+ return sql
232
+
233
+
234
+ NUMBER_FORMATS = {"General": num_text}
235
+ for d in range(5):
236
+ tail = "." + "0" * d if d else ""
237
+ NUMBER_FORMATS[f"0{tail}"] = _fixed(d, False, False)
238
+ NUMBER_FORMATS[f"#,##0{tail}"] = _fixed(d, True, False)
239
+ NUMBER_FORMATS[f"0{tail}%"] = _fixed(d, False, True)
240
+ FORMATS = dict(NUMBER_FORMATS) # dates.py adds the date formats
241
+
242
+
243
+ @register("TEXT")
244
+ def text(c, args, sheet):
245
+ arity("TEXT", args, 2, 2)
246
+ if not isinstance(args[1], Str):
247
+ raise Unsupported("TEXT format must be text written in the formula")
248
+ fmt = FORMATS.get(args[1].value) or FORMATS.get(args[1].value.lower())
249
+ if fmt is None:
250
+ raise Unsupported(f"TEXT format {args[1].value!r} not supported yet")
251
+ if getattr(fmt, "is_date", False) and c.book.date1904:
252
+ raise Unsupported("1904 date system not supported yet")
253
+ x = c.expr(args[0], sheet)
254
+ if x.type == "txt": # TEXT("abc", "0.00") passes text through
255
+ return x
256
+ x = num(x)
257
+ return Expr(fmt(x.sql), "txt", x.err)