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/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
|
cellproof/rangeindex.py
ADDED
|
@@ -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
|