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.
Files changed (53) hide show
  1. zalos_sheetops/__init__.py +5 -0
  2. zalos_sheetops/benchmarks/__init__.py +1 -0
  3. zalos_sheetops/benchmarks/large_xlsx.py +361 -0
  4. zalos_sheetops/cli.py +1591 -0
  5. zalos_sheetops/command_registry.py +227 -0
  6. zalos_sheetops/compare/__init__.py +1 -0
  7. zalos_sheetops/compare/workbooks.py +265 -0
  8. zalos_sheetops/contracts.py +32 -0
  9. zalos_sheetops/diff/__init__.py +1 -0
  10. zalos_sheetops/diff/tabular.py +204 -0
  11. zalos_sheetops/display/__init__.py +1 -0
  12. zalos_sheetops/display/scan.py +309 -0
  13. zalos_sheetops/display/widths.py +125 -0
  14. zalos_sheetops/display/xlsx_metadata.py +127 -0
  15. zalos_sheetops/errors.py +11 -0
  16. zalos_sheetops/formulas/__init__.py +1 -0
  17. zalos_sheetops/formulas/scan.py +333 -0
  18. zalos_sheetops/formulas/xml_scan.py +324 -0
  19. zalos_sheetops/hyperlinks/__init__.py +1 -0
  20. zalos_sheetops/hyperlinks/assertions.py +390 -0
  21. zalos_sheetops/io/__init__.py +1 -0
  22. zalos_sheetops/io/cellref.py +141 -0
  23. zalos_sheetops/io/headers.py +27 -0
  24. zalos_sheetops/io/inspect.py +98 -0
  25. zalos_sheetops/io/read_cells.py +231 -0
  26. zalos_sheetops/io/read_csv_table.py +45 -0
  27. zalos_sheetops/io/read_table.py +240 -0
  28. zalos_sheetops/io/search_cells.py +532 -0
  29. zalos_sheetops/io/xlsx_xml.py +1654 -0
  30. zalos_sheetops/normalize/__init__.py +1 -0
  31. zalos_sheetops/normalize/amounts.py +51 -0
  32. zalos_sheetops/normalize/dates.py +25 -0
  33. zalos_sheetops/normalize/preview.py +148 -0
  34. zalos_sheetops/normalize/text.py +22 -0
  35. zalos_sheetops/paths.py +28 -0
  36. zalos_sheetops/recalc/__init__.py +1 -0
  37. zalos_sheetops/recalc/libreoffice.py +119 -0
  38. zalos_sheetops/reconcile/__init__.py +1 -0
  39. zalos_sheetops/reconcile/exact.py +183 -0
  40. zalos_sheetops/runtime/__init__.py +1 -0
  41. zalos_sheetops/runtime/libreoffice.py +130 -0
  42. zalos_sheetops/styles/__init__.py +1 -0
  43. zalos_sheetops/styles/assertions.py +253 -0
  44. zalos_sheetops/styles/read.py +398 -0
  45. zalos_sheetops/styles/xml_assertions.py +372 -0
  46. zalos_sheetops/testing.py +43 -0
  47. zalos_sheetops/validation/__init__.py +1 -0
  48. zalos_sheetops/validation/workbook.py +451 -0
  49. zalos_sheetops-0.4.2.dist-info/METADATA +141 -0
  50. zalos_sheetops-0.4.2.dist-info/RECORD +53 -0
  51. zalos_sheetops-0.4.2.dist-info/WHEEL +4 -0
  52. zalos_sheetops-0.4.2.dist-info/entry_points.txt +2 -0
  53. zalos_sheetops-0.4.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,5 @@
1
+ """Agent-native Excel and CSV operations."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.4.2"
@@ -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())