modelwright 0.1.0a1__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.
- modelwright/__init__.py +148 -0
- modelwright/cli.py +466 -0
- modelwright/conversion.py +931 -0
- modelwright/evaluation.py +173 -0
- modelwright/execution.py +239 -0
- modelwright/extraction.py +662 -0
- modelwright/formulas.py +571 -0
- modelwright/formulas_oracle.py +153 -0
- modelwright/generation.py +726 -0
- modelwright/graph.py +591 -0
- modelwright/oracle_validation.py +59 -0
- modelwright/oracles.py +132 -0
- modelwright/references.py +209 -0
- modelwright/validation.py +475 -0
- modelwright-0.1.0a1.dist-info/METADATA +160 -0
- modelwright-0.1.0a1.dist-info/RECORD +20 -0
- modelwright-0.1.0a1.dist-info/WHEEL +5 -0
- modelwright-0.1.0a1.dist-info/entry_points.txt +2 -0
- modelwright-0.1.0a1.dist-info/licenses/LICENSE +21 -0
- modelwright-0.1.0a1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
"""Workbook extraction records.
|
|
2
|
+
|
|
3
|
+
These records describe extracted workbook facts; they do not read workbook
|
|
4
|
+
files themselves.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from datetime import date, datetime, time
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
|
|
15
|
+
from openpyxl import load_workbook
|
|
16
|
+
from openpyxl.formula.tokenizer import Tokenizer
|
|
17
|
+
from openpyxl.utils.cell import get_column_letter, range_boundaries
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
JsonValue = str | int | float | bool | None | list[Any] | dict[str, Any]
|
|
21
|
+
CellKind = Literal["value", "formula", "blank", "error"]
|
|
22
|
+
DiagnosticSeverity = Literal["info", "warning", "error"]
|
|
23
|
+
NamedRangeStatus = Literal["resolved", "partially_resolved", "unresolved"]
|
|
24
|
+
|
|
25
|
+
VOLATILE_FUNCTIONS = frozenset({"NOW", "TODAY", "RAND", "RANDBETWEEN", "OFFSET", "INDIRECT"})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class ExtractionDiagnostic:
|
|
30
|
+
"""Extraction or interpretation concern tied to workbook provenance."""
|
|
31
|
+
|
|
32
|
+
code: str
|
|
33
|
+
message: str
|
|
34
|
+
severity: DiagnosticSeverity = "warning"
|
|
35
|
+
location: str | None = None
|
|
36
|
+
raw_value: JsonValue = None
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_dict(cls, data: dict[str, Any]) -> "ExtractionDiagnostic":
|
|
40
|
+
return cls(
|
|
41
|
+
code=data["code"],
|
|
42
|
+
message=data["message"],
|
|
43
|
+
severity=data.get("severity", "warning"),
|
|
44
|
+
location=data.get("location"),
|
|
45
|
+
raw_value=data.get("raw_value"),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> dict[str, JsonValue]:
|
|
49
|
+
return {
|
|
50
|
+
"code": self.code,
|
|
51
|
+
"message": self.message,
|
|
52
|
+
"severity": self.severity,
|
|
53
|
+
"location": self.location,
|
|
54
|
+
"raw_value": self.raw_value,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class FormulaRecord:
|
|
60
|
+
"""Formula text and parsed extraction facts for one formula cell."""
|
|
61
|
+
|
|
62
|
+
raw_formula: str
|
|
63
|
+
tokens: tuple[str, ...] = field(default_factory=tuple)
|
|
64
|
+
raw_references: tuple[str, ...] = field(default_factory=tuple)
|
|
65
|
+
normalized_references: tuple[str, ...] = field(default_factory=tuple)
|
|
66
|
+
functions: tuple[str, ...] = field(default_factory=tuple)
|
|
67
|
+
diagnostics: tuple[ExtractionDiagnostic, ...] = field(default_factory=tuple)
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def from_dict(cls, data: dict[str, Any]) -> "FormulaRecord":
|
|
71
|
+
return cls(
|
|
72
|
+
raw_formula=data["raw_formula"],
|
|
73
|
+
tokens=tuple(data.get("tokens", [])),
|
|
74
|
+
raw_references=tuple(data.get("raw_references", [])),
|
|
75
|
+
normalized_references=tuple(data.get("normalized_references", [])),
|
|
76
|
+
functions=tuple(data.get("functions", [])),
|
|
77
|
+
diagnostics=tuple(ExtractionDiagnostic.from_dict(item) for item in data.get("diagnostics", [])),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def to_dict(self) -> dict[str, JsonValue]:
|
|
81
|
+
return {
|
|
82
|
+
"raw_formula": self.raw_formula,
|
|
83
|
+
"tokens": list(self.tokens),
|
|
84
|
+
"raw_references": list(self.raw_references),
|
|
85
|
+
"normalized_references": list(self.normalized_references),
|
|
86
|
+
"functions": list(self.functions),
|
|
87
|
+
"diagnostics": [diagnostic.to_dict() for diagnostic in self.diagnostics],
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class CellRecord:
|
|
93
|
+
"""Extracted cell facts for one canonical workbook cell reference."""
|
|
94
|
+
|
|
95
|
+
cell_ref: str
|
|
96
|
+
kind: CellKind
|
|
97
|
+
raw_value: JsonValue
|
|
98
|
+
data_type: str | None = None
|
|
99
|
+
cached_value: JsonValue = None
|
|
100
|
+
formula: FormulaRecord | None = None
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def from_dict(cls, data: dict[str, Any]) -> "CellRecord":
|
|
104
|
+
formula_data = data.get("formula")
|
|
105
|
+
return cls(
|
|
106
|
+
cell_ref=data["cell_ref"],
|
|
107
|
+
kind=data["kind"],
|
|
108
|
+
raw_value=data.get("raw_value"),
|
|
109
|
+
data_type=data.get("data_type"),
|
|
110
|
+
cached_value=data.get("cached_value"),
|
|
111
|
+
formula=FormulaRecord.from_dict(formula_data) if formula_data is not None else None,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def to_dict(self) -> dict[str, JsonValue]:
|
|
115
|
+
return {
|
|
116
|
+
"cell_ref": self.cell_ref,
|
|
117
|
+
"kind": self.kind,
|
|
118
|
+
"raw_value": self.raw_value,
|
|
119
|
+
"data_type": self.data_type,
|
|
120
|
+
"cached_value": self.cached_value,
|
|
121
|
+
"formula": self.formula.to_dict() if self.formula is not None else None,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class NamedRangeRecord:
|
|
127
|
+
"""Workbook or worksheet scoped defined name."""
|
|
128
|
+
|
|
129
|
+
name: str
|
|
130
|
+
scope: str
|
|
131
|
+
raw_definition: str
|
|
132
|
+
destinations: tuple[str, ...] = field(default_factory=tuple)
|
|
133
|
+
status: NamedRangeStatus = "unresolved"
|
|
134
|
+
diagnostics: tuple[ExtractionDiagnostic, ...] = field(default_factory=tuple)
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def from_dict(cls, data: dict[str, Any]) -> "NamedRangeRecord":
|
|
138
|
+
return cls(
|
|
139
|
+
name=data["name"],
|
|
140
|
+
scope=data["scope"],
|
|
141
|
+
raw_definition=data["raw_definition"],
|
|
142
|
+
destinations=tuple(data.get("destinations", [])),
|
|
143
|
+
status=data.get("status", "unresolved"),
|
|
144
|
+
diagnostics=tuple(ExtractionDiagnostic.from_dict(item) for item in data.get("diagnostics", [])),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def to_dict(self) -> dict[str, JsonValue]:
|
|
148
|
+
return {
|
|
149
|
+
"name": self.name,
|
|
150
|
+
"scope": self.scope,
|
|
151
|
+
"raw_definition": self.raw_definition,
|
|
152
|
+
"destinations": list(self.destinations),
|
|
153
|
+
"status": self.status,
|
|
154
|
+
"diagnostics": [diagnostic.to_dict() for diagnostic in self.diagnostics],
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True)
|
|
159
|
+
class TableRecord:
|
|
160
|
+
"""Worksheet table metadata needed to resolve structured references."""
|
|
161
|
+
|
|
162
|
+
name: str
|
|
163
|
+
sheet: str
|
|
164
|
+
ref: str
|
|
165
|
+
columns: tuple[str, ...] = field(default_factory=tuple)
|
|
166
|
+
|
|
167
|
+
@classmethod
|
|
168
|
+
def from_dict(cls, data: dict[str, Any]) -> "TableRecord":
|
|
169
|
+
return cls(
|
|
170
|
+
name=data["name"],
|
|
171
|
+
sheet=data["sheet"],
|
|
172
|
+
ref=data["ref"],
|
|
173
|
+
columns=tuple(data.get("columns", [])),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def to_dict(self) -> dict[str, JsonValue]:
|
|
177
|
+
return {
|
|
178
|
+
"name": self.name,
|
|
179
|
+
"sheet": self.sheet,
|
|
180
|
+
"ref": self.ref,
|
|
181
|
+
"columns": list(self.columns),
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True)
|
|
186
|
+
class SheetRecord:
|
|
187
|
+
"""Worksheet identity and ordering facts."""
|
|
188
|
+
|
|
189
|
+
sheet_id: str
|
|
190
|
+
title: str
|
|
191
|
+
state: str
|
|
192
|
+
index: int
|
|
193
|
+
|
|
194
|
+
@classmethod
|
|
195
|
+
def from_dict(cls, data: dict[str, Any]) -> "SheetRecord":
|
|
196
|
+
return cls(
|
|
197
|
+
sheet_id=data["sheet_id"],
|
|
198
|
+
title=data["title"],
|
|
199
|
+
state=data["state"],
|
|
200
|
+
index=data["index"],
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def to_dict(self) -> dict[str, JsonValue]:
|
|
204
|
+
return {
|
|
205
|
+
"sheet_id": self.sheet_id,
|
|
206
|
+
"title": self.title,
|
|
207
|
+
"state": self.state,
|
|
208
|
+
"index": self.index,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass(frozen=True)
|
|
213
|
+
class WorkbookRecord:
|
|
214
|
+
"""Extracted facts for one source workbook."""
|
|
215
|
+
|
|
216
|
+
workbook_id: str
|
|
217
|
+
source_path: str
|
|
218
|
+
sheets: tuple[SheetRecord, ...] = field(default_factory=tuple)
|
|
219
|
+
cells: tuple[CellRecord, ...] = field(default_factory=tuple)
|
|
220
|
+
named_ranges: tuple[NamedRangeRecord, ...] = field(default_factory=tuple)
|
|
221
|
+
tables: tuple[TableRecord, ...] = field(default_factory=tuple)
|
|
222
|
+
diagnostics: tuple[ExtractionDiagnostic, ...] = field(default_factory=tuple)
|
|
223
|
+
|
|
224
|
+
@classmethod
|
|
225
|
+
def from_dict(cls, data: dict[str, Any]) -> "WorkbookRecord":
|
|
226
|
+
return cls(
|
|
227
|
+
workbook_id=data["workbook_id"],
|
|
228
|
+
source_path=data["source_path"],
|
|
229
|
+
sheets=tuple(SheetRecord.from_dict(item) for item in data.get("sheets", [])),
|
|
230
|
+
cells=tuple(CellRecord.from_dict(item) for item in data.get("cells", [])),
|
|
231
|
+
named_ranges=tuple(NamedRangeRecord.from_dict(item) for item in data.get("named_ranges", [])),
|
|
232
|
+
tables=tuple(TableRecord.from_dict(item) for item in data.get("tables", [])),
|
|
233
|
+
diagnostics=tuple(ExtractionDiagnostic.from_dict(item) for item in data.get("diagnostics", [])),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def to_dict(self) -> dict[str, JsonValue]:
|
|
237
|
+
return {
|
|
238
|
+
"workbook_id": self.workbook_id,
|
|
239
|
+
"source_path": self.source_path,
|
|
240
|
+
"sheets": [sheet.to_dict() for sheet in self.sheets],
|
|
241
|
+
"cells": [cell.to_dict() for cell in self.cells],
|
|
242
|
+
"named_ranges": [named_range.to_dict() for named_range in self.named_ranges],
|
|
243
|
+
"tables": [table.to_dict() for table in self.tables],
|
|
244
|
+
"diagnostics": [diagnostic.to_dict() for diagnostic in self.diagnostics],
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def extract_workbook(path: str | Path, progress: Callable[[str], None] | None = None) -> WorkbookRecord:
|
|
249
|
+
"""Extract workbook facts with openpyxl into Modelwright records."""
|
|
250
|
+
|
|
251
|
+
workbook_path = Path(path)
|
|
252
|
+
_progress(progress, "load_workbook formulas start")
|
|
253
|
+
workbook = load_workbook(workbook_path, data_only=False)
|
|
254
|
+
_progress(progress, "load_workbook formulas done")
|
|
255
|
+
_progress(progress, "load_workbook cached_values start")
|
|
256
|
+
cached_workbook = load_workbook(workbook_path, data_only=True)
|
|
257
|
+
_progress(progress, "load_workbook cached_values done")
|
|
258
|
+
|
|
259
|
+
_progress(progress, "workbook diagnostics start")
|
|
260
|
+
diagnostics = _workbook_diagnostics(workbook)
|
|
261
|
+
_progress(progress, "workbook diagnostics done")
|
|
262
|
+
sheets = tuple(
|
|
263
|
+
SheetRecord(
|
|
264
|
+
sheet_id=worksheet.title,
|
|
265
|
+
title=worksheet.title,
|
|
266
|
+
state=worksheet.sheet_state,
|
|
267
|
+
index=index,
|
|
268
|
+
)
|
|
269
|
+
for index, worksheet in enumerate(workbook.worksheets)
|
|
270
|
+
)
|
|
271
|
+
_progress(progress, f"sheets extracted count={len(sheets)}")
|
|
272
|
+
_progress(progress, "tables start")
|
|
273
|
+
tables = tuple(table for worksheet in workbook.worksheets for table in _extract_tables(worksheet))
|
|
274
|
+
_progress(progress, f"tables done count={len(tables)}")
|
|
275
|
+
_progress(progress, "named ranges start")
|
|
276
|
+
named_ranges = tuple(
|
|
277
|
+
_extract_named_range(name, defined_name, tables=tables) for name, defined_name in workbook.defined_names.items()
|
|
278
|
+
)
|
|
279
|
+
_progress(progress, f"named ranges done count={len(named_ranges)}")
|
|
280
|
+
|
|
281
|
+
cell_records: list[CellRecord] = []
|
|
282
|
+
for index, worksheet in enumerate(workbook.worksheets, start=1):
|
|
283
|
+
populated_cells = _populated_cells(worksheet)
|
|
284
|
+
_progress(
|
|
285
|
+
progress,
|
|
286
|
+
f"sheet cells start index={index}/{len(workbook.worksheets)} populated={len(populated_cells)}",
|
|
287
|
+
)
|
|
288
|
+
sheet_cells = _extract_sheet_cells(
|
|
289
|
+
worksheet,
|
|
290
|
+
cached_workbook[worksheet.title],
|
|
291
|
+
populated_cells=populated_cells,
|
|
292
|
+
)
|
|
293
|
+
cell_records.extend(sheet_cells)
|
|
294
|
+
_progress(
|
|
295
|
+
progress,
|
|
296
|
+
f"sheet cells done index={index}/{len(workbook.worksheets)} extracted={len(sheet_cells)} total={len(cell_records)}",
|
|
297
|
+
)
|
|
298
|
+
cells = tuple(cell_records)
|
|
299
|
+
_progress(progress, f"workbook extraction done cells={len(cells)}")
|
|
300
|
+
|
|
301
|
+
return WorkbookRecord(
|
|
302
|
+
workbook_id=workbook_path.name,
|
|
303
|
+
source_path=str(workbook_path),
|
|
304
|
+
sheets=sheets,
|
|
305
|
+
cells=cells,
|
|
306
|
+
named_ranges=named_ranges,
|
|
307
|
+
tables=tables,
|
|
308
|
+
diagnostics=diagnostics,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _workbook_diagnostics(workbook: Any) -> tuple[ExtractionDiagnostic, ...]:
|
|
313
|
+
diagnostics: list[ExtractionDiagnostic] = []
|
|
314
|
+
if getattr(workbook, "vba_archive", None) is not None:
|
|
315
|
+
diagnostics.append(
|
|
316
|
+
ExtractionDiagnostic(
|
|
317
|
+
code="unsupported_macros",
|
|
318
|
+
message="workbook contains macros, which are not extracted",
|
|
319
|
+
severity="warning",
|
|
320
|
+
location="workbook",
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
if getattr(workbook, "_external_links", None):
|
|
324
|
+
diagnostics.append(
|
|
325
|
+
ExtractionDiagnostic(
|
|
326
|
+
code="unsupported_external_link",
|
|
327
|
+
message="workbook contains external links, which are not extracted",
|
|
328
|
+
severity="warning",
|
|
329
|
+
location="workbook",
|
|
330
|
+
)
|
|
331
|
+
)
|
|
332
|
+
return tuple(diagnostics)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _extract_named_range(name: str, defined_name: Any, *, tables: tuple[TableRecord, ...] = ()) -> NamedRangeRecord:
|
|
336
|
+
diagnostics: tuple[ExtractionDiagnostic, ...] = ()
|
|
337
|
+
try:
|
|
338
|
+
destinations = tuple(_cell_ref(sheet_name, coordinate) for sheet_name, coordinate in defined_name.destinations)
|
|
339
|
+
except Exception:
|
|
340
|
+
destinations = ()
|
|
341
|
+
if not destinations:
|
|
342
|
+
structured_destination = _structured_defined_name_destination(str(defined_name.attr_text), tables)
|
|
343
|
+
if structured_destination is not None:
|
|
344
|
+
destinations = (structured_destination,)
|
|
345
|
+
status: NamedRangeStatus = "resolved" if destinations else "unresolved"
|
|
346
|
+
if not destinations:
|
|
347
|
+
code = "named_range_source_error" if str(defined_name.attr_text).upper() == "#REF!" else "unresolved_named_range"
|
|
348
|
+
message = (
|
|
349
|
+
"named range definition contains a source workbook error"
|
|
350
|
+
if code == "named_range_source_error"
|
|
351
|
+
else "named range destinations could not be resolved"
|
|
352
|
+
)
|
|
353
|
+
diagnostics = (
|
|
354
|
+
ExtractionDiagnostic(
|
|
355
|
+
code=code,
|
|
356
|
+
message=message,
|
|
357
|
+
severity="warning",
|
|
358
|
+
location=name,
|
|
359
|
+
raw_value=defined_name.attr_text,
|
|
360
|
+
),
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
scope = "workbook"
|
|
364
|
+
local_sheet_id = getattr(defined_name, "localSheetId", None)
|
|
365
|
+
if local_sheet_id is not None:
|
|
366
|
+
scope = f"sheet:{local_sheet_id}"
|
|
367
|
+
|
|
368
|
+
return NamedRangeRecord(
|
|
369
|
+
name=name,
|
|
370
|
+
scope=scope,
|
|
371
|
+
raw_definition=defined_name.attr_text,
|
|
372
|
+
destinations=destinations,
|
|
373
|
+
status=status,
|
|
374
|
+
diagnostics=diagnostics,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _extract_tables(worksheet: Any) -> tuple[TableRecord, ...]:
|
|
379
|
+
records: list[TableRecord] = []
|
|
380
|
+
for table in worksheet.tables.values():
|
|
381
|
+
try:
|
|
382
|
+
min_col, header_row, max_col, _max_row = range_boundaries(table.ref)
|
|
383
|
+
except ValueError:
|
|
384
|
+
continue
|
|
385
|
+
|
|
386
|
+
columns = tuple(
|
|
387
|
+
str(worksheet[f"{get_column_letter(column)}{header_row}"].value)
|
|
388
|
+
for column in range(min_col, max_col + 1)
|
|
389
|
+
)
|
|
390
|
+
records.append(
|
|
391
|
+
TableRecord(
|
|
392
|
+
name=table.displayName,
|
|
393
|
+
sheet=worksheet.title,
|
|
394
|
+
ref=table.ref.replace("$", ""),
|
|
395
|
+
columns=columns,
|
|
396
|
+
)
|
|
397
|
+
)
|
|
398
|
+
return tuple(records)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _structured_defined_name_destination(definition: str, tables: tuple[TableRecord, ...]) -> str | None:
|
|
402
|
+
parsed = _parse_structured_defined_name(definition)
|
|
403
|
+
if parsed is None:
|
|
404
|
+
return None
|
|
405
|
+
|
|
406
|
+
table = next((candidate for candidate in tables if candidate.name == parsed.table_name), None)
|
|
407
|
+
if table is None:
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
try:
|
|
411
|
+
min_col, min_row, max_col, max_row = range_boundaries(table.ref)
|
|
412
|
+
except ValueError:
|
|
413
|
+
return None
|
|
414
|
+
|
|
415
|
+
try:
|
|
416
|
+
column_offset = table.columns.index(parsed.column)
|
|
417
|
+
except ValueError:
|
|
418
|
+
return None
|
|
419
|
+
|
|
420
|
+
column_name = get_column_letter(min_col + column_offset)
|
|
421
|
+
start_row = min_row if parsed.include_headers else min_row + 1
|
|
422
|
+
end_row = max_row
|
|
423
|
+
if start_row > end_row:
|
|
424
|
+
return None
|
|
425
|
+
return f"{table.sheet}!{column_name}{start_row}:{column_name}{end_row}"
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
@dataclass(frozen=True)
|
|
429
|
+
class _StructuredDefinedName:
|
|
430
|
+
table_name: str
|
|
431
|
+
column: str
|
|
432
|
+
include_headers: bool = False
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _parse_structured_defined_name(definition: str) -> _StructuredDefinedName | None:
|
|
436
|
+
if not _is_structured_reference(definition):
|
|
437
|
+
return None
|
|
438
|
+
|
|
439
|
+
table_name = definition.split("[", 1)[0]
|
|
440
|
+
if not table_name:
|
|
441
|
+
return None
|
|
442
|
+
|
|
443
|
+
bracketed_parts = _bracketed_parts(definition)
|
|
444
|
+
column = next(
|
|
445
|
+
(
|
|
446
|
+
_clean_structured_selector(part)
|
|
447
|
+
for part in reversed(bracketed_parts)
|
|
448
|
+
if not part.startswith("#")
|
|
449
|
+
),
|
|
450
|
+
None,
|
|
451
|
+
)
|
|
452
|
+
if column is None:
|
|
453
|
+
return None
|
|
454
|
+
return _StructuredDefinedName(
|
|
455
|
+
table_name=table_name,
|
|
456
|
+
column=column,
|
|
457
|
+
include_headers=any(part in {"#All", "#Headers"} for part in bracketed_parts),
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _extract_sheet_cells(
|
|
462
|
+
worksheet: Any,
|
|
463
|
+
cached_worksheet: Any,
|
|
464
|
+
*,
|
|
465
|
+
populated_cells: tuple[Any, ...] | None = None,
|
|
466
|
+
) -> tuple[CellRecord, ...]:
|
|
467
|
+
records: list[CellRecord] = []
|
|
468
|
+
for cell in populated_cells if populated_cells is not None else _populated_cells(worksheet):
|
|
469
|
+
if cell.value is None:
|
|
470
|
+
continue
|
|
471
|
+
|
|
472
|
+
cell_ref = _cell_ref(worksheet.title, cell.coordinate)
|
|
473
|
+
cached_value = cached_worksheet[cell.coordinate].value
|
|
474
|
+
if cell.data_type == "f":
|
|
475
|
+
formula = _extract_formula(cell_ref, str(cell.value), cached_value)
|
|
476
|
+
records.append(
|
|
477
|
+
CellRecord(
|
|
478
|
+
cell_ref=cell_ref,
|
|
479
|
+
kind="formula",
|
|
480
|
+
raw_value=_json_value(cell.value),
|
|
481
|
+
data_type=cell.data_type,
|
|
482
|
+
cached_value=_json_value(cached_value),
|
|
483
|
+
formula=formula,
|
|
484
|
+
)
|
|
485
|
+
)
|
|
486
|
+
continue
|
|
487
|
+
|
|
488
|
+
records.append(
|
|
489
|
+
CellRecord(
|
|
490
|
+
cell_ref=cell_ref,
|
|
491
|
+
kind="value",
|
|
492
|
+
raw_value=_json_value(cell.value),
|
|
493
|
+
data_type=cell.data_type,
|
|
494
|
+
cached_value=_json_value(cached_value),
|
|
495
|
+
formula=None,
|
|
496
|
+
)
|
|
497
|
+
)
|
|
498
|
+
return tuple(records)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _populated_cells(worksheet: Any) -> tuple[Any, ...]:
|
|
502
|
+
cells = getattr(worksheet, "_cells", None)
|
|
503
|
+
if isinstance(cells, dict):
|
|
504
|
+
return tuple(cell for _, cell in sorted(cells.items()))
|
|
505
|
+
|
|
506
|
+
return tuple(cell for row in worksheet.iter_rows() for cell in row)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _progress(progress: Callable[[str], None] | None, message: str) -> None:
|
|
510
|
+
if progress is not None:
|
|
511
|
+
progress(message)
|
|
512
|
+
|
|
513
|
+
def _extract_formula(cell_ref: str, raw_formula: str, cached_value: JsonValue) -> FormulaRecord:
|
|
514
|
+
try:
|
|
515
|
+
tokenizer = Tokenizer(raw_formula)
|
|
516
|
+
except Exception as error:
|
|
517
|
+
return FormulaRecord(
|
|
518
|
+
raw_formula=raw_formula,
|
|
519
|
+
diagnostics=(
|
|
520
|
+
ExtractionDiagnostic(
|
|
521
|
+
code="formula_tokenization_failed",
|
|
522
|
+
message=f"formula could not be tokenized: {error}",
|
|
523
|
+
severity="warning",
|
|
524
|
+
location=cell_ref,
|
|
525
|
+
raw_value=raw_formula,
|
|
526
|
+
),
|
|
527
|
+
),
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
tokens = tuple(token.value for token in tokenizer.items)
|
|
531
|
+
raw_references = tuple(
|
|
532
|
+
token.value for token in tokenizer.items if token.type == "OPERAND" and token.subtype == "RANGE"
|
|
533
|
+
)
|
|
534
|
+
functions = tuple(
|
|
535
|
+
token.value[:-1].upper() for token in tokenizer.items if token.type == "FUNC" and token.subtype == "OPEN"
|
|
536
|
+
)
|
|
537
|
+
diagnostics = _formula_diagnostics(
|
|
538
|
+
cell_ref=cell_ref,
|
|
539
|
+
raw_formula=raw_formula,
|
|
540
|
+
cached_value=cached_value,
|
|
541
|
+
functions=functions,
|
|
542
|
+
raw_references=raw_references,
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
return FormulaRecord(
|
|
546
|
+
raw_formula=raw_formula,
|
|
547
|
+
tokens=tokens,
|
|
548
|
+
raw_references=raw_references,
|
|
549
|
+
normalized_references=(),
|
|
550
|
+
functions=functions,
|
|
551
|
+
diagnostics=diagnostics,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _formula_diagnostics(
|
|
556
|
+
*,
|
|
557
|
+
cell_ref: str,
|
|
558
|
+
raw_formula: str,
|
|
559
|
+
cached_value: JsonValue,
|
|
560
|
+
functions: tuple[str, ...],
|
|
561
|
+
raw_references: tuple[str, ...],
|
|
562
|
+
) -> tuple[ExtractionDiagnostic, ...]:
|
|
563
|
+
diagnostics: list[ExtractionDiagnostic] = []
|
|
564
|
+
if cached_value is None:
|
|
565
|
+
diagnostics.append(
|
|
566
|
+
ExtractionDiagnostic(
|
|
567
|
+
code="missing_cached_formula_value",
|
|
568
|
+
message="formula cell has no cached value",
|
|
569
|
+
severity="warning",
|
|
570
|
+
location=cell_ref,
|
|
571
|
+
raw_value=raw_formula,
|
|
572
|
+
)
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
for function in functions:
|
|
576
|
+
if function in VOLATILE_FUNCTIONS:
|
|
577
|
+
diagnostics.append(
|
|
578
|
+
ExtractionDiagnostic(
|
|
579
|
+
code="unsupported_volatile_function",
|
|
580
|
+
message=f"formula uses volatile function {function}",
|
|
581
|
+
severity="warning",
|
|
582
|
+
location=cell_ref,
|
|
583
|
+
raw_value=raw_formula,
|
|
584
|
+
)
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
for reference in raw_references:
|
|
588
|
+
if _is_external_reference(reference):
|
|
589
|
+
diagnostics.append(
|
|
590
|
+
ExtractionDiagnostic(
|
|
591
|
+
code="unsupported_external_link",
|
|
592
|
+
message="formula references an external workbook",
|
|
593
|
+
severity="warning",
|
|
594
|
+
location=cell_ref,
|
|
595
|
+
raw_value=reference,
|
|
596
|
+
)
|
|
597
|
+
)
|
|
598
|
+
continue
|
|
599
|
+
|
|
600
|
+
if _is_structured_reference(reference):
|
|
601
|
+
diagnostics.append(
|
|
602
|
+
ExtractionDiagnostic(
|
|
603
|
+
code="unsupported_structured_reference",
|
|
604
|
+
message="formula uses an Excel structured reference",
|
|
605
|
+
severity="warning",
|
|
606
|
+
location=cell_ref,
|
|
607
|
+
raw_value=reference,
|
|
608
|
+
)
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
return tuple(diagnostics)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _cell_ref(sheet_name: str, coordinate: str) -> str:
|
|
615
|
+
return f"{sheet_name}!{coordinate.replace('$', '')}"
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _json_value(value: Any) -> JsonValue:
|
|
619
|
+
if isinstance(value, str | int | float | bool) or value is None:
|
|
620
|
+
return value
|
|
621
|
+
if isinstance(value, datetime | date | time):
|
|
622
|
+
return value.isoformat()
|
|
623
|
+
return str(value)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _is_external_reference(reference: str) -> bool:
|
|
627
|
+
return "[" in reference and "]" in reference and ("." in reference.split("]", 1)[0] or "!" in reference)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _is_structured_reference(reference: str) -> bool:
|
|
631
|
+
return "[" in reference and "]" in reference and not _is_external_reference(reference)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _bracketed_parts(reference: str) -> tuple[str, ...]:
|
|
635
|
+
parts: list[str] = []
|
|
636
|
+
current: list[str] = []
|
|
637
|
+
depth = 0
|
|
638
|
+
for character in reference:
|
|
639
|
+
if character == "[":
|
|
640
|
+
if depth > 0:
|
|
641
|
+
current.append(character)
|
|
642
|
+
depth += 1
|
|
643
|
+
continue
|
|
644
|
+
if character == "]":
|
|
645
|
+
depth -= 1
|
|
646
|
+
if depth == 0:
|
|
647
|
+
part = "".join(current)
|
|
648
|
+
current = []
|
|
649
|
+
if part.startswith("[") and part.endswith("]"):
|
|
650
|
+
parts.extend(_bracketed_parts(part))
|
|
651
|
+
elif part:
|
|
652
|
+
parts.append(part)
|
|
653
|
+
continue
|
|
654
|
+
current.append(character)
|
|
655
|
+
continue
|
|
656
|
+
if depth > 0:
|
|
657
|
+
current.append(character)
|
|
658
|
+
return tuple(parts)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _clean_structured_selector(selector: str) -> str:
|
|
662
|
+
return selector.removeprefix("@").replace("''", "'")
|