cellproof 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cellproof/__init__.py +1 -0
- cellproof/__main__.py +3 -0
- cellproof/cli.py +53 -0
- cellproof/compiler.py +408 -0
- cellproof/compute.py +62 -0
- cellproof/emitter.py +160 -0
- cellproof/expr.py +233 -0
- cellproof/fenwick.py +47 -0
- cellproof/fn/__init__.py +2 -0
- cellproof/fn/aggregate.py +181 -0
- cellproof/fn/criteria.py +188 -0
- cellproof/fn/dates.py +171 -0
- cellproof/fn/logic.py +131 -0
- cellproof/fn/lookup.py +302 -0
- cellproof/fn/maths.py +118 -0
- cellproof/fn/text.py +257 -0
- cellproof/functions.py +114 -0
- cellproof/graph.py +150 -0
- cellproof/model.py +72 -0
- cellproof/parser.py +172 -0
- cellproof/r1c1.py +202 -0
- cellproof/rangeindex.py +97 -0
- cellproof/reader.py +83 -0
- cellproof/refs.py +58 -0
- cellproof/region.py +392 -0
- cellproof/regions.py +248 -0
- cellproof/report.py +51 -0
- cellproof/verifier.py +62 -0
- cellproof-0.2.0.dist-info/METADATA +177 -0
- cellproof-0.2.0.dist-info/RECORD +33 -0
- cellproof-0.2.0.dist-info/WHEEL +4 -0
- cellproof-0.2.0.dist-info/entry_points.txt +2 -0
- cellproof-0.2.0.dist-info/licenses/LICENSE +21 -0
cellproof/fn/criteria.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""COUNTIF, COUNTIFS, SUMIF, SUMIFS, AVERAGEIF, AVERAGEIFS, MINIFS, MAXIFS.
|
|
2
|
+
|
|
3
|
+
A criterion is a number (matches equal numbers, and text that reads as that number), TRUE/FALSE, or text
|
|
4
|
+
that may start with =, <>, <, >, <= or >= and may use the wildcards * and ? (~ escapes them). Text criteria
|
|
5
|
+
can be built while the sheet runs (">"&B1), so they are parsed in SQL, not in Python.
|
|
6
|
+
Empty cells have no row in the cells table. They match "" and "=" and "<>x", so they are counted from
|
|
7
|
+
the range's size.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from cellproof.expr import CELLS, NO_ERR, Expr, errs, lit_str, parse_num, txt, where_area
|
|
12
|
+
from cellproof.functions import arity, note_text_numbers, register
|
|
13
|
+
from cellproof.model import Unsupported
|
|
14
|
+
from cellproof.parser import RefNode
|
|
15
|
+
from cellproof.refs import Area, resolve
|
|
16
|
+
|
|
17
|
+
OPS = (">=", "<=", "<>", ">", "<", "=")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def like_pattern(rhs: str) -> str:
|
|
21
|
+
"""Excel wildcards -> a LIKE pattern (ESCAPE '\\'): * -> %, ? -> _, ~* and ~? are literal."""
|
|
22
|
+
p = f"lower({rhs})"
|
|
23
|
+
for a, b in (("'\\'", "'\\\\'"), ("'%'", "'\\%'"), ("'_'", "'\\_'"), ("'~*'", "chr(1)"), ("'~?'", "chr(2)"),
|
|
24
|
+
("'*'", "'%'"), ("'?'", "'_'"), ("chr(1)", "'*'"), ("chr(2)", "'?'")):
|
|
25
|
+
p = f"replace({p}, {a}, {b})"
|
|
26
|
+
return p
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def match_sql(alias: str, crit: Expr, q: str) -> tuple[str, str, str]:
|
|
30
|
+
"""(SQL true when cell `alias` meets the criterion, SQL true when an empty cell does, a one-row table `q`).
|
|
31
|
+
|
|
32
|
+
A text criterion is parsed once, in the one-row table `q` the caller puts in its FROM clause:
|
|
33
|
+
repeating the criterion SQL inside every comparison overflowed DuckDB's expression depth.
|
|
34
|
+
"""
|
|
35
|
+
if crit.err != NO_ERR:
|
|
36
|
+
raise Unsupported("criteria that can be an error not supported yet")
|
|
37
|
+
if crit.type == "blank":
|
|
38
|
+
raise Unsupported("criteria from an empty cell not supported yet")
|
|
39
|
+
k, n, t = f"{alias}.kind", f"{alias}.num", f"COALESCE({alias}.txt, '')" # the seed CSV reads '' back as NULL
|
|
40
|
+
if crit.type == "num":
|
|
41
|
+
return f"COALESCE(({k} = 'n' AND {n} = {crit.sql}) OR ({k} = 's' AND {parse_num(t)} = {crit.sql}), FALSE)", \
|
|
42
|
+
"FALSE", f"(SELECT 1 AS one) {q}"
|
|
43
|
+
if crit.type == "bool":
|
|
44
|
+
return f"COALESCE({k} = 'b' AND {n} = CAST({crit.sql} AS DOUBLE), FALSE)", "FALSE", f"(SELECT 1 AS one) {q}"
|
|
45
|
+
ops = "(CASE " + " ".join(f"WHEN c LIKE '{o}%' THEN '{o}'" for o in OPS) + " ELSE '' END)"
|
|
46
|
+
parsed = (f"(SELECT op, rhs, {parse_num('rhs')} AS rn, "
|
|
47
|
+
f"(CASE upper(rhs) WHEN 'TRUE' THEN 1.0 WHEN 'FALSE' THEN 0.0 END) AS rb, {like_pattern('rhs')} AS pat "
|
|
48
|
+
f"FROM (SELECT op, substr(c, length(op) + 1) AS rhs FROM (SELECT c, {ops} AS op "
|
|
49
|
+
f"FROM (SELECT {txt(crit).sql} AS c)))) {q}")
|
|
50
|
+
op, rhs, rn, rb = f"{q}.op", f"{q}.rhs", f"{q}.rn", f"{q}.rb"
|
|
51
|
+
|
|
52
|
+
def order(a: str, b: str) -> str:
|
|
53
|
+
return " ".join(["(CASE", *(f"WHEN {op} = '{o}' THEN {a} {o} {b}" for o in (">", "<", ">=", "<=")), "END)"])
|
|
54
|
+
|
|
55
|
+
eq = (f"COALESCE(CASE WHEN {rn} IS NOT NULL THEN ({k} = 'n' AND {n} = {rn}) OR ({k} = 's' AND {parse_num(t)} = {rn}) "
|
|
56
|
+
f"WHEN {rb} IS NOT NULL THEN {k} = 'b' AND {n} = {rb} "
|
|
57
|
+
f"ELSE {k} = 's' AND lower({t}) LIKE {q}.pat ESCAPE '\\' END, FALSE)")
|
|
58
|
+
ordered = (f"COALESCE(CASE WHEN {rn} IS NOT NULL THEN {k} = 'n' AND {order(n, rn)} "
|
|
59
|
+
f"WHEN {rb} IS NOT NULL THEN {k} = 'b' AND {order(n, rb)} "
|
|
60
|
+
f"ELSE {k} = 's' AND {order(f'lower({t})', f'lower({rhs})')} END, FALSE)")
|
|
61
|
+
# Real Excel: "<>5" skips only the number 5. The text "5" matches =5 but still counts as "<>5".
|
|
62
|
+
same = (f"COALESCE(CASE WHEN {rn} IS NOT NULL THEN {k} = 'n' AND {n} = {rn} "
|
|
63
|
+
f"WHEN {rb} IS NOT NULL THEN {k} = 'b' AND {n} = {rb} "
|
|
64
|
+
f"ELSE {k} = 's' AND lower({t}) LIKE {q}.pat ESCAPE '\\' END, FALSE)")
|
|
65
|
+
# Real Excel: "<>" alone counts every cell that exists, including a formula that returns "".
|
|
66
|
+
match = (f"(CASE WHEN {op} = '<>' AND {rhs} = '' THEN TRUE WHEN {op} = '<>' THEN NOT {same} "
|
|
67
|
+
f"WHEN {op} IN ('', '=') THEN {eq} ELSE {ordered} END)")
|
|
68
|
+
blank = f"(({op} IN ('', '=') AND {rhs} = '') OR ({op} = '<>' AND {rhs} <> ''))"
|
|
69
|
+
return match, blank, parsed
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _range(c, node, sheet: str, fname: str) -> tuple[Area, object]:
|
|
73
|
+
if not isinstance(node, RefNode):
|
|
74
|
+
raise Unsupported(f"{fname} needs a cell range")
|
|
75
|
+
area = resolve(node.text, sheet, c.book)
|
|
76
|
+
return area, c.ready(area)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _column(c, node, sheet: str, fname: str, height: int | None = None) -> tuple[Area, object]:
|
|
80
|
+
area, keys = _range(c, node, sheet, fname)
|
|
81
|
+
if area.c1 != area.c2 or (height is not None and area.r2 - area.r1 != height):
|
|
82
|
+
raise Unsupported(f"{fname} ranges must be single columns of the same height")
|
|
83
|
+
return area, keys
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _pairs(c, args, sheet: str, fname: str, height: int | None) -> list[tuple[Area, Expr]]:
|
|
87
|
+
pairs = []
|
|
88
|
+
for i in range(0, len(args), 2):
|
|
89
|
+
area, _ = _column(c, args[i], sheet, fname, height)
|
|
90
|
+
height = area.r2 - area.r1
|
|
91
|
+
pairs.append((area, c.expr(args[i + 1], sheet)))
|
|
92
|
+
return pairs
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _joined(pairs, base: str, base_r1) -> tuple[str, str, str]:
|
|
96
|
+
"""(one-row criteria tables, LEFT JOINs of each criteria range to the driving rows, AND of conditions, AND of blanks)."""
|
|
97
|
+
parsed, joins, conds, blanks = [], [], [], []
|
|
98
|
+
for i, (area, crit) in enumerate(pairs, 1):
|
|
99
|
+
k = f"k{i}"
|
|
100
|
+
joins.append(f"LEFT JOIN {CELLS} {k} ON {k}.sheet = {lit_str(area.sheet).sql} "
|
|
101
|
+
f"AND {k}.col_num = {area.c1} AND {k}.row_num = {base} + {area.r1 - base_r1}")
|
|
102
|
+
match, blank, source = match_sql(k, crit, f"q{i}")
|
|
103
|
+
parsed.append(source)
|
|
104
|
+
conds.append(f"(CASE WHEN {k}.kind IS NULL THEN {blank} ELSE {match} END)")
|
|
105
|
+
blanks.append(blank)
|
|
106
|
+
return parsed, " ".join(joins), " AND ".join(conds), " AND ".join(blanks)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@register("COUNTIF")
|
|
110
|
+
def countif(c, args, sheet):
|
|
111
|
+
arity("COUNTIF", args, 2, 2)
|
|
112
|
+
area, _ = _range(c, args[0], sheet, "COUNTIF")
|
|
113
|
+
match, blank, source = match_sql("x", c.expr(args[1], sheet), "q1")
|
|
114
|
+
size = (area.r2 - area.r1 + 1) * (area.c2 - area.c1 + 1)
|
|
115
|
+
inside = f"FROM {CELLS} x WHERE {where_area(area, 'x')}"
|
|
116
|
+
return Expr(f"(SELECT (SELECT COUNT(*) FILTER (WHERE {match}) {inside}) "
|
|
117
|
+
f"+ CASE WHEN {blank} THEN {size} - (SELECT COUNT(*) {inside}) ELSE 0 END FROM {source})", "num")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@register("COUNTIFS")
|
|
121
|
+
def countifs(c, args, sheet):
|
|
122
|
+
if not args or len(args) % 2:
|
|
123
|
+
raise Unsupported("COUNTIFS needs criteria pairs")
|
|
124
|
+
pairs = _pairs(c, args, sheet, "COUNTIFS", None)
|
|
125
|
+
height = pairs[0][0].r2 - pairs[0][0].r1 + 1
|
|
126
|
+
rows = " UNION ".join(f"SELECT row_num - {a.r1} AS off FROM {CELLS} WHERE {where_area(a)}" for a, _ in pairs)
|
|
127
|
+
tables, joins, cond, blanks = _joined(pairs, "o.off", 0)
|
|
128
|
+
o = f"({rows}) o" # a derived table, not a CTE: DuckDB cannot bind a CTE inside a subquery on a row's value
|
|
129
|
+
return Expr(f"(SELECT (SELECT COUNT(*) FILTER (WHERE {cond}) FROM {o} {joins}) "
|
|
130
|
+
f"+ CASE WHEN {blanks} THEN {height} - (SELECT COUNT(*) FROM {o}) ELSE 0 END FROM {', '.join(tables)})", "num")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _aggregate(c, target: Area, pairs, how: str) -> Expr:
|
|
134
|
+
"""SUM / AVERAGE / MIN / MAX of the target cells whose rows meet every criterion."""
|
|
135
|
+
keys = c.ready(target)
|
|
136
|
+
note_text_numbers(c, keys)
|
|
137
|
+
tables, joins, cond, _ = _joined(pairs, "s.row_num", target.r1)
|
|
138
|
+
crossed = " ".join(f"CROSS JOIN {t}" for t in tables)
|
|
139
|
+
src = f"FROM {CELLS} s {crossed} {joins} WHERE {where_area(target, 's')} AND {cond}"
|
|
140
|
+
numbers = "FILTER (WHERE s.kind = 'n')"
|
|
141
|
+
err = NO_ERR
|
|
142
|
+
if keys.may_err: # a matched error cell makes the result that error
|
|
143
|
+
err = f"(SELECT arg_min(s.txt, s.row_num) {src} AND s.kind = 'e')" # the first; ORDER BY..LIMIT cannot run row-wise
|
|
144
|
+
if how == "SUM":
|
|
145
|
+
return Expr(f"(SELECT COALESCE(SUM(s.num) {numbers}, CAST(0 AS DOUBLE)) {src})", "num", err)
|
|
146
|
+
if how in ("MIN", "MAX"):
|
|
147
|
+
return Expr(f"(SELECT COALESCE({how}(s.num) {numbers}, CAST(0 AS DOUBLE)) {src})", "num", err)
|
|
148
|
+
count = f"(SELECT COUNT(*) {numbers} {src})"
|
|
149
|
+
return Expr(f"(SELECT SUM(s.num) {numbers} / NULLIF(COUNT(*) {numbers}, 0) {src})", "num",
|
|
150
|
+
errs(err, f"(CASE WHEN {count} = 0 THEN '#DIV/0!' END)"))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _resized(area: Area, like: Area) -> Area:
|
|
154
|
+
"""Excel's SUMIF uses the sum range's top-left cell and the criteria range's size."""
|
|
155
|
+
return Area(area.sheet, area.r1, area.c1, area.r1 + (like.r2 - like.r1), area.c1)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _if(how: str):
|
|
159
|
+
def fn(c, args, sheet):
|
|
160
|
+
fname = f"{how}IF" if how in ("SUM", "AVERAGE") else how
|
|
161
|
+
arity(fname, args, 2, 3)
|
|
162
|
+
area, _ = _column(c, args[0], sheet, fname)
|
|
163
|
+
target = area
|
|
164
|
+
if len(args) == 3:
|
|
165
|
+
if not isinstance(args[2], RefNode):
|
|
166
|
+
raise Unsupported(f"{fname} needs a cell range")
|
|
167
|
+
target = _resized(resolve(args[2].text, sheet, c.book), area)
|
|
168
|
+
return _aggregate(c, target, [(area, c.expr(args[1], sheet))], how)
|
|
169
|
+
return fn
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _ifs(how: str):
|
|
173
|
+
def fn(c, args, sheet):
|
|
174
|
+
fname = f"{how}IFS"
|
|
175
|
+
if len(args) < 3 or len(args) % 2 == 0:
|
|
176
|
+
raise Unsupported(f"{fname} needs a sum range and criteria pairs")
|
|
177
|
+
target, _ = _column(c, args[0], sheet, fname)
|
|
178
|
+
pairs = _pairs(c, args[1:], sheet, fname, target.r2 - target.r1)
|
|
179
|
+
return _aggregate(c, target, pairs, how)
|
|
180
|
+
return fn
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
register("SUMIF")(_if("SUM"))
|
|
184
|
+
register("AVERAGEIF")(_if("AVERAGE"))
|
|
185
|
+
register("SUMIFS")(_ifs("SUM"))
|
|
186
|
+
register("AVERAGEIFS")(_ifs("AVERAGE"))
|
|
187
|
+
register("MINIFS")(_ifs("MIN"))
|
|
188
|
+
register("MAXIFS")(_ifs("MAX"))
|
cellproof/fn/dates.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""DATE, YEAR, MONTH, DAY, WEEKDAY, DAYS, EDATE, EOMONTH, DAYS360, DATEDIF, and TEXT's date formats.
|
|
2
|
+
|
|
3
|
+
Excel stores a date as a serial number: 1 = 1900-01-01, and it counts a 29 February 1900 that never existed
|
|
4
|
+
(serial 60), so serials from 61 on are days since 1899-12-30. Serial 0 is "1900-01-00". Workbooks using the
|
|
5
|
+
1904 date system are not supported yet.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from cellproof.expr import Expr, errs, num
|
|
10
|
+
from cellproof.fn.lookup import int_literal
|
|
11
|
+
from cellproof.fn.text import FORMATS
|
|
12
|
+
from cellproof.functions import arity, register
|
|
13
|
+
from cellproof.model import Unsupported
|
|
14
|
+
from cellproof.parser import Bool, Str
|
|
15
|
+
|
|
16
|
+
MAX_SERIAL = 2958465 # 9999-12-31
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _check(c) -> None:
|
|
20
|
+
if c.book.date1904:
|
|
21
|
+
raise Unsupported("1904 date system not supported yet")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _serial(c, node, sheet: str) -> Expr:
|
|
25
|
+
_check(c)
|
|
26
|
+
s = num(c.expr(node, sheet))
|
|
27
|
+
return Expr(f"floor({s.sql})", "num", errs(s.err, f"(CASE WHEN {s.sql} < 0 OR {s.sql} > {MAX_SERIAL} THEN '#NUM!' END)"))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def to_date(s: str) -> str:
|
|
31
|
+
"""A whole serial (61 and up, or 1..59) as a DATE."""
|
|
32
|
+
return (f"(CASE WHEN {s} >= 61 THEN DATE '1899-12-30' + CAST({s} AS INTEGER) "
|
|
33
|
+
f"ELSE DATE '1899-12-31' + CAST({s} AS INTEGER) END)")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def from_date(d: str) -> str:
|
|
37
|
+
return (f"CAST(CASE WHEN {d} >= DATE '1900-03-01' THEN {d} - DATE '1899-12-30' "
|
|
38
|
+
f"ELSE {d} - DATE '1899-12-31' END AS DOUBLE)")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _ok(s: Expr) -> str:
|
|
42
|
+
return f"(({s.err}) IS NULL)"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _part(name: str, fn: str, at0: int, at60: int):
|
|
46
|
+
def impl(c, args, sheet):
|
|
47
|
+
arity(name, args, 1, 1)
|
|
48
|
+
s = _serial(c, args[0], sheet)
|
|
49
|
+
return Expr(f"(CASE WHEN NOT {_ok(s)} THEN NULL WHEN {s.sql} = 0 THEN {at0} WHEN {s.sql} = 60 THEN {at60} "
|
|
50
|
+
f"ELSE CAST({fn}({to_date(s.sql)}) AS DOUBLE) END)", "num", s.err)
|
|
51
|
+
register(name)(impl)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_part("YEAR", "year", 1900, 1900)
|
|
55
|
+
_part("MONTH", "month", 1, 2)
|
|
56
|
+
_part("DAY", "day", 0, 29)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@register("DATE")
|
|
60
|
+
def date(c, args, sheet):
|
|
61
|
+
arity("DATE", args, 3, 3)
|
|
62
|
+
_check(c)
|
|
63
|
+
y, m, d = (num(c.expr(a, sheet)) for a in args)
|
|
64
|
+
year = f"(CASE WHEN trunc({y.sql}) < 1900 THEN trunc({y.sql}) + 1900 ELSE trunc({y.sql}) END)"
|
|
65
|
+
ok = (f"(trunc({y.sql}) BETWEEN 0 AND 9999 AND abs({m.sql}) < 120000 AND abs({d.sql}) < 4000000)")
|
|
66
|
+
day = (f"CAST(make_date(CAST({year} AS INTEGER), 1, 1) + to_months(CAST(trunc({m.sql}) - 1 AS INTEGER)) "
|
|
67
|
+
f"+ to_days(CAST(trunc({d.sql}) - 1 AS INTEGER)) AS DATE)")
|
|
68
|
+
# Real Excel: DATE(1900,2,29) is 60 - it counts a 29 February 1900 that never existed - so inside 1900 a date is
|
|
69
|
+
# (days before the month, with 1900 a leap year) + day, for any day (DATE(1900,3,0) is 60). Months outside 1-12
|
|
70
|
+
# use the real calendar.
|
|
71
|
+
before = "[0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335]"
|
|
72
|
+
in_1900 = f"({year} = 1900 AND trunc({m.sql}) BETWEEN 1 AND 12)"
|
|
73
|
+
in_1900_serial = f"(CAST({before}[CAST(trunc({m.sql}) AS INTEGER)] AS DOUBLE) + trunc({d.sql}))"
|
|
74
|
+
serial = f"(CASE WHEN NOT {ok} THEN NULL WHEN {in_1900} THEN {in_1900_serial} ELSE {from_date(day)} END)"
|
|
75
|
+
return Expr(serial, "num", errs(y.err, m.err, d.err,
|
|
76
|
+
f"(CASE WHEN NOT {ok} OR {serial} < 0 OR {serial} > {MAX_SERIAL} THEN '#NUM!' END)"))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@register("WEEKDAY")
|
|
80
|
+
def weekday(c, args, sheet):
|
|
81
|
+
arity("WEEKDAY", args, 1, 2)
|
|
82
|
+
s = _serial(c, args[0], sheet)
|
|
83
|
+
kind = 1 if len(args) == 1 or args[1] is None else int_literal(args[1])
|
|
84
|
+
sunday0 = f"(CAST({s.sql} AS BIGINT) + 6) % 7" # serial 1 was a Sunday in Excel's calendar
|
|
85
|
+
sql = {1: f"({sunday0} + 1)", 2: f"(({sunday0} + 6) % 7 + 1)", 3: f"(({sunday0} + 6) % 7)"}.get(kind)
|
|
86
|
+
if sql is None:
|
|
87
|
+
raise Unsupported("WEEKDAY return type must be 1, 2 or 3")
|
|
88
|
+
return Expr(f"(CASE WHEN {_ok(s)} THEN CAST({sql} AS DOUBLE) END)", "num", s.err)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@register("DAYS")
|
|
92
|
+
def days(c, args, sheet):
|
|
93
|
+
arity("DAYS", args, 2, 2)
|
|
94
|
+
end, start = _serial(c, args[0], sheet), _serial(c, args[1], sheet)
|
|
95
|
+
return Expr(f"({end.sql} - {start.sql})", "num", errs(end.err, start.err))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _shift(name: str, last: bool):
|
|
99
|
+
def impl(c, args, sheet):
|
|
100
|
+
arity(name, args, 2, 2)
|
|
101
|
+
s, m = _serial(c, args[0], sheet), num(c.expr(args[1], sheet))
|
|
102
|
+
d = f"CAST({to_date(s.sql)} + to_months(CAST(trunc({m.sql}) AS INTEGER)) AS DATE)"
|
|
103
|
+
if last:
|
|
104
|
+
d = f"last_day({d})"
|
|
105
|
+
ok = f"({_ok(s)} AND ({m.err}) IS NULL AND trunc({m.sql}) BETWEEN -120000 AND 120000)"
|
|
106
|
+
serial = f"(CASE WHEN {ok} THEN {from_date(d)} END)"
|
|
107
|
+
bad = f"(CASE WHEN NOT {ok} OR {serial} NOT BETWEEN 0 AND {MAX_SERIAL} THEN '#NUM!' END)"
|
|
108
|
+
return Expr(serial, "num", errs(s.err, m.err, bad))
|
|
109
|
+
register(name)(impl)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
_shift("EDATE", last=False)
|
|
113
|
+
_shift("EOMONTH", last=True)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@register("DAYS360")
|
|
117
|
+
def days360(c, args, sheet):
|
|
118
|
+
arity("DAYS360", args, 2, 3)
|
|
119
|
+
if len(args) == 3 and not isinstance(args[2], Bool):
|
|
120
|
+
raise Unsupported("DAYS360 method must be TRUE/FALSE written in the formula")
|
|
121
|
+
european = len(args) == 3 and args[2].value
|
|
122
|
+
a, b = _serial(c, args[0], sheet), _serial(c, args[1], sheet)
|
|
123
|
+
d1, d2 = to_date(a.sql), to_date(b.sql)
|
|
124
|
+
y1, m1, y2, m2 = f"year({d1})", f"month({d1})", f"year({d2})", f"month({d2})"
|
|
125
|
+
if european:
|
|
126
|
+
day1, day2, m2 = f"least(day({d1}), 30)", f"least(day({d2}), 30)", m2
|
|
127
|
+
else: # U.S. (NASD), as Microsoft documents it for DAYS360
|
|
128
|
+
day1 = f"(CASE WHEN {d1} = last_day({d1}) THEN 30 ELSE day({d1}) END)"
|
|
129
|
+
# Real Excel: the last day of February ends the period unchanged (28 or 29) unless the start is also the
|
|
130
|
+
# last day of February; only then does it become the 30th. Only a 31st is moved (to the 1st of the next
|
|
131
|
+
# month, or the 30th), never the 30th of a short month: Jan 15 -> Apr 30 is 105, not 106.
|
|
132
|
+
feb_end = f"({d2} = last_day({d2}) AND month({d2}) = 2)"
|
|
133
|
+
feb_start = f"({d1} = last_day({d1}) AND month({d1}) = 2)"
|
|
134
|
+
end_last = f"(day({d2}) = 31)"
|
|
135
|
+
day2 = (f"(CASE WHEN {feb_end} AND {feb_start} THEN 30 WHEN {end_last} AND {day1} < 30 THEN 1 "
|
|
136
|
+
f"WHEN {end_last} THEN 30 ELSE day({d2}) END)")
|
|
137
|
+
m2 = f"({m2} + CASE WHEN {end_last} AND {day1} < 30 THEN 1 ELSE 0 END)"
|
|
138
|
+
sql = f"CAST(({y2} - {y1}) * 360 + ({m2} - {m1}) * 30 + ({day2} - {day1}) AS DOUBLE)"
|
|
139
|
+
return Expr(f"(CASE WHEN {_ok(a)} AND {_ok(b)} THEN {sql} END)", "num", errs(a.err, b.err))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@register("DATEDIF")
|
|
143
|
+
def datedif(c, args, sheet):
|
|
144
|
+
arity("DATEDIF", args, 3, 3)
|
|
145
|
+
if not isinstance(args[2], Str) or args[2].value.upper() not in ("Y", "M", "D"):
|
|
146
|
+
raise Unsupported("DATEDIF unit must be \"Y\", \"M\" or \"D\" written in the formula")
|
|
147
|
+
unit = args[2].value.upper()
|
|
148
|
+
a, b = _serial(c, args[0], sheet), _serial(c, args[1], sheet)
|
|
149
|
+
d1, d2 = to_date(a.sql), to_date(b.sql)
|
|
150
|
+
months = (f"((year({d2}) - year({d1})) * 12 + month({d2}) - month({d1}) "
|
|
151
|
+
f"- CASE WHEN day({d2}) < day({d1}) THEN 1 ELSE 0 END)")
|
|
152
|
+
sql = {"D": f"({b.sql} - {a.sql})", "M": months, "Y": f"floor({months} / 12)"}[unit]
|
|
153
|
+
return Expr(f"(CASE WHEN {_ok(a)} AND {_ok(b)} AND {a.sql} <= {b.sql} THEN CAST({sql} AS DOUBLE) END)", "num",
|
|
154
|
+
errs(a.err, b.err, f"(CASE WHEN {a.sql} > {b.sql} THEN '#NUM!' END)"))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _date_format(pattern: str):
|
|
158
|
+
def sql(x: str) -> str:
|
|
159
|
+
s = f"floor({x})"
|
|
160
|
+
return f"(CASE WHEN {s} BETWEEN 1 AND {MAX_SERIAL} AND {s} <> 60 THEN strftime({to_date(s)}, '{pattern}') END)"
|
|
161
|
+
sql.is_date = True # TEXT refuses date formats in 1904-system workbooks
|
|
162
|
+
return sql
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
DATE_FORMATS = {
|
|
166
|
+
"yyyy-mm-dd": "%Y-%m-%d", "mm/dd/yyyy": "%m/%d/%Y", "m/d/yyyy": "%-m/%-d/%Y", "dd/mm/yyyy": "%d/%m/%Y",
|
|
167
|
+
"d/m/yyyy": "%-d/%-m/%Y", "dd-mmm-yyyy": "%d-%b-%Y", "d-mmm-yy": "%-d-%b-%y", "dd-mmm-yy": "%d-%b-%y",
|
|
168
|
+
"mmm": "%b", "mmmm": "%B", "yyyy": "%Y", "yy": "%y", "mmm-yy": "%b-%y", "mmm yyyy": "%b %Y",
|
|
169
|
+
"mmmm yyyy": "%B %Y", "ddd": "%a", "dddd": "%A", "dd": "%d", "d": "%-d", "mm": "%m",
|
|
170
|
+
}
|
|
171
|
+
FORMATS.update({k: _date_format(v) for k, v in DATE_FORMATS.items()})
|
cellproof/fn/logic.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""IFS, SWITCH, NOT, XOR, IFNA, ISNA, ISERROR, ISERR, ISNUMBER, ISTEXT, ISLOGICAL, ISBLANK, NA, TRUE, FALSE.
|
|
2
|
+
|
|
3
|
+
The IS functions look at a value without failing: an error in their argument is their answer, not their error.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from cellproof.expr import NO_ERR, Expr, as_bool, compare, error, errs, pick, unify, unify_all
|
|
8
|
+
from cellproof.functions import arity, register
|
|
9
|
+
from cellproof.model import Unsupported
|
|
10
|
+
from cellproof.parser import RefNode
|
|
11
|
+
from cellproof.refs import resolve
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _chain(branches: list[tuple[Expr, Expr]], default: Expr | None) -> Expr:
|
|
15
|
+
"""CASE WHEN cond1 THEN value1 ... ELSE default (or #N/A): IFS and SWITCH."""
|
|
16
|
+
values = unify_all([v for _, v in branches] + ([default] if default is not None else []))
|
|
17
|
+
fallback = values[-1] if default is not None else None
|
|
18
|
+
|
|
19
|
+
def case(attr: str) -> str:
|
|
20
|
+
parts = [getattr(v, attr) for v in values]
|
|
21
|
+
if len(set(parts)) == 1:
|
|
22
|
+
return parts[0]
|
|
23
|
+
whens = " ".join(f"WHEN {cnd.sql} THEN {getattr(v, attr)}" for (cnd, _), v in zip(branches, values))
|
|
24
|
+
return f"(CASE {whens} ELSE {getattr(fallback, attr) if fallback else 'NULL'} END)"
|
|
25
|
+
err_whens = []
|
|
26
|
+
for (cnd, _), v in zip(branches, values):
|
|
27
|
+
if cnd.err != NO_ERR: # a condition that fails stops the chain, as in Excel
|
|
28
|
+
err_whens.append(f"WHEN ({cnd.err}) IS NOT NULL THEN {cnd.err}")
|
|
29
|
+
err_whens.append(f"WHEN {cnd.sql} THEN {v.err}")
|
|
30
|
+
last = fallback.err if fallback else "'#N/A'"
|
|
31
|
+
return Expr(case("sql"), values[0].type, f"(CASE {' '.join(err_whens)} ELSE {last} END)",
|
|
32
|
+
case("txt"), case("kind"))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@register("IFS")
|
|
36
|
+
def ifs(c, args, sheet):
|
|
37
|
+
if not args or len(args) % 2:
|
|
38
|
+
raise Unsupported("IFS needs condition/value pairs")
|
|
39
|
+
pairs = [(as_bool(c.expr(args[i], sheet)), c.expr(args[i + 1], sheet)) for i in range(0, len(args), 2)]
|
|
40
|
+
return _chain(pairs, None)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@register("SWITCH")
|
|
44
|
+
def switch(c, args, sheet):
|
|
45
|
+
if len(args) < 3:
|
|
46
|
+
raise Unsupported("SWITCH needs a value and value/result pairs")
|
|
47
|
+
x = c.expr(args[0], sheet)
|
|
48
|
+
rest = args[1:]
|
|
49
|
+
default = c.expr(rest[-1], sheet) if len(rest) % 2 else None
|
|
50
|
+
pairs = [(compare(x, c.expr(rest[i], sheet), "="), c.expr(rest[i + 1], sheet)) for i in range(0, len(rest) - 1, 2)]
|
|
51
|
+
return _chain(pairs, default)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@register("NOT")
|
|
55
|
+
def not_(c, args, sheet):
|
|
56
|
+
arity("NOT", args, 1, 1)
|
|
57
|
+
x = as_bool(c.expr(args[0], sheet))
|
|
58
|
+
return Expr(f"(NOT {x.sql})", "bool", x.err)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@register("XOR")
|
|
62
|
+
def xor(c, args, sheet):
|
|
63
|
+
if not args or any(isinstance(a, RefNode) and not resolve(a.text, sheet, c.book).is_single for a in args):
|
|
64
|
+
raise Unsupported("XOR over ranges not supported yet")
|
|
65
|
+
parts = [as_bool(c.expr(a, sheet)) for a in args]
|
|
66
|
+
total = " + ".join(f"CAST({p.sql} AS INTEGER)" for p in parts)
|
|
67
|
+
return Expr(f"(({total}) % 2 = 1)", "bool", errs(*(p.err for p in parts)))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@register("IFNA")
|
|
71
|
+
def ifna(c, args, sheet):
|
|
72
|
+
arity("IFNA", args, 2, 2)
|
|
73
|
+
value, fallback = unify(c.expr(args[0], sheet), c.expr(args[1], sheet))
|
|
74
|
+
if value.err == NO_ERR:
|
|
75
|
+
return value
|
|
76
|
+
na = f"COALESCE(({value.err}) = '#N/A', FALSE)"
|
|
77
|
+
return pick(f"(NOT {na})", value, fallback, f"(CASE WHEN {na} THEN {fallback.err} ELSE {value.err} END)")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _is(name: str, test):
|
|
81
|
+
def fn(c, args, sheet):
|
|
82
|
+
arity(name, args, 1, 1)
|
|
83
|
+
return Expr(test(c.expr(args[0], sheet)), "bool")
|
|
84
|
+
register(name)(fn)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
_is("ISNA", lambda x: f"COALESCE(({x.err}) = '#N/A', FALSE)")
|
|
88
|
+
_is("ISERROR", lambda x: f"(({x.err}) IS NOT NULL)")
|
|
89
|
+
_is("ISERR", lambda x: f"COALESCE(({x.err}) <> '#N/A', FALSE)")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _kind_is(kind: str, typ: str):
|
|
93
|
+
def test(x: Expr) -> str:
|
|
94
|
+
if x.type == "any":
|
|
95
|
+
return f"(({x.err}) IS NULL AND {x.kind} = '{kind}')"
|
|
96
|
+
return f"(({x.err}) IS NULL)" if x.type == typ else "FALSE"
|
|
97
|
+
return test
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
_is("ISNUMBER", _kind_is("n", "num"))
|
|
101
|
+
_is("ISTEXT", _kind_is("s", "txt"))
|
|
102
|
+
_is("ISLOGICAL", _kind_is("b", "bool"))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@register("ISBLANK")
|
|
106
|
+
def isblank(c, args, sheet):
|
|
107
|
+
arity("ISBLANK", args, 1, 1)
|
|
108
|
+
if not isinstance(args[0], RefNode):
|
|
109
|
+
return Expr("FALSE", "bool") # ISBLANK("") is FALSE: only an empty cell is blank
|
|
110
|
+
area = resolve(args[0].text, sheet, c.book)
|
|
111
|
+
cell = c.book.cells.get(area.key)
|
|
112
|
+
empty = cell is None or (cell.formula is None and cell.kind == "blank")
|
|
113
|
+
return Expr("TRUE" if empty else "FALSE", "bool")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@register("NA")
|
|
117
|
+
def na(c, args, sheet):
|
|
118
|
+
arity("NA", args, 0, 0)
|
|
119
|
+
return error("#N/A")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@register("TRUE")
|
|
123
|
+
def true(c, args, sheet):
|
|
124
|
+
arity("TRUE", args, 0, 0)
|
|
125
|
+
return Expr("TRUE", "bool")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@register("FALSE")
|
|
129
|
+
def false(c, args, sheet):
|
|
130
|
+
arity("FALSE", args, 0, 0)
|
|
131
|
+
return Expr("FALSE", "bool")
|