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/functions.py ADDED
@@ -0,0 +1,114 @@
1
+ """Excel function -> SQL. Each entry: fn(compiler, args, sheet) -> Expr.
2
+
3
+ Ranges are read from the cells table (inputs plus the formula cells of earlier batches).
4
+ Compiler.ready() checks every formula cell in a range was computed in an earlier batch.
5
+ Every function returns its Excel error in Expr.err; see expr.errs for the order rule.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from cellproof.expr import CELLS, NO_ERR, ZERO, Expr, any_of, as_bool, errs, lit_str, num, pick, unify, where_area
10
+ from cellproof.model import Unsupported, a1
11
+ from cellproof.parser import Bool, Num, RefNode, Str
12
+ from cellproof.refs import Area, resolve
13
+
14
+ FUNCS = {}
15
+
16
+
17
+ def register(name):
18
+ def deco(fn):
19
+ FUNCS[name] = fn
20
+ return fn
21
+
22
+ return deco
23
+
24
+
25
+ def arity(name, args, lo, hi):
26
+ if not lo <= len(args) <= hi:
27
+ raise Unsupported(f"{name} needs {lo}-{hi} arguments, got {len(args)}")
28
+
29
+
30
+ def _looks_numeric(text: str) -> bool:
31
+ try:
32
+ float(text.replace(",", "").strip())
33
+ return True
34
+ except ValueError:
35
+ return False
36
+
37
+
38
+ def note_text_numbers(c, keys):
39
+ """Excel skips text in SUM-like ranges, even text that looks like a number. Tell the user."""
40
+ for key in keys.text_cells():
41
+ cell = c.book.cells[key]
42
+ if _looks_numeric(cell.value):
43
+ c.findings.append(f"{a1(key)}: number stored as text ({cell.value!r}) is skipped by Excel")
44
+
45
+
46
+ def first_error(c, area: Area, keys) -> str:
47
+ """SQL for the first error in a range, row by row, or NO_ERR when no cell in it can hold one."""
48
+ if not keys.may_err:
49
+ return NO_ERR
50
+ return f"(SELECT txt FROM {CELLS} WHERE {where_area(area)} AND kind = 'e' ORDER BY row_num, col_num LIMIT 1)"
51
+
52
+
53
+ def range_sum(c, area: Area) -> Expr:
54
+ """SUM over a range: text, TRUE/FALSE and blanks are skipped; the first error wins."""
55
+ keys = c.ready(area)
56
+ note_text_numbers(c, keys)
57
+ return Expr(f"(SELECT COALESCE(SUM(num), CAST(0 AS DOUBLE)) FROM {CELLS} WHERE {where_area(area)} AND kind = 'n')",
58
+ "num", first_error(c, area, keys))
59
+
60
+
61
+ @register("SUM")
62
+ def sum_(c, args, sheet):
63
+ parts = [range_sum(c, resolve(arg.text, sheet, c.book)) if isinstance(arg, RefNode) else num(c.expr(arg, sheet))
64
+ for arg in args]
65
+ if not parts:
66
+ return ZERO
67
+ # Real Excel: SUM(H3,1/0) with H3 = #N/A is #DIV/0!. Typed-in expressions are evaluated first.
68
+ typed = [p for a, p in zip(args, parts) if not isinstance(a, RefNode)]
69
+ cells = [p for a, p in zip(args, parts) if isinstance(a, RefNode)]
70
+ return Expr("(" + " + ".join(p.sql for p in parts) + ")", "num", errs(*(p.err for p in typed + cells)))
71
+
72
+
73
+ @register("IF")
74
+ def if_(c, args, sheet):
75
+ arity("IF", args, 2, 3)
76
+ cond = as_bool(c.expr(args[0], sheet))
77
+ yes = c.expr(args[1], sheet)
78
+ no = c.expr(args[2], sheet) if len(args) == 3 else Expr("FALSE", "bool")
79
+ yes, no = unify(yes, no)
80
+ taken = NO_ERR if yes.err == no.err == NO_ERR else f"(CASE WHEN {cond.sql} THEN {yes.err} ELSE {no.err} END)"
81
+ return pick(cond.sql, yes, no, errs(cond.err, taken))
82
+
83
+
84
+ @register("ROUND")
85
+ def round_(c, args, sheet):
86
+ arity("ROUND", args, 2, 2)
87
+ x, digits = num(c.expr(args[0], sheet)), num(c.expr(args[1], sheet))
88
+ # DuckDB ROUND on DOUBLE rounds half away from zero like Excel (2.675 -> 2.68, verified on 1.5.6)
89
+ return Expr(f"ROUND({x.sql}, CAST(TRUNC({digits.sql}) AS INTEGER))", "num", errs(x.err, digits.err))
90
+
91
+
92
+ def _logic(name, joiner):
93
+ def fn(c, args, sheet):
94
+ if not args:
95
+ raise Unsupported(f"{name} needs arguments")
96
+ parts = [as_bool(c.expr(a, sheet)) for a in args]
97
+ return Expr("(" + f" {joiner} ".join(p.sql for p in parts) + ")", "bool", errs(*(p.err for p in parts)))
98
+
99
+ return fn
100
+
101
+
102
+ FUNCS["AND"] = _logic("AND", "AND")
103
+ FUNCS["OR"] = _logic("OR", "OR")
104
+
105
+
106
+ @register("IFERROR")
107
+ def iferror(c, args, sheet):
108
+ arity("IFERROR", args, 2, 2)
109
+ value, fallback = unify(c.expr(args[0], sheet), c.expr(args[1], sheet))
110
+ if value.err == NO_ERR:
111
+ return value
112
+ ok = f"(({value.err}) IS NULL)"
113
+ err = NO_ERR if fallback.err == NO_ERR else f"(CASE WHEN {ok} THEN NULL ELSE {fallback.err} END)"
114
+ return pick(ok, value, fallback, err)
cellproof/graph.py ADDED
@@ -0,0 +1,150 @@
1
+ """Order formula cells so every cell comes after the cells it reads.
2
+
3
+ Ranges are not expanded into cell sets (that is quadratic for a running SUM($B$1:Bn)); they are O(n log n) when the
4
+ cells they read are ordered in ascending order (see the ponytail comment in the main loop for the bottom-up case). Per
5
+ column, a range is an interval of that column's formula cells, and a Fenwick tree of "not yet ordered" counts says
6
+ whether the interval is empty and which cell to wait for next. Cells are released level by level: a node's level is one
7
+ more than the last node it waited for, so every cell that needs only inputs comes first, then the cells that need those.
8
+
9
+ A group of cells (a region) is one node: it waits for everything its members read from outside the group, and when it is
10
+ released all its members are ordered at once, consecutively, row-major.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import heapq
15
+ from bisect import bisect_left, bisect_right
16
+ from collections import defaultdict
17
+
18
+ from cellproof.fenwick import Fenwick
19
+ from cellproof.model import Book, Key, Unsupported
20
+ from cellproof.r1c1 import lexed
21
+ from cellproof.refs import resolve
22
+
23
+ CYCLE = "circular reference (or depends on one)"
24
+
25
+ Column = tuple[str, int] # (sheet, col)
26
+
27
+
28
+ def formula_order(book: Book, groups=()) -> tuple[list[Key], dict[Key, str]]:
29
+ formulas = {k for k, c in book.cells.items() if c.formula is not None}
30
+ grouped = {k for g in groups for k in g}
31
+ members = [sorted(g) for g in groups] + [[k] for k in sorted(formulas - grouped)]
32
+ node_of = {k: i for i, m in enumerate(members) for k in m}
33
+ frows: dict[Column, list[int]] = defaultdict(list) # rows holding a formula, per column, sorted
34
+ for sheet, row, col in sorted(formulas):
35
+ frows[(sheet, col)].append(row)
36
+ fcols: dict[str, list[int]] = defaultdict(list) # columns holding a formula, per sheet, sorted
37
+ for sheet, col in sorted(frows):
38
+ fcols[sheet].append(col)
39
+ unordered = {column: Fenwick([1] * len(rows)) for column, rows in frows.items()}
40
+ span: dict[tuple[int, Column], tuple[int, int]] = {} # (group node, column) -> its members' index interval
41
+ for n, m in enumerate(members[:len(groups)]):
42
+ per: dict[Column, list[int]] = defaultdict(list)
43
+ for sheet, row, col in m:
44
+ per[(sheet, col)].append(bisect_left(frows[(sheet, col)], row))
45
+ for column, idx in per.items():
46
+ if max(idx) - min(idx) + 1 == len(idx): # contiguous: a range over it can be clipped
47
+ span[(n, column)] = (min(idx), max(idx) + 1)
48
+
49
+ singles: dict[Key, set[Key]] = {} # cell -> formula cells it reads one by one
50
+ slices: dict[Key, list[tuple[Column, int, int]]] = {} # cell -> (column, first, past-last) index into frows
51
+ lex = lexed(book)
52
+ for key in formulas:
53
+ cell = book.cells[key]
54
+ one: set[Key] = set()
55
+ many: list[tuple[Column, int, int]] = []
56
+ shape, texts = lex[key]
57
+ try:
58
+ for text in (texts if shape is not None and shape.parses() else ()): # = refs(parse(cell.formula))
59
+ area = resolve(text, cell.sheet, book)
60
+ if area.is_single:
61
+ # a read of another member of the same group is not a wait; a cell reading itself still is
62
+ if area.key in formulas and (node_of[area.key] != node_of[key] or area.key == key):
63
+ one.add(area.key)
64
+ continue
65
+ cols = fcols.get(area.sheet, [])
66
+ for col in cols[bisect_left(cols, area.c1):bisect_right(cols, area.c2)]:
67
+ rows = frows[(area.sheet, col)]
68
+ i, j = bisect_left(rows, area.r1), bisect_right(rows, area.r2)
69
+ own = span.get((node_of[key], (area.sheet, col)))
70
+ # a range over the group's own members is not a wait (regions only admit such ranges when they
71
+ # read earlier rows of an earlier column: a growing SUM), only the cells outside the group are
72
+ parts = [(i, min(j, own[0])), (max(i, own[1]), j)] if own else [(i, j)]
73
+ for a, b in parts:
74
+ if b > a:
75
+ many.append(((area.sheet, col), a, b))
76
+ except (Unsupported, RecursionError):
77
+ pass # the compiler reports the real reason for this cell
78
+ singles[key], slices[key] = one, many
79
+
80
+ users = defaultdict(list) # cell -> nodes waiting for it, once per edge
81
+ for key, reads in singles.items():
82
+ for dep in reads:
83
+ users[dep].append(node_of[key])
84
+ blocked_on: dict[tuple[Column, int], list] = defaultdict(list) # (column, index) -> slices waiting for that cell
85
+ waiting = [0] * len(members)
86
+ for key in formulas:
87
+ node = node_of[key]
88
+ waiting[node] += len(singles[key]) + len(slices[key])
89
+ for column, i, j in slices[key]:
90
+ blocked_on[(column, j - 1)].append((node, column, i, j))
91
+ level = [0] * len(members)
92
+ ready = [(0, members[n][0], n) for n in range(len(members)) if waiting[n] == 0]
93
+ heapq.heapify(ready)
94
+ order = []
95
+
96
+ def release(user: int, after: int) -> None:
97
+ level[user] = max(level[user], level[after] + 1)
98
+ waiting[user] -= 1
99
+ if waiting[user] == 0:
100
+ heapq.heappush(ready, (level[user], members[user][0], user))
101
+
102
+ while ready:
103
+ _, _, node = heapq.heappop(ready)
104
+ for key in members[node]:
105
+ order.append(key)
106
+ column = (key[0], key[2])
107
+ pos = bisect_left(frows[column], key[1])
108
+ unordered[column].add(pos, -1)
109
+ for user in users[key]:
110
+ release(user, node)
111
+ for user, ucol, i, j in blocked_on.pop((column, pos), ()):
112
+ fw = unordered[ucol]
113
+ if fw.range(i, j) == 0:
114
+ release(user, node)
115
+ else: # wait for the last cell of the range that is still unordered
116
+ # ponytail: bottom-up chain (B_r = B_{r+1}+1 with C_r = SUM($B$1:B_r)) moves every waiting slice to
117
+ # the previous cell one step at a time, so ordering is O(n^2 log n) there (4000 rows: 28.8 s vs 7.9 s
118
+ # for the old graph). Upgrade: register each slice on canonical Fenwick/segment nodes with counters,
119
+ # or move whole waiter lists in one step.
120
+ blocked_on[(ucol, fw.kth(fw.prefix(j)))].append((user, ucol, i, j))
121
+ return order, {k: CYCLE for k in sorted(formulas - set(order))}
122
+
123
+
124
+ def order_with_groups(book: Book, groups: list[list[Key]], internally_acyclic: bool = False):
125
+ """Order with groups as nodes. A group that creates a cycle the cells do not have is dissolved and ordering
126
+ repeated; the set of groups only shrinks, so this ends. Returns (order, unresolved, kept groups).
127
+
128
+ A group can hide a true cycle only through reads among its own members. Callers whose groups cannot have such a
129
+ cycle pass internally_acyclic=True, and the extra ungrouped pass then runs only when the grouped order leaves cells
130
+ unresolved. Regions qualify because find_regions enforces two things: their same-row reads are acyclic
131
+ (column_order), and no absolute cell, range or name may read the region's own formula cells. The only reads of
132
+ other rows it admits are a running total's own previous row and a growing SUM($B$k:B{r}) of an earlier column,
133
+ both of earlier rows only (formula_order clips such ranges to the cells outside the group). Relaxing any of this
134
+ breaks the invariant."""
135
+ groups = [list(g) for g in groups]
136
+ base_bad = None
137
+ while True:
138
+ order, bad = formula_order(book, groups)
139
+ if not groups or (internally_acyclic and not bad):
140
+ return order, bad, groups
141
+ if base_bad is None:
142
+ base_bad = set(formula_order(book)[1])
143
+ # cycles that grouping created, and true cycles that grouping hid (a node hides a cycle inside itself)
144
+ extra = (set(bad) - base_bad) | (base_bad - set(bad))
145
+ if not extra:
146
+ return order, bad, groups
147
+ kept = [g for g in groups if not (set(g) & extra)]
148
+ if len(kept) == len(groups):
149
+ return order, bad, groups
150
+ groups = kept
cellproof/model.py ADDED
@@ -0,0 +1,72 @@
1
+ """Data types shared by every stage."""
2
+ from __future__ import annotations
3
+
4
+ from bisect import bisect_left, bisect_right
5
+ from collections import defaultdict
6
+ from dataclasses import dataclass, field
7
+ from functools import cached_property
8
+ from typing import Literal, Union
9
+
10
+ from openpyxl.utils.cell import get_column_letter
11
+
12
+ Key = tuple[str, int, int] # (sheet, row, col)
13
+ Kind = Literal["n", "s", "b", "e", "blank"]
14
+ Value = Union[float, str, bool, None]
15
+
16
+
17
+ class Unsupported(Exception):
18
+ """A cell cannot be translated. The message becomes the UNRESOLVED reason shown to the user."""
19
+
20
+
21
+ def a1(key: Key) -> str:
22
+ sheet, row, col = key
23
+ return f"{sheet}!{get_column_letter(col)}{row}"
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Cell:
28
+ sheet: str
29
+ row: int
30
+ col: int
31
+ formula: str | None # formula text without the leading "=", None for input cells
32
+ value: Value # Excel-saved value; numbers as float, dates as Excel serial numbers
33
+ kind: Kind # kind of the saved value
34
+ array: bool = False # array (Ctrl+Shift+Enter) formula
35
+
36
+ @property
37
+ def key(self) -> Key:
38
+ return (self.sheet, self.row, self.col)
39
+
40
+
41
+ @dataclass
42
+ class Book:
43
+ cells: dict[Key, Cell]
44
+ sheets: list[str]
45
+ names: dict[str, tuple[str, str]] = field(default_factory=dict) # NAME (upper) -> (sheet, ref)
46
+ max_row: dict[str, int] = field(default_factory=dict)
47
+ date1904: bool = False # the workbook counts dates from 1904 (old Mac Excel)
48
+
49
+ @cached_property
50
+ def _rows_by_col(self) -> dict[tuple[str, int], list[int]]:
51
+ """(sheet, col) -> sorted rows that hold a cell. Built once; build a new Book if cells change."""
52
+ cols = defaultdict(list)
53
+ for sheet, row, col in self.cells:
54
+ cols[(sheet, col)].append(row)
55
+ return {k: sorted(v) for k, v in cols.items()}
56
+
57
+ def present(self, sheet: str, r1: int, c1: int, r2: int, c2: int) -> list[Key]:
58
+ """Keys of the non-empty cells inside a rectangle, row-major. Fast on whole-column ranges."""
59
+ found = []
60
+ for col in range(c1, c2 + 1):
61
+ rows = self._rows_by_col.get((sheet, col), [])
62
+ found.extend((sheet, r, col) for r in rows[bisect_left(rows, r1):bisect_right(rows, r2)])
63
+ return sorted(found)
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class Verdict:
68
+ key: Key
69
+ status: Literal["PASS", "FAIL", "UNRESOLVED"]
70
+ expected: Value = None
71
+ actual: Value = None
72
+ reason: str = ""
cellproof/parser.py ADDED
@@ -0,0 +1,172 @@
1
+ """Excel formula text -> small AST. Pratt parser over openpyxl's tokenizer."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from typing import Iterator, Union
6
+
7
+ from openpyxl.formula.tokenizer import Token, Tokenizer, TokenizerError
8
+
9
+ from cellproof.model import Unsupported
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Num:
14
+ value: float
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Str:
19
+ value: str
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Bool:
24
+ value: bool
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ErrLit:
29
+ value: str
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class RefNode:
34
+ text: str
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Unary:
39
+ op: str
40
+ operand: "Node"
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Postfix:
45
+ op: str
46
+ operand: "Node"
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Binary:
51
+ op: str
52
+ left: "Node"
53
+ right: "Node"
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class Call:
58
+ name: str
59
+ args: tuple # Node or None (empty argument)
60
+
61
+
62
+ Node = Union[Num, Str, Bool, ErrLit, RefNode, Unary, Postfix, Binary, Call]
63
+
64
+ # Excel precedence, low -> high. Unary minus binds tighter than ^, so -2^2 = 4.
65
+ BINARY = {"=": 1, "<>": 1, "<": 1, ">": 1, "<=": 1, ">=": 1, "&": 2, "+": 3, "-": 3, "*": 4, "/": 4, "^": 5}
66
+ PREFIX = 6
67
+
68
+
69
+ def parse(formula: str) -> Node:
70
+ """Parse formula text without the leading '='."""
71
+ try:
72
+ tokens = [t for t in Tokenizer("=" + formula).items if t.type != Token.WSPACE]
73
+ except TokenizerError as err:
74
+ raise Unsupported(f"cannot parse formula: {err}") from None
75
+ p = _Parser(tokens)
76
+ node = p.expr(0)
77
+ if p.peek() is not None:
78
+ raise Unsupported(f"unexpected {p.peek().value!r} in formula")
79
+ return node
80
+
81
+
82
+ def refs(node) -> Iterator[str]:
83
+ """Every reference text in the tree, left to right."""
84
+ if isinstance(node, RefNode):
85
+ yield node.text
86
+ elif isinstance(node, (Unary, Postfix)):
87
+ yield from refs(node.operand)
88
+ elif isinstance(node, Binary):
89
+ yield from refs(node.left)
90
+ yield from refs(node.right)
91
+ elif isinstance(node, Call):
92
+ for arg in node.args:
93
+ if arg is not None:
94
+ yield from refs(arg)
95
+
96
+
97
+ class _Parser:
98
+ def __init__(self, tokens):
99
+ self.tokens, self.i = tokens, 0
100
+
101
+ def peek(self):
102
+ return self.tokens[self.i] if self.i < len(self.tokens) else None
103
+
104
+ def take(self):
105
+ tok = self.peek()
106
+ if tok is None:
107
+ raise Unsupported("formula ends unexpectedly")
108
+ self.i += 1
109
+ return tok
110
+
111
+ def at(self, type_, subtype):
112
+ tok = self.peek()
113
+ return tok is not None and tok.type == type_ and tok.subtype == subtype
114
+
115
+ def expr(self, min_bp):
116
+ left = self.atom()
117
+ while (tok := self.peek()) is not None:
118
+ if tok.type == Token.OP_POST:
119
+ self.take()
120
+ left = Postfix(tok.value, left)
121
+ continue
122
+ if tok.type != Token.OP_IN:
123
+ break
124
+ bp = BINARY.get(tok.value)
125
+ if bp is None:
126
+ raise Unsupported(f"operator {tok.value!r} not supported")
127
+ if bp <= min_bp:
128
+ break
129
+ self.take()
130
+ left = Binary(tok.value, left, self.expr(bp))
131
+ return left
132
+
133
+ def atom(self):
134
+ tok = self.take()
135
+ if tok.type == Token.OP_PRE:
136
+ operand = self.expr(PREFIX)
137
+ return operand if tok.value == "+" else Unary("-", operand)
138
+ if tok.type == Token.OPERAND:
139
+ if tok.subtype == Token.NUMBER:
140
+ return Num(float(tok.value))
141
+ if tok.subtype == Token.TEXT:
142
+ return Str(tok.value[1:-1].replace('""', '"'))
143
+ if tok.subtype == Token.LOGICAL:
144
+ return Bool(tok.value.upper() == "TRUE")
145
+ if tok.subtype == Token.ERROR:
146
+ return ErrLit(tok.value)
147
+ return RefNode(tok.value)
148
+ if tok.type == Token.PAREN and tok.subtype == Token.OPEN:
149
+ inner = self.expr(0)
150
+ if not self.at(Token.PAREN, Token.CLOSE):
151
+ raise Unsupported("missing ')'")
152
+ self.take()
153
+ return inner
154
+ if tok.type == Token.FUNC and tok.subtype == Token.OPEN:
155
+ return self.call(tok.value[:-1].upper().removeprefix("_XLFN."))
156
+ raise Unsupported(f"unexpected {tok.value!r} in formula")
157
+
158
+ def call(self, name):
159
+ if self.at(Token.FUNC, Token.CLOSE):
160
+ self.take()
161
+ return Call(name, ())
162
+ args = []
163
+ while True:
164
+ if self.at(Token.SEP, Token.ARG) or self.at(Token.FUNC, Token.CLOSE):
165
+ args.append(None)
166
+ else:
167
+ args.append(self.expr(0))
168
+ tok = self.take()
169
+ if tok.type == Token.FUNC and tok.subtype == Token.CLOSE:
170
+ return Call(name, tuple(args))
171
+ if not (tok.type == Token.SEP and tok.subtype == Token.ARG):
172
+ raise Unsupported(f"unexpected {tok.value!r} in {name}()")