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.
Files changed (91) hide show
  1. package/README.md +46 -21
  2. package/dist/SBOM.spdx.json +222 -0
  3. package/dist/THIRD_PARTY_NOTICES.txt +244 -0
  4. package/dist/assets/chromium-seccomp.json +181 -0
  5. package/dist/assets/computer/BROWSER_SKILLS_THIRD_PARTY_NOTICES.txt +23 -0
  6. package/dist/assets/computer/Dockerfile +191 -49
  7. package/dist/assets/computer/PLAYWRIGHT_THIRD_PARTY_NOTICES.txt +217 -0
  8. package/dist/assets/computer/SKILLS_THIRD_PARTY_NOTICES.txt +29 -0
  9. package/dist/assets/computer/THIRD_PARTY_NOTICES.txt +28 -0
  10. package/dist/assets/computer/WEB_THIRD_PARTY_NOTICES.txt +42 -0
  11. package/dist/assets/computer/browser-skills-requirements.txt +12 -0
  12. package/dist/assets/computer/chromium-wrapper.sh +12 -0
  13. package/dist/assets/computer/control.mjs +112 -52
  14. package/dist/assets/computer/entrypoint.sh +272 -35
  15. package/dist/assets/computer/libreoffice-registrymodifications.xcu +8 -0
  16. package/dist/assets/computer/manifests/browser.json +72 -0
  17. package/dist/assets/computer/manifests/computer.json +76 -0
  18. package/dist/assets/computer/manifests/file-system.json +42 -0
  19. package/dist/assets/computer/manifests/workstation.json +78 -0
  20. package/dist/assets/computer/node_modules/playwright-core/LICENSE +202 -0
  21. package/dist/assets/computer/node_modules/playwright-core/NOTICE +5 -0
  22. package/dist/assets/computer/node_modules/playwright-core/ThirdPartyNotices.txt +13 -0
  23. package/dist/assets/computer/node_modules/playwright-core/browsers.json +75 -0
  24. package/dist/assets/computer/node_modules/playwright-core/index.js +17 -0
  25. package/dist/assets/computer/node_modules/playwright-core/index.mjs +28 -0
  26. package/dist/assets/computer/node_modules/playwright-core/lib/bootstrap.js +88 -0
  27. package/dist/assets/computer/node_modules/playwright-core/lib/coreBundle.js +74830 -0
  28. package/dist/assets/computer/node_modules/playwright-core/lib/utilsBundle.js +90764 -0
  29. package/dist/assets/computer/node_modules/playwright-core/lib/utilsBundle.js.LICENSE +2179 -0
  30. package/dist/assets/computer/node_modules/playwright-core/lib/webp_codec.LICENSE +173 -0
  31. package/dist/assets/computer/node_modules/playwright-core/lib/webp_codec.wasm +0 -0
  32. package/dist/assets/computer/node_modules/playwright-core/lib/xdg-open +1267 -0
  33. package/dist/assets/computer/node_modules/playwright-core/package.json +34 -0
  34. package/dist/assets/computer/skills/core/docx/LICENSE +21 -0
  35. package/dist/assets/computer/skills/core/docx/SKILL.md +21 -0
  36. package/dist/assets/computer/skills/core/docx/references/revisions-and-comments.md +88 -0
  37. package/dist/assets/computer/skills/core/docx/scripts/docx_comments.py +289 -0
  38. package/dist/assets/computer/skills/core/docx/scripts/docx_common.py +94 -0
  39. package/dist/assets/computer/skills/core/docx/scripts/docx_create.py +177 -0
  40. package/dist/assets/computer/skills/core/docx/scripts/docx_edit.py +250 -0
  41. package/dist/assets/computer/skills/core/docx/scripts/docx_read.py +149 -0
  42. package/dist/assets/computer/skills/core/docx/scripts/docx_revisions.py +147 -0
  43. package/dist/assets/computer/skills/core/docx/scripts/docx_template.py +70 -0
  44. package/dist/assets/computer/skills/core/docx/scripts/docx_validate.py +156 -0
  45. package/dist/assets/computer/skills/core/ocr-and-documents/LICENSE +21 -0
  46. package/dist/assets/computer/skills/core/ocr-and-documents/SKILL.md +24 -0
  47. package/dist/assets/computer/skills/core/ocr-and-documents/scripts/ocr_document.py +132 -0
  48. package/dist/assets/computer/skills/core/pdf/LICENSE +21 -0
  49. package/dist/assets/computer/skills/core/pdf/SKILL.md +25 -0
  50. package/dist/assets/computer/skills/core/pdf/references/forms.md +100 -0
  51. package/dist/assets/computer/skills/core/pdf/scripts/_raster.py +76 -0
  52. package/dist/assets/computer/skills/core/pdf/scripts/pdf_create.py +130 -0
  53. package/dist/assets/computer/skills/core/pdf/scripts/pdf_fill_form.py +97 -0
  54. package/dist/assets/computer/skills/core/pdf/scripts/pdf_form_layout.py +168 -0
  55. package/dist/assets/computer/skills/core/pdf/scripts/pdf_make_form.py +145 -0
  56. package/dist/assets/computer/skills/core/pdf/scripts/pdf_merge.py +50 -0
  57. package/dist/assets/computer/skills/core/pdf/scripts/pdf_meta.py +115 -0
  58. package/dist/assets/computer/skills/core/pdf/scripts/pdf_page_image.py +99 -0
  59. package/dist/assets/computer/skills/core/pdf/scripts/pdf_read.py +153 -0
  60. package/dist/assets/computer/skills/core/pdf/scripts/pdf_secure.py +71 -0
  61. package/dist/assets/computer/skills/core/pdf/scripts/pdf_split.py +84 -0
  62. package/dist/assets/computer/skills/core/pdf/scripts/pdf_stamp.py +143 -0
  63. package/dist/assets/computer/skills/core/pdf/scripts/pdf_watermark.py +51 -0
  64. package/dist/assets/computer/skills/core/plan/LICENSE +21 -0
  65. package/dist/assets/computer/skills/core/plan/SKILL.md +21 -0
  66. package/dist/assets/computer/skills/core/powerpoint/LICENSE +21 -0
  67. package/dist/assets/computer/skills/core/powerpoint/SKILL.md +18 -0
  68. package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_create.py +214 -0
  69. package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_edit.py +436 -0
  70. package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_from_template.py +88 -0
  71. package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_read.py +131 -0
  72. package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_render.py +93 -0
  73. package/dist/assets/computer/skills/core/xlsx/LICENSE +21 -0
  74. package/dist/assets/computer/skills/core/xlsx/SKILL.md +22 -0
  75. package/dist/assets/computer/skills/core/xlsx/references/restructuring.md +71 -0
  76. package/dist/assets/computer/skills/core/xlsx/scripts/csv_to_xlsx.py +104 -0
  77. package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_create.py +259 -0
  78. package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_edit.py +263 -0
  79. package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_read.py +160 -0
  80. package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_recalc.py +110 -0
  81. package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_restructure.py +337 -0
  82. package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_to_csv.py +69 -0
  83. package/dist/assets/computer/skills/core-catalog.json +246 -0
  84. package/dist/assets/computer/skills-requirements.txt +18 -0
  85. package/dist/assets/computer/web-provider.py +592 -0
  86. package/dist/assets/computer/web-requirements.txt +37 -0
  87. package/dist/assets/gateway/Dockerfile +3 -1
  88. package/dist/assets/gateway/gateway.mjs +69 -40
  89. package/dist/assets/image-catalog.json +183 -0
  90. package/dist/qubicl.mjs +297 -167
  91. 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)