qubicl-cli 0.1.0-dev.0 → 0.1.0
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.
- package/README.md +46 -21
- package/dist/SBOM.spdx.json +222 -0
- package/dist/THIRD_PARTY_NOTICES.txt +244 -0
- package/dist/assets/chromium-seccomp.json +181 -0
- package/dist/assets/computer/BROWSER_SKILLS_THIRD_PARTY_NOTICES.txt +23 -0
- package/dist/assets/computer/Dockerfile +191 -49
- package/dist/assets/computer/PLAYWRIGHT_THIRD_PARTY_NOTICES.txt +217 -0
- package/dist/assets/computer/SKILLS_THIRD_PARTY_NOTICES.txt +29 -0
- package/dist/assets/computer/THIRD_PARTY_NOTICES.txt +28 -0
- package/dist/assets/computer/WEB_THIRD_PARTY_NOTICES.txt +42 -0
- package/dist/assets/computer/browser-skills-requirements.txt +12 -0
- package/dist/assets/computer/chromium-wrapper.sh +12 -0
- package/dist/assets/computer/control.mjs +112 -52
- package/dist/assets/computer/entrypoint.sh +272 -35
- package/dist/assets/computer/libreoffice-registrymodifications.xcu +8 -0
- package/dist/assets/computer/manifests/browser.json +72 -0
- package/dist/assets/computer/manifests/computer.json +76 -0
- package/dist/assets/computer/manifests/file-system.json +42 -0
- package/dist/assets/computer/manifests/workstation.json +78 -0
- package/dist/assets/computer/node_modules/playwright-core/LICENSE +202 -0
- package/dist/assets/computer/node_modules/playwright-core/NOTICE +5 -0
- package/dist/assets/computer/node_modules/playwright-core/ThirdPartyNotices.txt +13 -0
- package/dist/assets/computer/node_modules/playwright-core/browsers.json +75 -0
- package/dist/assets/computer/node_modules/playwright-core/index.js +17 -0
- package/dist/assets/computer/node_modules/playwright-core/index.mjs +28 -0
- package/dist/assets/computer/node_modules/playwright-core/lib/bootstrap.js +88 -0
- package/dist/assets/computer/node_modules/playwright-core/lib/coreBundle.js +74830 -0
- package/dist/assets/computer/node_modules/playwright-core/lib/utilsBundle.js +90764 -0
- package/dist/assets/computer/node_modules/playwright-core/lib/utilsBundle.js.LICENSE +2179 -0
- package/dist/assets/computer/node_modules/playwright-core/lib/webp_codec.LICENSE +173 -0
- package/dist/assets/computer/node_modules/playwright-core/lib/webp_codec.wasm +0 -0
- package/dist/assets/computer/node_modules/playwright-core/lib/xdg-open +1267 -0
- package/dist/assets/computer/node_modules/playwright-core/package.json +34 -0
- package/dist/assets/computer/skills/core/docx/LICENSE +21 -0
- package/dist/assets/computer/skills/core/docx/SKILL.md +21 -0
- package/dist/assets/computer/skills/core/docx/references/revisions-and-comments.md +88 -0
- package/dist/assets/computer/skills/core/docx/scripts/docx_comments.py +289 -0
- package/dist/assets/computer/skills/core/docx/scripts/docx_common.py +94 -0
- package/dist/assets/computer/skills/core/docx/scripts/docx_create.py +177 -0
- package/dist/assets/computer/skills/core/docx/scripts/docx_edit.py +250 -0
- package/dist/assets/computer/skills/core/docx/scripts/docx_read.py +149 -0
- package/dist/assets/computer/skills/core/docx/scripts/docx_revisions.py +147 -0
- package/dist/assets/computer/skills/core/docx/scripts/docx_template.py +70 -0
- package/dist/assets/computer/skills/core/docx/scripts/docx_validate.py +156 -0
- package/dist/assets/computer/skills/core/ocr-and-documents/LICENSE +21 -0
- package/dist/assets/computer/skills/core/ocr-and-documents/SKILL.md +24 -0
- package/dist/assets/computer/skills/core/ocr-and-documents/scripts/ocr_document.py +132 -0
- package/dist/assets/computer/skills/core/pdf/LICENSE +21 -0
- package/dist/assets/computer/skills/core/pdf/SKILL.md +25 -0
- package/dist/assets/computer/skills/core/pdf/references/forms.md +100 -0
- package/dist/assets/computer/skills/core/pdf/scripts/_raster.py +76 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_create.py +130 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_fill_form.py +97 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_form_layout.py +168 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_make_form.py +145 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_merge.py +50 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_meta.py +115 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_page_image.py +99 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_read.py +153 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_secure.py +71 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_split.py +84 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_stamp.py +143 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_watermark.py +51 -0
- package/dist/assets/computer/skills/core/plan/LICENSE +21 -0
- package/dist/assets/computer/skills/core/plan/SKILL.md +21 -0
- package/dist/assets/computer/skills/core/powerpoint/LICENSE +21 -0
- package/dist/assets/computer/skills/core/powerpoint/SKILL.md +18 -0
- package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_create.py +214 -0
- package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_edit.py +436 -0
- package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_from_template.py +88 -0
- package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_read.py +131 -0
- package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_render.py +93 -0
- package/dist/assets/computer/skills/core/xlsx/LICENSE +21 -0
- package/dist/assets/computer/skills/core/xlsx/SKILL.md +22 -0
- package/dist/assets/computer/skills/core/xlsx/references/restructuring.md +71 -0
- package/dist/assets/computer/skills/core/xlsx/scripts/csv_to_xlsx.py +104 -0
- package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_create.py +259 -0
- package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_edit.py +263 -0
- package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_read.py +160 -0
- package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_recalc.py +110 -0
- package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_restructure.py +337 -0
- package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_to_csv.py +69 -0
- package/dist/assets/computer/skills/core-catalog.json +246 -0
- package/dist/assets/computer/skills-requirements.txt +18 -0
- package/dist/assets/computer/web-provider.py +592 -0
- package/dist/assets/computer/web-requirements.txt +37 -0
- package/dist/assets/gateway/Dockerfile +3 -1
- package/dist/assets/gateway/gateway.mjs +69 -40
- package/dist/assets/image-catalog.json +183 -0
- package/dist/qubicl.mjs +297 -167
- package/package.json +7 -7
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Edit an existing .xlsx workbook in place (or to --out).
|
|
3
|
+
|
|
4
|
+
Operations (repeatable where noted, applied in the order listed below):
|
|
5
|
+
--rename-sheet OLD:NEW rename a sheet
|
|
6
|
+
--copy-sheet SRC:NEW duplicate a sheet under a new name
|
|
7
|
+
--insert-rows IDX[:N] insert N rows before row IDX (default N=1)
|
|
8
|
+
--delete-rows IDX[:N] delete N rows starting at row IDX
|
|
9
|
+
--insert-cols IDX[:N] insert N columns before column IDX (number)
|
|
10
|
+
--delete-cols IDX[:N] delete N columns starting at column IDX
|
|
11
|
+
--set CELL=VALUE repeatable; type-inferred (int, float, bool,
|
|
12
|
+
ISO date, else string). '=...' sets a formula.
|
|
13
|
+
--append ROWJSON repeatable; JSON array appended as a row
|
|
14
|
+
--add-table NAME:RANGE[:STYLE] create a native Excel table (ListObject)
|
|
15
|
+
--table-append NAME=ROWJSON append a row inside a table, auto-extending
|
|
16
|
+
the table's range (repeatable)
|
|
17
|
+
--list-tables print tables on the target sheet and exit
|
|
18
|
+
--define-name NAME=REF workbook-scope defined name, e.g.
|
|
19
|
+
"Rates='Data'!$B$2:$B$9" (repeatable)
|
|
20
|
+
--delete-name NAME remove a defined name (repeatable)
|
|
21
|
+
--hyperlink CELL=URL[|TEXT] set a hyperlink (optional display text)
|
|
22
|
+
--note CELL=TEXT[|AUTHOR] set a cell note/comment (repeatable)
|
|
23
|
+
--clear-note CELL remove a cell note (repeatable)
|
|
24
|
+
--protect [PASSWORD] enable sheet protection; combine with
|
|
25
|
+
--unlock RANGE to leave ranges editable.
|
|
26
|
+
NOT security: trivially strippable (see
|
|
27
|
+
SKILL.md Pitfalls).
|
|
28
|
+
--recalc set fullCalcOnLoad so Excel/LibreOffice
|
|
29
|
+
recomputes all formulas on next open
|
|
30
|
+
|
|
31
|
+
WARNING: openpyxl does NOT shift merged-cell ranges, chart anchors, or
|
|
32
|
+
formula references when rows/columns are inserted or deleted. Verify any
|
|
33
|
+
sheet containing merges or formulas after structural edits — or use
|
|
34
|
+
xlsx_restructure.py, which rewrites references for you.
|
|
35
|
+
|
|
36
|
+
Usage:
|
|
37
|
+
xlsx_edit.py book.xlsx --sheet Data --set B2=42 --set C2=2026-01-01 \
|
|
38
|
+
--set "D2==SUM(B2:C2)" --recalc
|
|
39
|
+
xlsx_edit.py book.xlsx --sheet Data --append '["Widget", 9.99, true]'
|
|
40
|
+
xlsx_edit.py book.xlsx --copy-sheet Data:Backup --rename-sheet Data:Main
|
|
41
|
+
"""
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import argparse
|
|
45
|
+
import json
|
|
46
|
+
import sys
|
|
47
|
+
from datetime import date, datetime
|
|
48
|
+
|
|
49
|
+
from openpyxl import load_workbook
|
|
50
|
+
from openpyxl.comments import Comment
|
|
51
|
+
from openpyxl.styles import Protection
|
|
52
|
+
from openpyxl.utils import get_column_letter, range_boundaries
|
|
53
|
+
from openpyxl.workbook.defined_name import DefinedName
|
|
54
|
+
from openpyxl.worksheet.table import Table, TableStyleInfo
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def infer(text):
|
|
58
|
+
if text.startswith("="):
|
|
59
|
+
return text # formula
|
|
60
|
+
low = text.lower()
|
|
61
|
+
if low in ("true", "false"):
|
|
62
|
+
return low == "true"
|
|
63
|
+
for caster in (int, float):
|
|
64
|
+
try:
|
|
65
|
+
return caster(text)
|
|
66
|
+
except ValueError:
|
|
67
|
+
pass
|
|
68
|
+
for parser in (date.fromisoformat, datetime.fromisoformat):
|
|
69
|
+
try:
|
|
70
|
+
return parser(text)
|
|
71
|
+
except ValueError:
|
|
72
|
+
pass
|
|
73
|
+
return text
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parse_idx(arg):
|
|
77
|
+
if ":" in arg:
|
|
78
|
+
idx, n = arg.split(":", 1)
|
|
79
|
+
return int(idx), int(n)
|
|
80
|
+
return int(arg), 1
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def add_table(ws, spec):
|
|
84
|
+
parts = spec.split(":")
|
|
85
|
+
if len(parts) < 3:
|
|
86
|
+
raise ValueError("--add-table needs NAME:RANGE like Sales:A1:C9")
|
|
87
|
+
name = parts[0]
|
|
88
|
+
rng = ":".join(parts[1:3])
|
|
89
|
+
style = parts[3] if len(parts) > 3 else "TableStyleMedium9"
|
|
90
|
+
table = Table(displayName=name, ref=rng)
|
|
91
|
+
table.tableStyleInfo = TableStyleInfo(name=style, showRowStripes=True)
|
|
92
|
+
ws.add_table(table)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def table_append(ws, name, row_values):
|
|
96
|
+
table = ws.tables[name]
|
|
97
|
+
min_col, min_row, max_col, max_row = range_boundaries(table.ref)
|
|
98
|
+
new_row = max_row + 1
|
|
99
|
+
for offset, value in enumerate(row_values):
|
|
100
|
+
ws.cell(row=new_row, column=min_col + offset, value=value)
|
|
101
|
+
table.ref = (f"{get_column_letter(min_col)}{min_row}:"
|
|
102
|
+
f"{get_column_letter(max_col)}{new_row}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def main(argv=None):
|
|
106
|
+
ap = argparse.ArgumentParser(
|
|
107
|
+
description="Edit an existing .xlsx workbook.",
|
|
108
|
+
epilog="Plain insert/delete does not shift merges/formula refs — "
|
|
109
|
+
"use xlsx_restructure.py for reference-aware moves.")
|
|
110
|
+
ap.add_argument("file", help="path to .xlsx file")
|
|
111
|
+
ap.add_argument("--sheet", help="target sheet (default: active)")
|
|
112
|
+
ap.add_argument("--out", help="output path (default: edit in place)")
|
|
113
|
+
ap.add_argument("--rename-sheet", action="append", default=[],
|
|
114
|
+
metavar="OLD:NEW")
|
|
115
|
+
ap.add_argument("--copy-sheet", action="append", default=[],
|
|
116
|
+
metavar="SRC:NEW")
|
|
117
|
+
ap.add_argument("--insert-rows", action="append", default=[],
|
|
118
|
+
metavar="IDX[:N]")
|
|
119
|
+
ap.add_argument("--delete-rows", action="append", default=[],
|
|
120
|
+
metavar="IDX[:N]")
|
|
121
|
+
ap.add_argument("--insert-cols", action="append", default=[],
|
|
122
|
+
metavar="IDX[:N]")
|
|
123
|
+
ap.add_argument("--delete-cols", action="append", default=[],
|
|
124
|
+
metavar="IDX[:N]")
|
|
125
|
+
ap.add_argument("--set", action="append", default=[], metavar="CELL=VALUE")
|
|
126
|
+
ap.add_argument("--append", action="append", default=[], metavar="ROWJSON")
|
|
127
|
+
ap.add_argument("--add-table", action="append", default=[],
|
|
128
|
+
metavar="NAME:RANGE[:STYLE]")
|
|
129
|
+
ap.add_argument("--table-append", action="append", default=[],
|
|
130
|
+
metavar="NAME=ROWJSON")
|
|
131
|
+
ap.add_argument("--list-tables", action="store_true",
|
|
132
|
+
help="print tables on the target sheet and exit")
|
|
133
|
+
ap.add_argument("--define-name", action="append", default=[],
|
|
134
|
+
metavar="NAME=REF")
|
|
135
|
+
ap.add_argument("--delete-name", action="append", default=[],
|
|
136
|
+
metavar="NAME")
|
|
137
|
+
ap.add_argument("--hyperlink", action="append", default=[],
|
|
138
|
+
metavar="CELL=URL[|TEXT]")
|
|
139
|
+
ap.add_argument("--note", action="append", default=[],
|
|
140
|
+
metavar="CELL=TEXT[|AUTHOR]")
|
|
141
|
+
ap.add_argument("--clear-note", action="append", default=[],
|
|
142
|
+
metavar="CELL")
|
|
143
|
+
ap.add_argument("--protect", nargs="?", const="", metavar="PASSWORD",
|
|
144
|
+
help="protect the target sheet (integrity signal only, "
|
|
145
|
+
"NOT security)")
|
|
146
|
+
ap.add_argument("--unlock", action="append", default=[], metavar="RANGE",
|
|
147
|
+
help="cell range left editable under --protect")
|
|
148
|
+
ap.add_argument("--recalc", action="store_true",
|
|
149
|
+
help="force full recalculation when the file is opened")
|
|
150
|
+
args = ap.parse_args(argv)
|
|
151
|
+
|
|
152
|
+
wb = load_workbook(args.file)
|
|
153
|
+
changes = []
|
|
154
|
+
|
|
155
|
+
for pair in args.rename_sheet:
|
|
156
|
+
old, new = pair.split(":", 1)
|
|
157
|
+
wb[old].title = new
|
|
158
|
+
changes.append(f"rename {old}->{new}")
|
|
159
|
+
for pair in args.copy_sheet:
|
|
160
|
+
src, new = pair.split(":", 1)
|
|
161
|
+
copy = wb.copy_worksheet(wb[src])
|
|
162
|
+
copy.title = new
|
|
163
|
+
changes.append(f"copy {src}->{new}")
|
|
164
|
+
|
|
165
|
+
ws = wb[args.sheet] if args.sheet else wb.active
|
|
166
|
+
|
|
167
|
+
if args.list_tables:
|
|
168
|
+
print(json.dumps({"ok": True, "sheet": ws.title,
|
|
169
|
+
"tables": {t.displayName: {
|
|
170
|
+
"ref": t.ref,
|
|
171
|
+
"style": t.tableStyleInfo.name
|
|
172
|
+
if t.tableStyleInfo else None}
|
|
173
|
+
for t in ws.tables.values()}},
|
|
174
|
+
ensure_ascii=False))
|
|
175
|
+
return 0
|
|
176
|
+
|
|
177
|
+
for arg in args.insert_rows:
|
|
178
|
+
idx, n = parse_idx(arg)
|
|
179
|
+
ws.insert_rows(idx, n)
|
|
180
|
+
changes.append(f"insert_rows {idx}x{n}")
|
|
181
|
+
for arg in args.delete_rows:
|
|
182
|
+
idx, n = parse_idx(arg)
|
|
183
|
+
ws.delete_rows(idx, n)
|
|
184
|
+
changes.append(f"delete_rows {idx}x{n}")
|
|
185
|
+
for arg in args.insert_cols:
|
|
186
|
+
idx, n = parse_idx(arg)
|
|
187
|
+
ws.insert_cols(idx, n)
|
|
188
|
+
changes.append(f"insert_cols {idx}x{n}")
|
|
189
|
+
for arg in args.delete_cols:
|
|
190
|
+
idx, n = parse_idx(arg)
|
|
191
|
+
ws.delete_cols(idx, n)
|
|
192
|
+
changes.append(f"delete_cols {idx}x{n}")
|
|
193
|
+
|
|
194
|
+
for assignment in args.set:
|
|
195
|
+
coord, raw = assignment.split("=", 1)
|
|
196
|
+
ws[coord] = infer(raw)
|
|
197
|
+
changes.append(f"set {coord}")
|
|
198
|
+
for row_json in args.append:
|
|
199
|
+
ws.append(json.loads(row_json))
|
|
200
|
+
changes.append(f"append row {ws.max_row}")
|
|
201
|
+
|
|
202
|
+
for spec in args.add_table:
|
|
203
|
+
add_table(ws, spec)
|
|
204
|
+
changes.append(f"add_table {spec.split(':')[0]}")
|
|
205
|
+
for spec in args.table_append:
|
|
206
|
+
name, row_json = spec.split("=", 1)
|
|
207
|
+
table_append(ws, name, json.loads(row_json))
|
|
208
|
+
changes.append(f"table_append {name} -> {ws.tables[name].ref}")
|
|
209
|
+
|
|
210
|
+
for spec in args.define_name:
|
|
211
|
+
name, ref = spec.split("=", 1)
|
|
212
|
+
wb.defined_names[name] = DefinedName(name, attr_text=ref)
|
|
213
|
+
changes.append(f"define_name {name}")
|
|
214
|
+
for name in args.delete_name:
|
|
215
|
+
del wb.defined_names[name]
|
|
216
|
+
changes.append(f"delete_name {name}")
|
|
217
|
+
|
|
218
|
+
for spec in args.hyperlink:
|
|
219
|
+
coord, rest = spec.split("=", 1)
|
|
220
|
+
url, _, text = rest.partition("|")
|
|
221
|
+
cell = ws[coord]
|
|
222
|
+
cell.hyperlink = url
|
|
223
|
+
cell.value = text or (cell.value if cell.value is not None else url)
|
|
224
|
+
cell.style = "Hyperlink"
|
|
225
|
+
changes.append(f"hyperlink {coord}")
|
|
226
|
+
for spec in args.note:
|
|
227
|
+
coord, rest = spec.split("=", 1)
|
|
228
|
+
text, _, author = rest.partition("|")
|
|
229
|
+
ws[coord].comment = Comment(text, author or "xlsx-skill")
|
|
230
|
+
changes.append(f"note {coord}")
|
|
231
|
+
for coord in args.clear_note:
|
|
232
|
+
ws[coord].comment = None
|
|
233
|
+
changes.append(f"clear_note {coord}")
|
|
234
|
+
|
|
235
|
+
if args.protect is not None:
|
|
236
|
+
for rng in args.unlock:
|
|
237
|
+
for row in ws[rng]:
|
|
238
|
+
for cell in row:
|
|
239
|
+
cell.protection = Protection(locked=False)
|
|
240
|
+
if args.protect:
|
|
241
|
+
ws.protection.password = args.protect
|
|
242
|
+
ws.protection.sheet = True
|
|
243
|
+
changes.append(f"protect {ws.title}"
|
|
244
|
+
+ (f" (unlocked {len(args.unlock)} ranges)"
|
|
245
|
+
if args.unlock else ""))
|
|
246
|
+
|
|
247
|
+
if args.recalc:
|
|
248
|
+
wb.calculation.fullCalcOnLoad = True
|
|
249
|
+
changes.append("fullCalcOnLoad")
|
|
250
|
+
|
|
251
|
+
out = args.out or args.file
|
|
252
|
+
wb.save(out)
|
|
253
|
+
print(json.dumps({"ok": True, "output": out, "sheet": ws.title,
|
|
254
|
+
"changes": changes}, ensure_ascii=False))
|
|
255
|
+
return 0
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
if __name__ == "__main__":
|
|
259
|
+
try:
|
|
260
|
+
sys.exit(main())
|
|
261
|
+
except Exception as exc: # noqa: BLE001
|
|
262
|
+
print(json.dumps({"ok": False, "error": str(exc)}), file=sys.stderr)
|
|
263
|
+
sys.exit(1)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read an .xlsx workbook: inventory, JSON/CSV dumps, formula listing.
|
|
3
|
+
|
|
4
|
+
Modes (pick one):
|
|
5
|
+
--sheets JSON inventory: sheet names, dimensions, row/col counts
|
|
6
|
+
--json dump one sheet's rows as a JSON array of arrays
|
|
7
|
+
--csv dump one sheet as CSV to stdout or --out
|
|
8
|
+
--formulas JSON list of formula cells {"cell", "formula", "cached"}
|
|
9
|
+
--notes JSON list of cell notes/comments across sheets
|
|
10
|
+
--names JSON map of workbook defined names
|
|
11
|
+
|
|
12
|
+
Options:
|
|
13
|
+
--sheet NAME sheet to dump (default: active sheet)
|
|
14
|
+
--data-only load cached formula RESULTS instead of formula strings.
|
|
15
|
+
Caveat: openpyxl never computes formulas; cached values
|
|
16
|
+
exist only if the file was last saved by Excel/LibreOffice.
|
|
17
|
+
--encoding ENC encoding for --csv --out files (default utf-8)
|
|
18
|
+
--out PATH write --csv output to a file instead of stdout
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
xlsx_read.py book.xlsx --sheets
|
|
22
|
+
xlsx_read.py book.xlsx --json --sheet Data
|
|
23
|
+
xlsx_read.py book.xlsx --csv --sheet Data --out data.csv
|
|
24
|
+
xlsx_read.py book.xlsx --formulas
|
|
25
|
+
xlsx_read.py book.xlsx --notes
|
|
26
|
+
xlsx_read.py book.xlsx --names
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import csv
|
|
32
|
+
import json
|
|
33
|
+
import sys
|
|
34
|
+
from datetime import date, datetime, time
|
|
35
|
+
|
|
36
|
+
from openpyxl import load_workbook
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def jsonable(value):
|
|
40
|
+
if isinstance(value, (datetime, date, time)):
|
|
41
|
+
return value.isoformat()
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def sheet_rows(ws):
|
|
46
|
+
return [[jsonable(c) for c in row] for row in ws.iter_rows(values_only=True)]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def cmd_sheets(wb):
|
|
50
|
+
info = []
|
|
51
|
+
for ws in wb.worksheets:
|
|
52
|
+
info.append({
|
|
53
|
+
"name": ws.title,
|
|
54
|
+
"dimensions": ws.dimensions,
|
|
55
|
+
"max_row": ws.max_row,
|
|
56
|
+
"max_col": ws.max_column,
|
|
57
|
+
"merged": [str(r) for r in ws.merged_cells.ranges],
|
|
58
|
+
"charts": len(getattr(ws, "_charts", [])),
|
|
59
|
+
"freeze_panes": ws.freeze_panes,
|
|
60
|
+
"autofilter": ws.auto_filter.ref,
|
|
61
|
+
"tables": {t.displayName: t.ref for t in ws.tables.values()},
|
|
62
|
+
"protected": bool(ws.protection.sheet),
|
|
63
|
+
})
|
|
64
|
+
names = {name: dn.attr_text for name, dn in wb.defined_names.items()}
|
|
65
|
+
print(json.dumps({"sheets": info, "defined_names": names},
|
|
66
|
+
ensure_ascii=False, indent=2))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def cmd_notes(wb, sheet):
|
|
70
|
+
out = []
|
|
71
|
+
sheets = [sheet] if sheet else wb.sheetnames
|
|
72
|
+
for name in sheets:
|
|
73
|
+
for row in wb[name].iter_rows():
|
|
74
|
+
for cell in row:
|
|
75
|
+
if cell.comment is not None:
|
|
76
|
+
out.append({"sheet": name, "cell": cell.coordinate,
|
|
77
|
+
"text": cell.comment.text,
|
|
78
|
+
"author": cell.comment.author})
|
|
79
|
+
print(json.dumps({"notes": out}, ensure_ascii=False, indent=2))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cmd_names(wb):
|
|
83
|
+
names = {name: dn.attr_text for name, dn in wb.defined_names.items()}
|
|
84
|
+
print(json.dumps({"defined_names": names}, ensure_ascii=False, indent=2))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def cmd_formulas(path, sheet):
|
|
88
|
+
wb_f = load_workbook(path, data_only=False)
|
|
89
|
+
wb_v = load_workbook(path, data_only=True)
|
|
90
|
+
out = []
|
|
91
|
+
sheets = [sheet] if sheet else wb_f.sheetnames
|
|
92
|
+
for name in sheets:
|
|
93
|
+
ws_f, ws_v = wb_f[name], wb_v[name]
|
|
94
|
+
for row in ws_f.iter_rows():
|
|
95
|
+
for cell in row:
|
|
96
|
+
if isinstance(cell.value, str) and cell.value.startswith("="):
|
|
97
|
+
out.append({
|
|
98
|
+
"sheet": name,
|
|
99
|
+
"cell": cell.coordinate,
|
|
100
|
+
"formula": cell.value,
|
|
101
|
+
"cached": jsonable(ws_v[cell.coordinate].value),
|
|
102
|
+
})
|
|
103
|
+
print(json.dumps({"formulas": out}, ensure_ascii=False, indent=2))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main(argv=None):
|
|
107
|
+
ap = argparse.ArgumentParser(description="Read/inspect an .xlsx workbook.")
|
|
108
|
+
ap.add_argument("file", help="path to .xlsx file")
|
|
109
|
+
mode = ap.add_mutually_exclusive_group(required=True)
|
|
110
|
+
mode.add_argument("--sheets", action="store_true")
|
|
111
|
+
mode.add_argument("--json", action="store_true")
|
|
112
|
+
mode.add_argument("--csv", action="store_true")
|
|
113
|
+
mode.add_argument("--formulas", action="store_true")
|
|
114
|
+
mode.add_argument("--notes", action="store_true")
|
|
115
|
+
mode.add_argument("--names", action="store_true")
|
|
116
|
+
ap.add_argument("--sheet", help="sheet name (default: active)")
|
|
117
|
+
ap.add_argument("--data-only", action="store_true",
|
|
118
|
+
help="return cached formula results (see module docstring)")
|
|
119
|
+
ap.add_argument("--encoding", default="utf-8")
|
|
120
|
+
ap.add_argument("--out", help="output file for --csv")
|
|
121
|
+
args = ap.parse_args(argv)
|
|
122
|
+
|
|
123
|
+
if args.formulas:
|
|
124
|
+
cmd_formulas(args.file, args.sheet)
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
wb = load_workbook(args.file, data_only=args.data_only)
|
|
128
|
+
if args.sheets:
|
|
129
|
+
cmd_sheets(wb)
|
|
130
|
+
return 0
|
|
131
|
+
if args.notes:
|
|
132
|
+
cmd_notes(wb, args.sheet)
|
|
133
|
+
return 0
|
|
134
|
+
if args.names:
|
|
135
|
+
cmd_names(wb)
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
ws = wb[args.sheet] if args.sheet else wb.active
|
|
139
|
+
rows = sheet_rows(ws)
|
|
140
|
+
if args.json:
|
|
141
|
+
print(json.dumps({"sheet": ws.title, "rows": rows}, ensure_ascii=False))
|
|
142
|
+
else: # --csv
|
|
143
|
+
if args.out:
|
|
144
|
+
with open(args.out, "w", newline="", encoding=args.encoding) as fh:
|
|
145
|
+
csv.writer(fh).writerows(
|
|
146
|
+
[["" if v is None else v for v in r] for r in rows])
|
|
147
|
+
print(json.dumps({"ok": True, "out": args.out, "rows": len(rows)}))
|
|
148
|
+
else:
|
|
149
|
+
w = csv.writer(sys.stdout)
|
|
150
|
+
for r in rows:
|
|
151
|
+
w.writerow(["" if v is None else v for v in r])
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
if __name__ == "__main__":
|
|
156
|
+
try:
|
|
157
|
+
sys.exit(main())
|
|
158
|
+
except Exception as exc: # noqa: BLE001
|
|
159
|
+
print(json.dumps({"ok": False, "error": str(exc)}), file=sys.stderr)
|
|
160
|
+
sys.exit(1)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Recalculate a workbook's formulas headlessly with LibreOffice.
|
|
3
|
+
|
|
4
|
+
openpyxl never computes formulas. This script shells out to `soffice`
|
|
5
|
+
(LibreOffice) to open the workbook, recalculate, and re-save it, so
|
|
6
|
+
cached formula results become available to `xlsx_read.py --data-only`
|
|
7
|
+
and `--formulas`.
|
|
8
|
+
|
|
9
|
+
Behavior:
|
|
10
|
+
* soffice on PATH: converts the file to .xlsx in a temp dir (which
|
|
11
|
+
recalculates all formulas) and replaces the original (or writes
|
|
12
|
+
--out). Prints {"recalculated": true, ...} and exits 0.
|
|
13
|
+
* soffice absent: prints {"recalculated": false, "reason": ...} with
|
|
14
|
+
Qubicl preset guidance and STILL exits 0 — callers can branch on the JSON
|
|
15
|
+
instead of the exit code.
|
|
16
|
+
|
|
17
|
+
Note: LibreOffice recalculates .xlsx on load per its default
|
|
18
|
+
calculation settings; conversion re-saves with fresh cached values.
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
xlsx_recalc.py book.xlsx
|
|
22
|
+
xlsx_recalc.py book.xlsx --out recalced.xlsx
|
|
23
|
+
xlsx_recalc.py book.xlsx --timeout 120
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import json
|
|
29
|
+
import shutil
|
|
30
|
+
import subprocess
|
|
31
|
+
import sys
|
|
32
|
+
import tempfile
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def count_cached(path):
|
|
37
|
+
"""Number of formula cells with a cached value present."""
|
|
38
|
+
from openpyxl import load_workbook
|
|
39
|
+
wb_f = load_workbook(path, data_only=False)
|
|
40
|
+
wb_v = load_workbook(path, data_only=True)
|
|
41
|
+
formulas = cached = 0
|
|
42
|
+
for name in wb_f.sheetnames:
|
|
43
|
+
ws_f, ws_v = wb_f[name], wb_v[name]
|
|
44
|
+
for row in ws_f.iter_rows():
|
|
45
|
+
for cell in row:
|
|
46
|
+
if isinstance(cell.value, str) and cell.value.startswith("="):
|
|
47
|
+
formulas += 1
|
|
48
|
+
if ws_v[cell.coordinate].value is not None:
|
|
49
|
+
cached += 1
|
|
50
|
+
return formulas, cached
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main(argv=None):
|
|
54
|
+
ap = argparse.ArgumentParser(
|
|
55
|
+
description="Recalculate .xlsx formulas headlessly via LibreOffice.")
|
|
56
|
+
ap.add_argument("file", help="path to .xlsx file")
|
|
57
|
+
ap.add_argument("--out", help="output path (default: replace input)")
|
|
58
|
+
ap.add_argument("--timeout", type=int, default=180,
|
|
59
|
+
help="seconds to wait for soffice (default 180)")
|
|
60
|
+
args = ap.parse_args(argv)
|
|
61
|
+
|
|
62
|
+
src = Path(args.file).resolve()
|
|
63
|
+
if not src.exists():
|
|
64
|
+
print(json.dumps({"ok": False, "error": f"no such file: {src}"}),
|
|
65
|
+
file=sys.stderr)
|
|
66
|
+
return 1
|
|
67
|
+
|
|
68
|
+
soffice = shutil.which("soffice")
|
|
69
|
+
if not soffice:
|
|
70
|
+
print(json.dumps({
|
|
71
|
+
"ok": True, "recalculated": False,
|
|
72
|
+
"reason": "LibreOffice (soffice) not found on PATH",
|
|
73
|
+
"guidance": "Formula recalculation is supported by the Qubicl "
|
|
74
|
+
"workstation preset. Use that preset or ask the user "
|
|
75
|
+
"to open and re-save the workbook in an office app.",
|
|
76
|
+
}, ensure_ascii=False))
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
80
|
+
proc = subprocess.run(
|
|
81
|
+
[soffice, "--headless", "--calc", "--convert-to", "xlsx:Calc "
|
|
82
|
+
"MS Excel 2007 XML", "--outdir", tmp, str(src)],
|
|
83
|
+
capture_output=True, text=True, encoding="utf-8",
|
|
84
|
+
timeout=args.timeout,
|
|
85
|
+
env={"HOME": tmp, "PATH": Path(soffice).parent.as_posix()
|
|
86
|
+
+ ":/usr/bin:/bin"})
|
|
87
|
+
produced = Path(tmp) / (src.stem + ".xlsx")
|
|
88
|
+
if proc.returncode != 0 or not produced.exists():
|
|
89
|
+
print(json.dumps({"ok": False,
|
|
90
|
+
"error": "soffice conversion failed",
|
|
91
|
+
"stderr": proc.stderr.strip()[-500:]}),
|
|
92
|
+
file=sys.stderr)
|
|
93
|
+
return 1
|
|
94
|
+
formulas, cached = count_cached(produced)
|
|
95
|
+
dest = Path(args.out).resolve() if args.out else src
|
|
96
|
+
shutil.copyfile(produced, dest)
|
|
97
|
+
|
|
98
|
+
print(json.dumps({
|
|
99
|
+
"ok": True, "recalculated": True, "output": str(dest),
|
|
100
|
+
"formula_cells": formulas, "with_cached_values": cached,
|
|
101
|
+
}, ensure_ascii=False))
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
try:
|
|
107
|
+
sys.exit(main())
|
|
108
|
+
except Exception as exc: # noqa: BLE001
|
|
109
|
+
print(json.dumps({"ok": False, "error": str(exc)}), file=sys.stderr)
|
|
110
|
+
sys.exit(1)
|