zalos-sheetops 0.4.2__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.
- zalos_sheetops/__init__.py +5 -0
- zalos_sheetops/benchmarks/__init__.py +1 -0
- zalos_sheetops/benchmarks/large_xlsx.py +361 -0
- zalos_sheetops/cli.py +1591 -0
- zalos_sheetops/command_registry.py +227 -0
- zalos_sheetops/compare/__init__.py +1 -0
- zalos_sheetops/compare/workbooks.py +265 -0
- zalos_sheetops/contracts.py +32 -0
- zalos_sheetops/diff/__init__.py +1 -0
- zalos_sheetops/diff/tabular.py +204 -0
- zalos_sheetops/display/__init__.py +1 -0
- zalos_sheetops/display/scan.py +309 -0
- zalos_sheetops/display/widths.py +125 -0
- zalos_sheetops/display/xlsx_metadata.py +127 -0
- zalos_sheetops/errors.py +11 -0
- zalos_sheetops/formulas/__init__.py +1 -0
- zalos_sheetops/formulas/scan.py +333 -0
- zalos_sheetops/formulas/xml_scan.py +324 -0
- zalos_sheetops/hyperlinks/__init__.py +1 -0
- zalos_sheetops/hyperlinks/assertions.py +390 -0
- zalos_sheetops/io/__init__.py +1 -0
- zalos_sheetops/io/cellref.py +141 -0
- zalos_sheetops/io/headers.py +27 -0
- zalos_sheetops/io/inspect.py +98 -0
- zalos_sheetops/io/read_cells.py +231 -0
- zalos_sheetops/io/read_csv_table.py +45 -0
- zalos_sheetops/io/read_table.py +240 -0
- zalos_sheetops/io/search_cells.py +532 -0
- zalos_sheetops/io/xlsx_xml.py +1654 -0
- zalos_sheetops/normalize/__init__.py +1 -0
- zalos_sheetops/normalize/amounts.py +51 -0
- zalos_sheetops/normalize/dates.py +25 -0
- zalos_sheetops/normalize/preview.py +148 -0
- zalos_sheetops/normalize/text.py +22 -0
- zalos_sheetops/paths.py +28 -0
- zalos_sheetops/recalc/__init__.py +1 -0
- zalos_sheetops/recalc/libreoffice.py +119 -0
- zalos_sheetops/reconcile/__init__.py +1 -0
- zalos_sheetops/reconcile/exact.py +183 -0
- zalos_sheetops/runtime/__init__.py +1 -0
- zalos_sheetops/runtime/libreoffice.py +130 -0
- zalos_sheetops/styles/__init__.py +1 -0
- zalos_sheetops/styles/assertions.py +253 -0
- zalos_sheetops/styles/read.py +398 -0
- zalos_sheetops/styles/xml_assertions.py +372 -0
- zalos_sheetops/testing.py +43 -0
- zalos_sheetops/validation/__init__.py +1 -0
- zalos_sheetops/validation/workbook.py +451 -0
- zalos_sheetops-0.4.2.dist-info/METADATA +141 -0
- zalos_sheetops-0.4.2.dist-info/RECORD +53 -0
- zalos_sheetops-0.4.2.dist-info/WHEEL +4 -0
- zalos_sheetops-0.4.2.dist-info/entry_points.txt +2 -0
- zalos_sheetops-0.4.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Benchmark helpers for non-CI SheetOps performance checks."""
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import cProfile
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
import tracemalloc
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from openpyxl import Workbook
|
|
11
|
+
|
|
12
|
+
from zalos_sheetops.errors import SheetOpsError
|
|
13
|
+
from zalos_sheetops.io.inspect import inspect_workbook
|
|
14
|
+
from zalos_sheetops.io.read_table import read_table
|
|
15
|
+
from zalos_sheetops.io.search_cells import search_cells
|
|
16
|
+
from zalos_sheetops.normalize.preview import preview_normalization
|
|
17
|
+
from zalos_sheetops.validation.workbook import validate_workbook
|
|
18
|
+
|
|
19
|
+
DEFAULT_SCENARIOS = [
|
|
20
|
+
"inspect",
|
|
21
|
+
"validate",
|
|
22
|
+
"read-table",
|
|
23
|
+
"normalize-preview",
|
|
24
|
+
"cells-search",
|
|
25
|
+
]
|
|
26
|
+
DEFAULT_ROWS = 100_000
|
|
27
|
+
DEFAULT_COLUMNS = 12
|
|
28
|
+
SHEET_NAME = "Data"
|
|
29
|
+
KEY_COLUMN = "record_id"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def generate_large_xlsx_fixture(
|
|
33
|
+
path: Path,
|
|
34
|
+
*,
|
|
35
|
+
rows: int,
|
|
36
|
+
columns: int,
|
|
37
|
+
) -> dict[str, object]:
|
|
38
|
+
_require_positive_int(rows, "rows")
|
|
39
|
+
_require_positive_int(columns, "columns")
|
|
40
|
+
if columns < 4:
|
|
41
|
+
raise ValueError("columns must be at least 4")
|
|
42
|
+
|
|
43
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
headers = _headers(columns)
|
|
45
|
+
workbook = Workbook(write_only=True)
|
|
46
|
+
sheet = workbook.create_sheet(SHEET_NAME)
|
|
47
|
+
sheet.append(headers)
|
|
48
|
+
for row_number in range(1, rows + 1):
|
|
49
|
+
sheet.append(_data_row(row_number, columns))
|
|
50
|
+
workbook.save(path)
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
"path": str(path),
|
|
54
|
+
"sheet": SHEET_NAME,
|
|
55
|
+
"rows": rows,
|
|
56
|
+
"columns": columns,
|
|
57
|
+
"headers": headers,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def run_benchmark_plan(
|
|
62
|
+
*,
|
|
63
|
+
workbook_path: Path,
|
|
64
|
+
output_path: Path,
|
|
65
|
+
rows: int,
|
|
66
|
+
columns: int,
|
|
67
|
+
scenarios: list[str] | None = None,
|
|
68
|
+
cpu_profile_dir: Path | None = None,
|
|
69
|
+
) -> dict[str, object]:
|
|
70
|
+
scenario_names = _normalize_scenarios(scenarios)
|
|
71
|
+
generate_large_xlsx_fixture(workbook_path, rows=rows, columns=columns)
|
|
72
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
if cpu_profile_dir is not None:
|
|
74
|
+
cpu_profile_dir.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
|
|
76
|
+
records = [
|
|
77
|
+
run_benchmark_scenario(
|
|
78
|
+
scenario,
|
|
79
|
+
workbook_path=workbook_path,
|
|
80
|
+
rows=rows,
|
|
81
|
+
columns=columns,
|
|
82
|
+
cpu_profile_dir=cpu_profile_dir,
|
|
83
|
+
)
|
|
84
|
+
for scenario in scenario_names
|
|
85
|
+
]
|
|
86
|
+
output_path.write_text(
|
|
87
|
+
"".join(f"{json.dumps(record, sort_keys=True)}\n" for record in records),
|
|
88
|
+
encoding="utf-8",
|
|
89
|
+
)
|
|
90
|
+
return {
|
|
91
|
+
"workbook": str(workbook_path),
|
|
92
|
+
"output": str(output_path),
|
|
93
|
+
"scenarioCount": len(records),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def run_benchmark_scenario(
|
|
98
|
+
scenario: str,
|
|
99
|
+
*,
|
|
100
|
+
workbook_path: Path,
|
|
101
|
+
rows: int,
|
|
102
|
+
columns: int,
|
|
103
|
+
cpu_profile_dir: Path | None = None,
|
|
104
|
+
) -> dict[str, object]:
|
|
105
|
+
operation = _scenario_operation(scenario, workbook_path)
|
|
106
|
+
profile_path = (
|
|
107
|
+
cpu_profile_dir / f"{scenario}.prof" if cpu_profile_dir is not None else None
|
|
108
|
+
)
|
|
109
|
+
measurement = _measure(operation, profile_path)
|
|
110
|
+
record = {
|
|
111
|
+
"scenario": scenario,
|
|
112
|
+
"ok": measurement["ok"],
|
|
113
|
+
"workbook": str(workbook_path),
|
|
114
|
+
"rows": rows,
|
|
115
|
+
"columns": columns,
|
|
116
|
+
"elapsedSeconds": measurement["elapsedSeconds"],
|
|
117
|
+
"peakTracedBytes": measurement["peakTracedBytes"],
|
|
118
|
+
"maxRssBytes": _max_rss_bytes(),
|
|
119
|
+
}
|
|
120
|
+
if measurement["ok"]:
|
|
121
|
+
record["summary"] = _scenario_summary(scenario, measurement["data"])
|
|
122
|
+
else:
|
|
123
|
+
record["error"] = measurement["error"]
|
|
124
|
+
return record
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def main(argv: list[str] | None = None) -> int:
|
|
128
|
+
args = _parse_args(argv)
|
|
129
|
+
result = run_benchmark_plan(
|
|
130
|
+
workbook_path=args.workbook,
|
|
131
|
+
output_path=args.output,
|
|
132
|
+
rows=args.rows,
|
|
133
|
+
columns=args.columns,
|
|
134
|
+
scenarios=_scenario_args(args.scenarios),
|
|
135
|
+
cpu_profile_dir=args.cpu_profile_dir,
|
|
136
|
+
)
|
|
137
|
+
print(json.dumps(result, sort_keys=True))
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _headers(columns: int) -> list[str]:
|
|
142
|
+
base_headers = [KEY_COLUMN, "amount", "status"]
|
|
143
|
+
formula_headers = [
|
|
144
|
+
f"formula_{index}" for index in range(1, columns - len(base_headers) + 1)
|
|
145
|
+
]
|
|
146
|
+
return base_headers + formula_headers
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _data_row(row_number: int, columns: int) -> list[object]:
|
|
150
|
+
excel_row = row_number + 1
|
|
151
|
+
formula_count = columns - 3
|
|
152
|
+
return [
|
|
153
|
+
f"REC-{row_number:07d}",
|
|
154
|
+
row_number * 10,
|
|
155
|
+
"open" if row_number % 2 else "closed",
|
|
156
|
+
*[f"=B{excel_row}*{index}" for index in range(1, formula_count + 1)],
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _normalize_scenarios(scenarios: list[str] | None) -> list[str]:
|
|
161
|
+
names = scenarios or DEFAULT_SCENARIOS
|
|
162
|
+
invalid = [scenario for scenario in names if scenario not in DEFAULT_SCENARIOS]
|
|
163
|
+
if not names or invalid:
|
|
164
|
+
raise ValueError(
|
|
165
|
+
"scenarios must be a non-empty list of supported scenarios",
|
|
166
|
+
)
|
|
167
|
+
return names
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _scenario_operation(
|
|
171
|
+
scenario: str,
|
|
172
|
+
workbook_path: Path,
|
|
173
|
+
) -> Callable[[], dict[str, object]]:
|
|
174
|
+
if scenario == "inspect":
|
|
175
|
+
return lambda: inspect_workbook(
|
|
176
|
+
workbook_path,
|
|
177
|
+
engine="xml",
|
|
178
|
+
max_formula_cells=0,
|
|
179
|
+
)
|
|
180
|
+
if scenario == "validate":
|
|
181
|
+
return lambda: validate_workbook(
|
|
182
|
+
workbook_path,
|
|
183
|
+
required_sheets=[SHEET_NAME],
|
|
184
|
+
required_columns={SHEET_NAME: _headers(4)[:3]},
|
|
185
|
+
unique_keys={SHEET_NAME: [KEY_COLUMN]},
|
|
186
|
+
)
|
|
187
|
+
if scenario == "read-table":
|
|
188
|
+
return lambda: read_table(
|
|
189
|
+
workbook_path,
|
|
190
|
+
SHEET_NAME,
|
|
191
|
+
"row",
|
|
192
|
+
1,
|
|
193
|
+
max_rows=100,
|
|
194
|
+
)
|
|
195
|
+
if scenario == "normalize-preview":
|
|
196
|
+
return lambda: preview_normalization(
|
|
197
|
+
workbook_path,
|
|
198
|
+
SHEET_NAME,
|
|
199
|
+
"row",
|
|
200
|
+
1,
|
|
201
|
+
[{"source": "amount", "kind": "amount", "name": "amount"}],
|
|
202
|
+
max_rows=100,
|
|
203
|
+
)
|
|
204
|
+
if scenario == "cells-search":
|
|
205
|
+
return lambda: search_cells(
|
|
206
|
+
workbook_path,
|
|
207
|
+
"open",
|
|
208
|
+
fields=["values"],
|
|
209
|
+
sheet_name=SHEET_NAME,
|
|
210
|
+
limit=100,
|
|
211
|
+
)
|
|
212
|
+
raise ValueError(f"unknown scenario: {scenario}")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _measure(
|
|
216
|
+
operation: Callable[[], dict[str, object]],
|
|
217
|
+
profile_path: Path | None,
|
|
218
|
+
) -> dict[str, object]:
|
|
219
|
+
tracemalloc.start()
|
|
220
|
+
started = time.perf_counter()
|
|
221
|
+
profiler = cProfile.Profile() if profile_path is not None else None
|
|
222
|
+
try:
|
|
223
|
+
if profiler is not None:
|
|
224
|
+
profiler.enable()
|
|
225
|
+
data = operation()
|
|
226
|
+
ok = True
|
|
227
|
+
error = None
|
|
228
|
+
except SheetOpsError as sheetops_error:
|
|
229
|
+
data = {}
|
|
230
|
+
ok = False
|
|
231
|
+
error = {
|
|
232
|
+
"errorCode": sheetops_error.error_code,
|
|
233
|
+
"message": sheetops_error.message,
|
|
234
|
+
"details": sheetops_error.details,
|
|
235
|
+
}
|
|
236
|
+
except Exception as unexpected_error:
|
|
237
|
+
data = {}
|
|
238
|
+
ok = False
|
|
239
|
+
error = {
|
|
240
|
+
"errorCode": unexpected_error.__class__.__name__,
|
|
241
|
+
"message": str(unexpected_error),
|
|
242
|
+
"details": {},
|
|
243
|
+
}
|
|
244
|
+
finally:
|
|
245
|
+
if profiler is not None:
|
|
246
|
+
profiler.disable()
|
|
247
|
+
profiler.dump_stats(profile_path)
|
|
248
|
+
elapsed = time.perf_counter() - started
|
|
249
|
+
_, peak = tracemalloc.get_traced_memory()
|
|
250
|
+
tracemalloc.stop()
|
|
251
|
+
return {
|
|
252
|
+
"ok": ok,
|
|
253
|
+
"data": data,
|
|
254
|
+
"error": error,
|
|
255
|
+
"elapsedSeconds": round(elapsed, 6),
|
|
256
|
+
"peakTracedBytes": peak,
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _scenario_summary(
|
|
261
|
+
scenario: str,
|
|
262
|
+
data: dict[str, object],
|
|
263
|
+
) -> dict[str, object]:
|
|
264
|
+
if scenario == "inspect":
|
|
265
|
+
workbook = data["workbook"]
|
|
266
|
+
if not isinstance(workbook, dict):
|
|
267
|
+
return {}
|
|
268
|
+
return {
|
|
269
|
+
"engine": workbook.get("engine"),
|
|
270
|
+
"sheetCount": workbook.get("sheetCount"),
|
|
271
|
+
"formulaCount": workbook.get("formulaCount"),
|
|
272
|
+
}
|
|
273
|
+
if scenario == "validate":
|
|
274
|
+
issues = data.get("issues", [])
|
|
275
|
+
return {
|
|
276
|
+
"valid": data.get("valid"),
|
|
277
|
+
"issueCount": len(issues) if isinstance(issues, list) else 0,
|
|
278
|
+
"uniqueKeysChecked": data.get("uniqueKeysChecked"),
|
|
279
|
+
}
|
|
280
|
+
if scenario == "read-table":
|
|
281
|
+
return {
|
|
282
|
+
"sheet": data.get("sheet"),
|
|
283
|
+
"rowCount": data.get("rowCount"),
|
|
284
|
+
"returnedRowCount": data.get("returnedRowCount"),
|
|
285
|
+
"truncated": data.get("truncated"),
|
|
286
|
+
}
|
|
287
|
+
if scenario == "normalize-preview":
|
|
288
|
+
preview_rows = data.get("previewRows", [])
|
|
289
|
+
return {
|
|
290
|
+
"rowCount": data.get("rowCount"),
|
|
291
|
+
"previewRowCount": len(preview_rows)
|
|
292
|
+
if isinstance(preview_rows, list)
|
|
293
|
+
else 0,
|
|
294
|
+
"issueCount": data.get("issueCount"),
|
|
295
|
+
}
|
|
296
|
+
if scenario == "cells-search":
|
|
297
|
+
return {
|
|
298
|
+
"returnedCount": data.get("returnedCount"),
|
|
299
|
+
"hasMore": data.get("hasMore"),
|
|
300
|
+
"stoppedEarly": data.get("stoppedEarly"),
|
|
301
|
+
"scannedCellCount": data.get("scannedCellCount"),
|
|
302
|
+
}
|
|
303
|
+
return {}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _max_rss_bytes() -> int | None:
|
|
307
|
+
try:
|
|
308
|
+
import resource
|
|
309
|
+
except ImportError:
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
max_rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
|
313
|
+
if sys.platform == "darwin":
|
|
314
|
+
return int(max_rss)
|
|
315
|
+
return int(max_rss) * 1024
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _scenario_args(value: str) -> list[str]:
|
|
319
|
+
return [scenario.strip() for scenario in value.split(",") if scenario.strip()]
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|
323
|
+
parser = argparse.ArgumentParser(
|
|
324
|
+
description="Benchmark SheetOps large-XLSX inspection and validation.",
|
|
325
|
+
)
|
|
326
|
+
parser.add_argument(
|
|
327
|
+
"--workbook",
|
|
328
|
+
type=Path,
|
|
329
|
+
default=Path(".benchmarks/large-xlsx/workbook.xlsx"),
|
|
330
|
+
)
|
|
331
|
+
parser.add_argument(
|
|
332
|
+
"--output",
|
|
333
|
+
type=Path,
|
|
334
|
+
default=Path(".benchmarks/large-xlsx/results.jsonl"),
|
|
335
|
+
)
|
|
336
|
+
parser.add_argument("--rows", type=int, default=DEFAULT_ROWS)
|
|
337
|
+
parser.add_argument("--columns", type=int, default=DEFAULT_COLUMNS)
|
|
338
|
+
parser.add_argument(
|
|
339
|
+
"--scenarios",
|
|
340
|
+
default=",".join(DEFAULT_SCENARIOS),
|
|
341
|
+
help=(
|
|
342
|
+
"Comma-separated scenarios: inspect,validate,read-table,"
|
|
343
|
+
"normalize-preview,cells-search"
|
|
344
|
+
),
|
|
345
|
+
)
|
|
346
|
+
parser.add_argument(
|
|
347
|
+
"--cpu-profile-dir",
|
|
348
|
+
type=Path,
|
|
349
|
+
default=None,
|
|
350
|
+
help="Optional directory for cProfile .prof files.",
|
|
351
|
+
)
|
|
352
|
+
return parser.parse_args(argv)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _require_positive_int(value: int, field_name: str) -> None:
|
|
356
|
+
if isinstance(value, bool) or value < 1:
|
|
357
|
+
raise ValueError(f"{field_name} must be a positive integer")
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
if __name__ == "__main__":
|
|
361
|
+
raise SystemExit(main())
|