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/r1c1.py ADDED
@@ -0,0 +1,202 @@
1
+ """Reference classification and R1C1 keys: which formulas are 'the same formula pasted down a column'."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ from openpyxl.formula.tokenizer import Token, Tokenizer, TokenizerError
7
+ from openpyxl.utils.cell import column_index_from_string
8
+
9
+ from cellproof.model import Unsupported
10
+ from cellproof.parser import Binary, Call, Postfix, RefNode, Unary, parse
11
+
12
+ CELL = re.compile(r"^(\$?)([A-Za-z]{1,3})(\$?)([0-9]+)$")
13
+ ROW_ONLY = re.compile(r"^\$?[0-9]+$")
14
+ COLUMN_ONLY = re.compile(r"^\$?[A-Za-z]{1,3}$")
15
+ NAME = re.compile(r"^[A-Za-z_\\][A-Za-z0-9_.]*$")
16
+ ROWREF = "__ROWREF_{}__"
17
+
18
+
19
+ def _split(text: str) -> tuple[str, str]:
20
+ if "!" in text:
21
+ sheet, ref = text.rsplit("!", 1)
22
+ return sheet + "!", ref
23
+ return "", text
24
+
25
+
26
+ def classify(text: str, row: int) -> tuple[str, int]:
27
+ """How a reference behaves when its formula is copied down.
28
+ ('same_row', col): one cell of the same sheet and the same row, row part not anchored.
29
+ ('abs', 0): reads the same cells on every row (all row parts anchored, whole columns, names).
30
+ ('bad', 0): anything else that moves with the row (previous/next row, relative ranges, other sheets, whole rows)."""
31
+ if "[" in text or "]" in text:
32
+ return "bad", 0 # structured (Table1[@Col]) and external ([Book]Sheet!A1) references: fail closed
33
+ prefix, ref = _split(text)
34
+ parts = ref.split(":")
35
+ relative = []
36
+ for part in parts:
37
+ m = CELL.match(part)
38
+ if m:
39
+ relative.append((not m.group(3), column_index_from_string(m.group(2).upper()), int(m.group(4))))
40
+ elif ROW_ONLY.match(part):
41
+ return "bad", 0 # whole-row reference
42
+ elif COLUMN_ONLY.match(part) or NAME.match(part):
43
+ continue # whole columns (A:A) and defined names read the same cells on every row
44
+ else:
45
+ return "bad", 0 # anything else is not a reference we can prove is row-independent
46
+ if not any(is_relative for is_relative, _, _ in relative):
47
+ return "abs", 0
48
+ if len(parts) == 1 and not prefix and relative[0][2] == row:
49
+ return "same_row", relative[0][1]
50
+ return "bad", 0
51
+
52
+
53
+ RUNREF = "__RUN_{}_{}__" # a growing SUM over column {} from row {}: a window column of the region
54
+
55
+
56
+ def prev_self(text: str, row: int, col: int) -> bool:
57
+ """The formula's own cell one row up (relative row, same sheet): the previous value of a running total."""
58
+ m = CELL.match(text)
59
+ return (bool(m) and not m.group(3) and int(m.group(4)) == row - 1 >= 1
60
+ and column_index_from_string(m.group(2).upper()) == col)
61
+
62
+
63
+ def growing(text: str, row: int, col: int):
64
+ """(source column, start row) of $B$k:B{row} (start row anchored, end row relative on this row, one column other
65
+ than the formula's own, same sheet), else None."""
66
+ parts = text.split(":")
67
+ if len(parts) != 2:
68
+ return None
69
+ a, b = CELL.match(parts[0]), CELL.match(parts[1])
70
+ if not a or not b or not a.group(3) or b.group(3):
71
+ return None
72
+ src = column_index_from_string(a.group(2).upper())
73
+ start, end = int(a.group(4)), int(b.group(4))
74
+ if column_index_from_string(b.group(2).upper()) != src or src == col or end != row or start > row:
75
+ return None
76
+ return src, start
77
+
78
+
79
+ def _norm(part: str, row: int, col: int) -> str:
80
+ m = CELL.match(part)
81
+ if not m:
82
+ return part
83
+ c = column_index_from_string(m.group(2).upper())
84
+ cs = f"C{c}" if m.group(1) else f"C[{c - col}]"
85
+ rs = f"R{m.group(4)}" if m.group(3) else f"R[{int(m.group(4)) - row}]"
86
+ return rs + cs
87
+
88
+
89
+ def r1c1_key(formula: str, row: int, col: int) -> str:
90
+ """The formula with every reference written relative to its own cell: equal keys = same formula pasted down."""
91
+ out = []
92
+ for tok in Tokenizer("=" + formula).items:
93
+ if tok.type == Token.OPERAND and tok.subtype == Token.RANGE:
94
+ prefix, ref = _split(tok.value)
95
+ out.append(prefix + ":".join(_norm(p, row, col) for p in ref.split(":")))
96
+ else:
97
+ out.append(tok.value)
98
+ return "".join(out)
99
+
100
+
101
+ MAX_ROW = 1048576
102
+
103
+
104
+ class Shape:
105
+ """A formula's tokens with the row numbers of its relative cell references taken out, so the same formula pasted
106
+ to another row of the column can be predicted as text instead of tokenized again (the tokenizer is most of the
107
+ compile time). Equal text means equal tokens: only the digits inside reference operands change, and the tokenizer
108
+ never splits an operand on a digit."""
109
+
110
+ __slots__ = ("key", "formula", "segments", "refs", "_parses")
111
+
112
+ def __init__(self, formula: str, row: int, col: int):
113
+ self.formula, self._parses = formula, None
114
+ key, self.segments, refs = [], [], [] # segments: fixed text, or a reference as [text or row offset, ...]
115
+ for tok in Tokenizer("=" + formula).items:
116
+ if tok.type == Token.OPERAND and tok.subtype == Token.RANGE:
117
+ prefix, ref = _split(tok.value)
118
+ key.append(prefix + ":".join(_norm(p, row, col) for p in ref.split(":")))
119
+ refs.append(tok.value)
120
+ pieces = [prefix]
121
+ for i, part in enumerate(ref.split(":")):
122
+ m = CELL.match(part)
123
+ pieces.append(":" if i else "")
124
+ if m and not m.group(3):
125
+ pieces += [m.group(1) + m.group(2), int(m.group(4)) - row]
126
+ else:
127
+ pieces.append(part)
128
+ self.segments.append(pieces)
129
+ else:
130
+ key.append(tok.value)
131
+ self.segments.append(tok.value)
132
+ self.key, self.refs = "".join(key), tuple(refs) # refs: this cell's reference texts, as written
133
+
134
+ def at(self, row: int) -> tuple[str, tuple[str, ...]] | None:
135
+ """(formula text, reference texts) of this formula pasted to `row`; None when a reference leaves the sheet."""
136
+ text, refs = [], []
137
+ for seg in self.segments:
138
+ if isinstance(seg, str):
139
+ text.append(seg)
140
+ continue
141
+ out = []
142
+ for p in seg:
143
+ if isinstance(p, int):
144
+ r = row + p
145
+ if not 1 <= r <= MAX_ROW:
146
+ return None
147
+ out.append(str(r))
148
+ else:
149
+ out.append(p)
150
+ ref = "".join(out)
151
+ refs.append(ref)
152
+ text.append(ref)
153
+ return "".join(text), tuple(refs)
154
+
155
+ def parses(self) -> bool:
156
+ """Whether the parser accepts it; the same for every cell of the shape (the parser never reads reference text)."""
157
+ if self._parses is None:
158
+ try:
159
+ parse(self.formula)
160
+ self._parses = True
161
+ except (Unsupported, RecursionError):
162
+ self._parses = False
163
+ return self._parses
164
+
165
+
166
+ def lexed(book) -> dict:
167
+ """Formula cell -> (Shape or None when it does not tokenize, its reference texts left to right). Built once per
168
+ book; a run of one formula pasted down a column is tokenized once."""
169
+ got = book.__dict__.get("_lexed")
170
+ if got is not None:
171
+ return got
172
+ got = {}
173
+ for (sheet, col), rows in book._rows_by_col.items():
174
+ shape, last = None, None
175
+ for row in rows:
176
+ cell = book.cells[(sheet, row, col)]
177
+ if cell.formula is None:
178
+ continue
179
+ seen = shape.at(row) if shape is not None and last == row - 1 else None
180
+ if seen is None or seen[0] != cell.formula:
181
+ try:
182
+ shape = Shape(cell.formula, row, col)
183
+ seen = (cell.formula, shape.refs)
184
+ except TokenizerError:
185
+ shape, seen = None, None
186
+ got[(sheet, row, col)] = (shape, seen[1] if seen else ())
187
+ last = row
188
+ book.__dict__["_lexed"] = got
189
+ return got
190
+
191
+
192
+ def map_refs(node, f):
193
+ """The tree with every RefNode replaced by f(node)."""
194
+ if isinstance(node, RefNode):
195
+ return f(node)
196
+ if isinstance(node, (Unary, Postfix)):
197
+ return type(node)(node.op, map_refs(node.operand, f))
198
+ if isinstance(node, Binary):
199
+ return Binary(node.op, map_refs(node.left, f), map_refs(node.right, f))
200
+ if isinstance(node, Call):
201
+ return Call(node.name, tuple(None if a is None else map_refs(a, f) for a in node.args))
202
+ return node
@@ -0,0 +1,97 @@
1
+ """What the compiler needs to know about a rectangle of cells, in O(columns x log rows) instead of O(cells).
2
+
3
+ Per (sheet, column) one Fenwick tree holds a packed counter for every present cell: how many cells of each kind
4
+ (n s b e a blank), how many can hold an error, and how many formula cells are finished. Input cells are counted
5
+ when the index is built; a formula cell is counted by complete() when its batch closes.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from bisect import bisect_left, bisect_right
10
+ from collections import defaultdict
11
+ from dataclasses import dataclass
12
+
13
+ from cellproof.fenwick import Fenwick
14
+ from cellproof.model import Book, Key
15
+
16
+ FIELDS = ("n", "s", "b", "e", "a", "blank", "merr", "fdone")
17
+ BITS = 40 # summary() adds counters across columns: 1,048,576 rows x 16,384 columns = 2**34 < 2**40, no carry
18
+ SHIFT = {name: BITS * i for i, name in enumerate(FIELDS)}
19
+ MASK = (1 << BITS) - 1
20
+ Column = tuple[str, int]
21
+
22
+
23
+ def field(total: int, name: str) -> int:
24
+ return (total >> SHIFT[name]) & MASK
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Summary:
29
+ present: int # non-empty cells in the rectangle
30
+ formulas: int # of those, formula cells
31
+ total: int # packed counters, read with field()
32
+
33
+
34
+ class RangeIndex:
35
+ def __init__(self, book: Book):
36
+ self.book = book
37
+ self.finished: set[Key] = set()
38
+ self.rows = book._rows_by_col # (sheet, col) -> sorted rows that hold a cell
39
+ self.cols: dict[str, list[int]] = defaultdict(list) # sheet -> sorted columns that hold a cell
40
+ for sheet, col in sorted(self.rows):
41
+ self.cols[sheet].append(col)
42
+ self.fen: dict[Column, Fenwick] = {}
43
+ self.formula_prefix: dict[Column, list[int]] = {} # formula cells among the first i cells of the column
44
+ self.text_rows: dict[Column, list[int]] = {} # rows of input text cells
45
+ for (sheet, col), rows in self.rows.items():
46
+ values, prefix, text = [], [0], []
47
+ for row in rows:
48
+ cell = book.cells[(sheet, row, col)]
49
+ if cell.formula is not None:
50
+ values.append(0)
51
+ prefix.append(prefix[-1] + 1)
52
+ continue
53
+ values.append((1 << SHIFT[cell.kind]) + ((1 << SHIFT["merr"]) if cell.kind == "e" else 0))
54
+ prefix.append(prefix[-1])
55
+ if cell.kind == "s":
56
+ text.append(row)
57
+ self.fen[(sheet, col)] = Fenwick(values)
58
+ self.formula_prefix[(sheet, col)] = prefix
59
+ self.text_rows[(sheet, col)] = text
60
+
61
+ def complete(self, key: Key, kind: str, may_err: bool) -> None:
62
+ """A formula cell is finished: kind is its value kind (n s b e a), may_err whether it can hold an error."""
63
+ sheet, row, col = key
64
+ rows = self.rows.get((sheet, col), [])
65
+ pos = bisect_left(rows, row)
66
+ cell = self.book.cells.get(key)
67
+ if pos >= len(rows) or rows[pos] != row or cell is None or cell.formula is None:
68
+ raise ValueError(f"{key} is not a formula cell in the index")
69
+ if key in self.finished:
70
+ raise ValueError(f"{key} was already completed")
71
+ self.finished.add(key)
72
+ add = (1 << SHIFT[kind]) + (1 << SHIFT["fdone"]) + ((1 << SHIFT["merr"]) if may_err else 0)
73
+ self.fen[(sheet, col)].add(pos, add)
74
+
75
+ def _slices(self, sheet: str, r1: int, c1: int, r2: int, c2: int):
76
+ cols = self.cols.get(sheet, [])
77
+ for col in cols[bisect_left(cols, c1):bisect_right(cols, c2)]:
78
+ rows = self.rows[(sheet, col)]
79
+ yield (sheet, col), bisect_left(rows, r1), bisect_right(rows, r2)
80
+
81
+ def summary(self, sheet: str, r1: int, c1: int, r2: int, c2: int) -> Summary:
82
+ present = formulas = total = 0
83
+ for column, i, j in self._slices(sheet, r1, c1, r2, c2):
84
+ if j > i:
85
+ present += j - i
86
+ prefix = self.formula_prefix[column]
87
+ formulas += prefix[j] - prefix[i]
88
+ total += self.fen[column].range(i, j)
89
+ return Summary(present, formulas, total)
90
+
91
+ def text_cells(self, sheet: str, r1: int, c1: int, r2: int, c2: int) -> list[Key]:
92
+ """Input text cells in the rectangle, row-major."""
93
+ found = []
94
+ for (s, col), _, _ in self._slices(sheet, r1, c1, r2, c2):
95
+ rows = self.text_rows[(s, col)]
96
+ found.extend((s, r, col) for r in rows[bisect_left(rows, r1):bisect_right(rows, r2)])
97
+ return sorted(found)
cellproof/reader.py ADDED
@@ -0,0 +1,83 @@
1
+ """Load a workbook twice: once for formulas, once for the values Excel saved."""
2
+ from __future__ import annotations
3
+
4
+ import datetime
5
+ import zipfile
6
+ from pathlib import Path
7
+
8
+ import openpyxl
9
+ from openpyxl.utils.datetime import CALENDAR_MAC_1904, to_excel
10
+ from openpyxl.worksheet.formula import ArrayFormula
11
+
12
+ from cellproof.model import Book, Cell, Kind, Value
13
+
14
+ ERRORS = {"#NULL!", "#DIV/0!", "#VALUE!", "#REF!", "#NAME?", "#NUM!", "#N/A"}
15
+ SUPPORTED = {".xlsx", ".xlsm"}
16
+ # An .xlsx is a zip of XML. A small file that unzips to gigabytes (a "zip bomb") would exhaust memory in openpyxl.
17
+ # ponytail: one total-size limit; raise it if real workbooks ever need more
18
+ MAX_UNZIPPED = 2 * 1024**3
19
+
20
+
21
+ class UnreadableWorkbook(Exception):
22
+ pass
23
+
24
+
25
+ def _saved(cell, epoch) -> tuple[Value, Kind]:
26
+ v = cell.value
27
+ if v is None:
28
+ if cell.data_type == "str": # Excel's cached empty string is None in openpyxl.
29
+ return "", "s"
30
+ return None, "blank"
31
+ if cell.data_type == "e":
32
+ return str(v), "e"
33
+ if isinstance(v, bool): # before int: bool is an int subclass
34
+ return v, "b"
35
+ if isinstance(v, (int, float)):
36
+ return float(v), "n"
37
+ if isinstance(v, (datetime.datetime, datetime.date, datetime.time)):
38
+ return float(to_excel(v, epoch)), "n" # handles Excel's 1900 leap-year bug and the 1904 system
39
+ return str(v), "s"
40
+
41
+
42
+ def load(path: str | Path) -> Book:
43
+ path = Path(path)
44
+ if path.suffix.lower() not in SUPPORTED:
45
+ raise UnreadableWorkbook(f"{path.suffix or 'these'} files are not supported in v1 - save the workbook as .xlsx")
46
+ try:
47
+ with zipfile.ZipFile(path) as z:
48
+ size = sum(i.file_size for i in z.infolist())
49
+ except (OSError, zipfile.BadZipFile) as err:
50
+ raise UnreadableWorkbook(f"cannot open {path.name}: {err}") from err
51
+ if size > MAX_UNZIPPED:
52
+ raise UnreadableWorkbook(f"cannot open {path.name}: it unzips to {size / 1024**3:.1f} GB, more than the "
53
+ f"{MAX_UNZIPPED // 1024**3} GB limit")
54
+ try:
55
+ fwb = openpyxl.load_workbook(path)
56
+ vwb = openpyxl.load_workbook(path, data_only=True)
57
+ except Exception as err: # openpyxl raises many types for corrupt or encrypted files
58
+ raise UnreadableWorkbook(f"cannot open {path.name}: {err}") from err
59
+
60
+ names = {}
61
+ for name, dn in fwb.defined_names.items():
62
+ dests = list(dn.destinations)
63
+ if len(dests) == 1:
64
+ sheet, ref = dests[0]
65
+ names[name.upper()] = (sheet, ref.replace("$", ""))
66
+
67
+ cells, max_row = {}, {}
68
+ for ws in fwb.worksheets:
69
+ vws = vwb[ws.title]
70
+ max_row[ws.title] = ws.max_row
71
+ # ponytail: openpyxl's private _cells holds only the cells the file has; iter_rows() would build
72
+ # every row x column up to max_row (one styled cell at G1048576 cost 6 s; at Z, 3.8 GB)
73
+ for (r, col), c in sorted(ws._cells.items()):
74
+ if c.value is None:
75
+ continue
76
+ value, kind = _saved(vws.cell(row=r, column=col), fwb.epoch)
77
+ formula, array = None, False
78
+ if isinstance(c.value, ArrayFormula):
79
+ formula, array = (c.value.text or "").removeprefix("="), True
80
+ elif c.data_type == "f":
81
+ formula = c.value.removeprefix("=")
82
+ cells[(ws.title, c.row, c.column)] = Cell(ws.title, c.row, c.column, formula, value, kind, array)
83
+ return Book(cells, [ws.title for ws in fwb.worksheets], names, max_row, fwb.epoch == CALENDAR_MAC_1904)
cellproof/refs.py ADDED
@@ -0,0 +1,58 @@
1
+ """Reference text ('Sales'!C:C, $A$1, Rate) -> a concrete rectangle of cells."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from typing import Iterator
6
+
7
+ from openpyxl.utils.cell import range_boundaries
8
+
9
+ from cellproof.model import Book, Key, Unsupported
10
+
11
+ MAX_ROW = 1_048_576
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Area:
16
+ sheet: str
17
+ r1: int
18
+ c1: int
19
+ r2: int
20
+ c2: int
21
+
22
+ @property
23
+ def key(self) -> Key:
24
+ return (self.sheet, self.r1, self.c1)
25
+
26
+ @property
27
+ def is_single(self) -> bool:
28
+ return self.r1 == self.r2 and self.c1 == self.c2
29
+
30
+ def cells(self) -> Iterator[Key]:
31
+ for r in range(self.r1, self.r2 + 1):
32
+ for c in range(self.c1, self.c2 + 1):
33
+ yield (self.sheet, r, c)
34
+
35
+ def present(self, book: Book) -> list[Key]:
36
+ """Non-empty cells in the area, row-major. Use this, not cells(), to read a range."""
37
+ return book.present(self.sheet, self.r1, self.c1, self.r2, self.c2)
38
+
39
+
40
+ def resolve(text: str, sheet: str, book: Book) -> Area:
41
+ ref = text
42
+ if "!" in text:
43
+ sheet, ref = text.rsplit("!", 1)
44
+ if sheet.startswith("'"):
45
+ sheet = sheet[1:-1].replace("''", "'")
46
+ if "[" in sheet:
47
+ raise Unsupported(f"external link {text}")
48
+ elif ref.upper() in book.names:
49
+ sheet, ref = book.names[ref.upper()]
50
+ if sheet not in book.sheets:
51
+ raise Unsupported(f"unknown sheet {sheet!r}")
52
+ try:
53
+ c1, r1, c2, r2 = range_boundaries(ref.replace("$", ""))
54
+ except ValueError:
55
+ raise Unsupported(f"unknown name {text!r}") from None
56
+ if c1 is None:
57
+ raise Unsupported(f"whole-row reference {text}")
58
+ return Area(sheet, r1 or 1, c1, r2 or MAX_ROW, c2) # C:C is every row, as in Excel; Area.present skips empties