Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Nous Research
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+196
View File
@@ -0,0 +1,196 @@
---
name: xlsx
description: Create, read, edit Excel .xlsx workbooks and CSVs.
version: 1.1.0
author: Nous Research
license: MIT
platforms: [linux, macos, windows]
metadata:
hermes:
tags: [excel, spreadsheet, xlsx, csv, openpyxl, productivity]
category: productivity
related_skills: [docx, pdf, powerpoint]
---
# Xlsx Skill
Work with Excel .xlsx workbooks using Python and openpyxl: build styled
multi-sheet workbooks with formulas and charts, inspect or dump existing
files, edit cells and structure, and convert to/from CSV. All helper
scripts are argparse CLIs that print JSON and use explicit UTF-8 I/O.
## When to Use
- Creating .xlsx reports: multiple sheets, number formats, styling,
merged cells, freeze panes, autofilter, conditional formatting,
charts, data-validation dropdowns, native Excel tables, defined
names, hyperlinks, cell notes, sheet protection.
- Reading a workbook: sheet inventory, dumping data as JSON or CSV,
listing formulas vs cached values, notes, defined names, tables.
- Editing existing files: set cells, append rows, insert/delete
rows/columns (reference-aware via `xlsx_restructure.py`),
copy/rename sheets, tables, names, notes, protection.
- Recalculating formulas headlessly via LibreOffice
(`xlsx_recalc.py`).
- CSV interop with type inference and non-UTF-8 encodings.
- Not for the legacy .xls binary format (use LibreOffice to convert
first: `soffice --headless --convert-to xlsx old.xls`).
## Prerequisites
- Python 3.10+ with `openpyxl` (`pip install openpyxl`). No other
third-party packages are needed; everything else is stdlib.
- Optional: LibreOffice (`soffice`) for headless recalculation or
format conversion.
## How to Run
Run the helper scripts with the `terminal` tool from this skill's
`scripts/` directory (every script supports `--help`):
```bash
python scripts/xlsx_create.py spec.json report.xlsx # build from JSON spec
python scripts/xlsx_read.py report.xlsx --sheets # inventory
python scripts/xlsx_read.py report.xlsx --json --sheet Data
python scripts/xlsx_read.py report.xlsx --formulas
python scripts/xlsx_edit.py report.xlsx --sheet Data --set B2=42 --recalc
python scripts/xlsx_restructure.py report.xlsx --sheet Data --insert-rows 3:2
python scripts/xlsx_recalc.py report.xlsx
python scripts/csv_to_xlsx.py data.csv out.xlsx --encoding utf-8
python scripts/xlsx_to_csv.py report.xlsx out.csv --sheet Data
```
Author the JSON spec with `write_file`, inspect script JSON output with
`read_file` or directly from stdout.
## Quick Reference
| Task | Command |
|---|---|
| Create workbook from spec | `xlsx_create.py spec.json out.xlsx` |
| Sheet names + dimensions | `xlsx_read.py f.xlsx --sheets` |
| Dump sheet as JSON | `xlsx_read.py f.xlsx --json --sheet S` |
| Dump sheet as CSV | `xlsx_read.py f.xlsx --csv --out d.csv` |
| List formulas + cached values | `xlsx_read.py f.xlsx --formulas` |
| Set a cell / formula | `xlsx_edit.py f.xlsx --set "A1==SUM(B:B)"` |
| Append a row | `xlsx_edit.py f.xlsx --append '[1,"x",true]'` |
| Insert 2 rows, refs NOT shifted | `xlsx_edit.py f.xlsx --insert-rows 3:2` |
| Insert 2 rows, refs shifted | `xlsx_restructure.py f.xlsx --insert-rows 3:2` |
| Delete a column, refs shifted | `xlsx_restructure.py f.xlsx --delete-cols B` |
| Create a native table | `xlsx_edit.py f.xlsx --add-table Sales:A1:C9` |
| Append inside a table | `--table-append 'Sales=["West",5]'` |
| List tables | `xlsx_edit.py f.xlsx --list-tables` |
| Defined names | `--define-name "Rates='Data'!$B$2:$B$9"` / `--delete-name Rates` / `xlsx_read.py f.xlsx --names` |
| Hyperlink | `--hyperlink "A1=https://example.com|Docs"` |
| Cell note | `--note "B2=Check this|Reviewer"`; read via `xlsx_read.py f.xlsx --notes` |
| Protect sheet (see Pitfalls) | `--protect your-password --unlock B2:B9` |
| Recalculate via LibreOffice | `xlsx_recalc.py f.xlsx` |
| Copy / rename sheet | `--copy-sheet Src:New --rename-sheet Old:New` |
| Force recalc on open | `xlsx_edit.py f.xlsx --recalc` |
| CSV -> styled xlsx | `csv_to_xlsx.py in.csv out.xlsx` |
| xlsx -> CSV | `xlsx_to_csv.py f.xlsx out.csv --encoding utf-8` |
## Procedure
1. **Create**: write a JSON spec (schema documented in
`xlsx_create.py --help` and its docstring). Each sheet supports
`rows` (scalars or styled cell objects), sparse `cells` overrides,
`column_widths`, `row_heights`, `merges`, `freeze_panes`,
`autofilter`, `conditional_formats` (cell_is rules and color
scales), `charts` (bar/line/pie from cell ranges),
`validations` (list dropdowns), `tables` (native Excel tables with
a style name), and `protection`. Workbook-level `defined_names`
maps names to refs. Cell objects also take `hyperlink` and `note`.
Typed values: JSON numbers/bools
pass through; dates use `{"value": "2026-01-31", "type": "date"}`.
Number formats are Excel format strings: currency `"$#,##0.00"`,
percent `"0.0%"`, date `"yyyy-mm-dd"`.
2. **Formulas**: set with `"formula": "SUM(B2:B9)"` in the spec or
`--set "C1==SUM(A:A)"` in the editor. When writing formulas, add
`"full_calc_on_load": true` (spec) or `--recalc` (editor); this sets
the workbook's `fullCalcOnLoad` flag so Excel/LibreOffice recompute
everything on open. openpyxl itself NEVER evaluates formulas.
3. **Read**: `--sheets` for inventory (names, dimensions, merged
ranges, chart count, tables, protection, defined names),
`--json`/`--csv` for data, `--formulas` to
pair each formula string with its cached result, `--notes` for
cell comments, `--names` for defined names. Cached results
exist only if the file was last saved by a real spreadsheet app;
files fresh from openpyxl return `null` there. To materialize
results headlessly run `xlsx_recalc.py file.xlsx` (uses
LibreOffice; prints `{"recalculated": false, ...}` and exits 0
when `soffice` is absent), then reload with `--data-only`.
4. **Edit**: `xlsx_edit.py` applies renames/copies first, then
structural row/column changes, then `--set`/`--append`. It edits in
place unless `--out` is given — copy the file first if you need the
original.
5. **Restructure**: for insert/delete on sheets that have formulas,
merges, tables, or filters, use `xlsx_restructure.py` instead of
`xlsx_edit.py`. It rewrites formula references on ALL sheets
(absolute `$` refs, ranges, cross-sheet refs), shifts merges,
autofilter, freeze panes, validation and conditional-format
ranges, table refs, defined names, and row/column dimensions, then
prints a JSON report including a `not_shifted` list. Rules and
limits: `references/restructuring.md`.
6. **CSV interop**: `csv_to_xlsx.py` infers int/float/bool/ISO-date
per cell and styles the header row; `xlsx_to_csv.py` writes ISO
dates and blank strings for empty cells. Both default to UTF-8 and
accept `--encoding` (e.g. `utf-8-sig` for Excel-friendly BOM,
`cp1252` for legacy Windows exports).
## Converting to PDF
LibreOffice converts headlessly (also works for CSV export of a single
sheet):
```bash
soffice --headless --convert-to pdf report.xlsx --outdir out/
soffice --headless --convert-to csv report.xlsx --outdir out/ # 1st sheet only
```
Only the first sheet lands in a CSV; for other sheets use
`xlsx_to_csv.py --sheet NAME`. If `soffice` is missing, install
LibreOffice or hand the file to the user unconverted.
## Pitfalls
- **openpyxl does not calculate.** Formula results are available only
via `load_workbook(path, data_only=True)` and only when the file was
previously saved by Excel/LibreOffice. Otherwise you get `None`.
- **`xlsx_edit.py` insert/delete does not shift references** (raw
openpyxl behavior). Use `xlsx_restructure.py`, which does — but even
it cannot move chart anchors, images, or conditional-format RULE
formulas; read its JSON report's `not_shifted` list and
`references/restructuring.md`.
- **Sheet protection is NOT security.** `--protect` sets the standard
xlsx sheet-protection hash: it signals "don't edit this" to
well-behaved apps and nothing more. Anyone can strip it by editing
the zip's XML or unchecking it in LibreOffice. Never rely on it for
confidentiality or integrity; it does not encrypt anything.
- **`data_only=True` then save** silently discards all formulas
(cached values replace them). Never save a workbook loaded that way
unless that is the goal.
- **Loading strips charts/images**: openpyxl does not round-trip
charts, so editing a charted workbook and saving drops the charts.
Re-add charts after editing, or avoid re-saving charted files.
- **CSV locale traps**: always pass explicit encodings (the scripts
already do) and remember European CSVs often use `;` delimiters and
decimal commas — use `--delimiter ';'` and expect strings like
`"12,5"` to stay strings.
- **Dates are datetimes**: Excel stores dates as serial numbers;
openpyxl returns `datetime`/`date` objects. Dumps here emit ISO
strings.
- Sheet names are capped at 31 chars and reject `[ ] : * ? / \`.
## Verification
- After creating: `xlsx_read.py out.xlsx --sheets` and confirm sheet
names, dimensions, merged ranges, and chart counts match intent.
- Dump data with `--json` and compare against the source values.
- After edits: re-dump the touched range; if formulas were written,
confirm `--formulas` lists them and that `--recalc` was applied.
- After `xlsx_restructure.py`: read its JSON report, then re-run
`--formulas` and `--sheets` to confirm references and ranges landed
where expected.
- For a full visual check, open in LibreOffice:
`soffice --headless --convert-to pdf out.xlsx` and inspect the PDF.
@@ -0,0 +1,71 @@
# Reference-aware restructuring (xlsx_restructure.py)
`scripts/xlsx_restructure.py` performs one row/column insert or delete
and rewrites everything that references the moved cells. This document
gives the exact rewrite rules and honest limits.
## What gets rewritten
| Artifact | Scope | Behavior |
|---|---|---|
| Formula references | ALL sheets | A1 refs into the edited sheet are shifted; refs into a fully deleted region become `#REF!` |
| Merged-cell ranges | edited sheet | shifted; expanded when they span the insertion point; dropped (reported `to: null`) when fully deleted |
| Autofilter ref | edited sheet | shifted/expanded like a range |
| Freeze panes | edited sheet | anchor cell shifted (never below row/col of the pane's own minimum) |
| Data validations | edited sheet | each range in the sqref shifted; deleted ranges removed |
| Conditional formats | edited sheet | applied range (sqref) shifted |
| Native tables | edited sheet | table `ref` shifted/expanded |
| Defined names | workbook scope | `attr_text` refs into the edited sheet rewritten |
| Row heights / column widths | edited sheet | dimension keys re-indexed |
## Reference grammar handled
- Relative and absolute coordinates in any mix: `B2`, `$B2`, `B$2`,
`$B$2` — the `$` flags are preserved through the shift.
- Ranges `B2:D9`, including partial-absolute endpoints.
- Cross-sheet refs: `Data!B2`, `'My Sheet'!$A$1:$C$9` (quoted names may
contain doubled quotes `''`). Only refs whose sheet qualifier matches
the edited sheet are touched; unqualified refs are interpreted
relative to the formula's own sheet.
- String literals inside formulas (`"See B2"`) are never rewritten.
- Function names that look like cells (`LOG10(...)`) are not touched
(a reference is never followed by `(`).
- Whole-row/column refs (`B:B`, `2:2`) pass through unchanged — Excel
semantics keep them valid across inserts within the span.
## Shift semantics
Insert of N at index i: every coordinate >= i moves +N; range endpoints
move independently, so a range spanning i grows by N.
Delete of N at index i: coordinates before i are unchanged; coordinates
past the deleted block move -N; a single cell inside the block becomes
`#REF!`; a RANGE partially covering the block is clamped (Excel does the
same); a range entirely inside the block becomes `#REF!` (formulas) or
is removed (merges/validations).
## What it CANNOT shift (honest limits)
- **Chart anchors and plotted ranges** — openpyxl chart objects are not
reliably round-tripped; anchors stay where they were. Re-create
charts after restructuring if their data moved.
- **Images / drawings** — same reason.
- **Conditional-format RULE formulas** — the applied range (sqref) is
shifted, but formulas inside `cell_is`/`expression` rules (e.g.
`$B1>100`) are left as-is. Review them if they reference moved cells.
- **Sheet-local defined names** and names using R1C1 or union/
intersection operators are rewritten only if they parse as plain A1
refs; anything else passes through untouched.
- **Structured table references** in formulas (`Table1[Sales]`) don't
need shifting (they follow the table), and are left alone.
Every run prints a JSON report listing exactly which formulas, merges,
tables, names, and ranges were changed, plus a fixed `not_shifted` list
of the above limits — inspect it after any structural edit.
## One op per invocation
The CLI takes exactly one of `--insert-rows/--delete-rows/
--insert-cols/--delete-cols` (as `IDX[:N]`; columns accept letters).
For multiple operations run it repeatedly — ordering compound shifts in
one pass is where spreadsheet tools historically corrupt references.
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Convert a CSV file to a styled .xlsx workbook with type inference.
Type inference per cell (disable with --no-infer):
int, float, bool ("true"/"false", case-insensitive), ISO date
(YYYY-MM-DD) and ISO datetime; everything else stays a string.
Styling applied by default (disable with --plain):
bold header row with a light fill, frozen top row, autofilter over the
data range, and column widths sized to the longest cell (capped at 60).
Usage:
csv_to_xlsx.py data.csv out.xlsx
csv_to_xlsx.py data.csv out.xlsx --sheet-name Import --encoding cp1252
csv_to_xlsx.py data.csv out.xlsx --delimiter ';' --no-infer
"""
from __future__ import annotations
import argparse
import csv
import json
import sys
from datetime import date, datetime
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill
from openpyxl.utils import get_column_letter
MAX_COL_WIDTH = 60
COL_PADDING = 2
DEFAULT_COL_WIDTH = 8
def infer(text):
if text == "":
return None
low = text.lower()
if low in ("true", "false"):
return low == "true"
for caster in (int, float):
try:
return caster(text)
except ValueError:
pass
for parser in (date.fromisoformat, datetime.fromisoformat):
try:
return parser(text)
except ValueError:
pass
return text
def main(argv=None):
ap = argparse.ArgumentParser(description="CSV -> styled .xlsx converter.")
ap.add_argument("csv_file", help="input CSV path")
ap.add_argument("output", help="output .xlsx path")
ap.add_argument("--sheet-name", default="Sheet1")
ap.add_argument("--encoding", default="utf-8",
help="CSV file encoding (default utf-8)")
ap.add_argument("--delimiter", default=",")
ap.add_argument("--no-infer", action="store_true",
help="keep every cell as a string")
ap.add_argument("--plain", action="store_true",
help="skip header styling / freeze / autofilter")
args = ap.parse_args(argv)
with open(args.csv_file, newline="", encoding=args.encoding) as fh:
rows = list(csv.reader(fh, delimiter=args.delimiter))
wb = Workbook()
ws = wb.active
ws.title = args.sheet_name
for i, row in enumerate(rows):
if args.no_infer or i == 0:
ws.append(row)
else:
ws.append([infer(cell) for cell in row])
if rows and not args.plain:
header_font = Font(bold=True)
header_fill = PatternFill("solid", fgColor="DDEBF7")
for cell in ws[1]:
cell.font = header_font
cell.fill = header_fill
ws.freeze_panes = "A2"
ws.auto_filter.ref = ws.dimensions
for col_idx in range(1, ws.max_column + 1):
longest = max((len(str(r[col_idx - 1])) for r in rows
if len(r) >= col_idx), default=DEFAULT_COL_WIDTH)
ws.column_dimensions[get_column_letter(col_idx)].width = \
min(longest + COL_PADDING, MAX_COL_WIDTH)
wb.save(args.output)
print(json.dumps({"ok": True, "output": args.output,
"rows": len(rows)}, ensure_ascii=False))
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as exc: # noqa: BLE001
print(json.dumps({"ok": False, "error": str(exc)}), file=sys.stderr)
sys.exit(1)
@@ -0,0 +1,259 @@
#!/usr/bin/env python3
"""Create an .xlsx workbook from a JSON spec.
Spec (JSON object):
{
"full_calc_on_load": true, # force recalculation on open (optional)
"defined_names": {"Rates": "'Data'!$B$2:$B$4"}, # workbook scope
"sheets": [
{
"name": "Data",
"rows": [["Header", 1, true], ...], # scalars or cell objects (see below)
"cells": {"A1": {"value": 5, "format": "0.00%"}}, # sparse overrides
"column_widths": {"A": 22, "B": 12},
"row_heights": {"1": 24},
"merges": ["A1:C1"],
"freeze_panes": "A2",
"autofilter": "A1:C10",
"conditional_formats": [
{"range": "B2:B9", "type": "cell_is", "operator": "greaterThan",
"formula": ["100"], "fill": "FFC7CE"},
{"range": "C2:C9", "type": "color_scale"}
],
"charts": [
{"type": "bar", "title": "Sales", "anchor": "F2",
"data": "B1:B5", "categories": "A2:A5"}
],
"validations": [
{"range": "D2:D9", "type": "list", "formula1": "\"Yes,No,Maybe\""}
],
"tables": [
{"name": "Sales", "range": "A1:C4",
"style": "TableStyleMedium9"} # native Excel table
],
"protection": {"password": "your-password", # NOT security --
"unlock": ["B2:B9"]} # see SKILL.md Pitfalls
}
]
}
Cell object keys (all optional except value/formula):
value scalar; JSON true/false -> bool, numbers stay numeric
type "date" or "datetime" -> value parsed from ISO string
formula e.g. "=SUM(A2:A9)" (leading '=' optional)
hyperlink URL; value becomes the display text
note cell note text (or {"text": ..., "author": ...})
format Excel number format, e.g. "$#,##0.00", "0.0%", "yyyy-mm-dd"
bold, italic booleans
font_size points
font_color hex RGB like "FF0000"
fill solid fill hex RGB like "DDEBF7"
border "thin" | "medium" | "thick" (all four sides)
align "left" | "center" | "right"
valign "top" | "center" | "bottom"
wrap boolean (wrap text)
Usage:
xlsx_create.py spec.json out.xlsx
xlsx_create.py - out.xlsx (spec on stdin)
Prints a JSON summary to stdout; exits non-zero on failure.
"""
from __future__ import annotations
import argparse
import json
import sys
from datetime import date, datetime
from openpyxl import Workbook
from openpyxl.chart import BarChart, LineChart, PieChart, Reference
from openpyxl.comments import Comment
from openpyxl.formatting.rule import CellIsRule, ColorScaleRule
from openpyxl.styles import (Alignment, Border, Font, PatternFill,
Protection, Side)
from openpyxl.utils import column_index_from_string, range_boundaries
from openpyxl.workbook.defined_name import DefinedName
from openpyxl.worksheet.datavalidation import DataValidation
from openpyxl.worksheet.table import Table, TableStyleInfo
def parse_typed(value, type_hint=None):
if type_hint == "date" and isinstance(value, str):
return date.fromisoformat(value)
if type_hint == "datetime" and isinstance(value, str):
return datetime.fromisoformat(value)
return value
def apply_cell(ws, coord, spec):
cell = ws[coord]
if isinstance(spec, dict):
if "formula" in spec:
f = spec["formula"]
cell.value = f if f.startswith("=") else "=" + f
elif "value" in spec:
cell.value = parse_typed(spec["value"], spec.get("type"))
if "hyperlink" in spec:
cell.hyperlink = spec["hyperlink"]
if cell.value is None:
cell.value = spec["hyperlink"]
cell.style = "Hyperlink"
if "note" in spec:
note = spec["note"]
if isinstance(note, dict):
cell.comment = Comment(note.get("text", ""),
note.get("author", "xlsx-skill"))
else:
cell.comment = Comment(str(note), "xlsx-skill")
if "format" in spec:
cell.number_format = spec["format"]
font_kw = {}
if spec.get("bold"):
font_kw["bold"] = True
if spec.get("italic"):
font_kw["italic"] = True
if "font_size" in spec:
font_kw["size"] = spec["font_size"]
if "font_color" in spec:
font_kw["color"] = spec["font_color"]
if font_kw:
cell.font = Font(**font_kw)
if "fill" in spec:
cell.fill = PatternFill("solid", fgColor=spec["fill"])
if "border" in spec:
side = Side(style=spec["border"])
cell.border = Border(left=side, right=side, top=side, bottom=side)
align_kw = {}
if "align" in spec:
align_kw["horizontal"] = spec["align"]
if "valign" in spec:
align_kw["vertical"] = spec["valign"]
if spec.get("wrap"):
align_kw["wrap_text"] = True
if align_kw:
cell.alignment = Alignment(**align_kw)
else:
cell.value = spec
def ref_from_range(ws, rng):
min_col, min_row, max_col, max_row = range_boundaries(rng)
return Reference(ws, min_col=min_col, min_row=min_row,
max_col=max_col, max_row=max_row)
def add_chart(ws, spec):
kind = spec.get("type", "bar")
chart = {"bar": BarChart, "line": LineChart, "pie": PieChart}[kind]()
if "title" in spec:
chart.title = spec["title"]
data = ref_from_range(ws, spec["data"])
chart.add_data(data, titles_from_data=spec.get("titles_from_data", True))
if "categories" in spec:
chart.set_categories(ref_from_range(ws, spec["categories"]))
ws.add_chart(chart, spec.get("anchor", "H2"))
def add_conditional(ws, spec):
rng = spec["range"]
kind = spec.get("type", "cell_is")
if kind == "color_scale":
rule = ColorScaleRule(
start_type="min", start_color=spec.get("start_color", "FFF8696B"),
end_type="max", end_color=spec.get("end_color", "FF63BE7B"))
else:
fill = PatternFill("solid", fgColor=spec.get("fill", "FFC7CE"))
rule = CellIsRule(operator=spec.get("operator", "greaterThan"),
formula=spec.get("formula", ["0"]), fill=fill)
ws.conditional_formatting.add(rng, rule)
def build_sheet(ws, spec):
for row in spec.get("rows", []):
values, styled = [], []
for item in row:
if isinstance(item, dict):
values.append(None)
styled.append(item)
else:
values.append(item)
styled.append(None)
ws.append(values)
r = ws.max_row
for idx, item in enumerate(styled, start=1):
if item is not None:
apply_cell(ws, ws.cell(row=r, column=idx).coordinate, item)
for coord, cell_spec in spec.get("cells", {}).items():
apply_cell(ws, coord, cell_spec)
for col, width in spec.get("column_widths", {}).items():
ws.column_dimensions[col].width = width
for row, height in spec.get("row_heights", {}).items():
ws.row_dimensions[int(row)].height = height
for rng in spec.get("merges", []):
ws.merge_cells(rng)
if spec.get("freeze_panes"):
ws.freeze_panes = spec["freeze_panes"]
if spec.get("autofilter"):
ws.auto_filter.ref = spec["autofilter"]
for cf in spec.get("conditional_formats", []):
add_conditional(ws, cf)
for ch in spec.get("charts", []):
add_chart(ws, ch)
for dv_spec in spec.get("validations", []):
dv = DataValidation(type=dv_spec.get("type", "list"),
formula1=dv_spec["formula1"],
allow_blank=dv_spec.get("allow_blank", True))
dv.add(dv_spec["range"])
ws.add_data_validation(dv)
for t_spec in spec.get("tables", []):
table = Table(displayName=t_spec["name"], ref=t_spec["range"])
table.tableStyleInfo = TableStyleInfo(
name=t_spec.get("style", "TableStyleMedium9"),
showRowStripes=t_spec.get("row_stripes", True),
showColumnStripes=t_spec.get("column_stripes", False))
ws.add_table(table)
prot = spec.get("protection")
if prot:
for rng in prot.get("unlock", []):
for row in ws[rng]:
for cell in row:
cell.protection = Protection(locked=False)
if prot.get("password"):
ws.protection.password = prot["password"]
ws.protection.sheet = True
def main(argv=None):
ap = argparse.ArgumentParser(description="Create .xlsx from a JSON spec.")
ap.add_argument("spec", help="path to JSON spec, or '-' for stdin")
ap.add_argument("output", help="output .xlsx path")
args = ap.parse_args(argv)
if args.spec == "-":
spec = json.load(sys.stdin)
else:
with open(args.spec, encoding="utf-8") as fh:
spec = json.load(fh)
wb = Workbook()
wb.remove(wb.active)
for sheet_spec in spec.get("sheets", []):
ws = wb.create_sheet(sheet_spec.get("name", "Sheet1"))
build_sheet(ws, sheet_spec)
for name, ref in spec.get("defined_names", {}).items():
wb.defined_names[name] = DefinedName(name, attr_text=ref)
if spec.get("full_calc_on_load"):
wb.calculation.fullCalcOnLoad = True
wb.save(args.output)
print(json.dumps({"ok": True, "output": args.output,
"sheets": wb.sheetnames}, ensure_ascii=False))
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as exc: # noqa: BLE001
print(json.dumps({"ok": False, "error": str(exc)}), file=sys.stderr)
sys.exit(1)
@@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""Edit an existing .xlsx workbook in place (or to --out).
Operations (repeatable where noted, applied in the order listed below):
--rename-sheet OLD:NEW rename a sheet
--copy-sheet SRC:NEW duplicate a sheet under a new name
--insert-rows IDX[:N] insert N rows before row IDX (default N=1)
--delete-rows IDX[:N] delete N rows starting at row IDX
--insert-cols IDX[:N] insert N columns before column IDX (number)
--delete-cols IDX[:N] delete N columns starting at column IDX
--set CELL=VALUE repeatable; type-inferred (int, float, bool,
ISO date, else string). '=...' sets a formula.
--append ROWJSON repeatable; JSON array appended as a row
--add-table NAME:RANGE[:STYLE] create a native Excel table (ListObject)
--table-append NAME=ROWJSON append a row inside a table, auto-extending
the table's range (repeatable)
--list-tables print tables on the target sheet and exit
--define-name NAME=REF workbook-scope defined name, e.g.
"Rates='Data'!$B$2:$B$9" (repeatable)
--delete-name NAME remove a defined name (repeatable)
--hyperlink CELL=URL[|TEXT] set a hyperlink (optional display text)
--note CELL=TEXT[|AUTHOR] set a cell note/comment (repeatable)
--clear-note CELL remove a cell note (repeatable)
--protect [PASSWORD] enable sheet protection; combine with
--unlock RANGE to leave ranges editable.
NOT security: trivially strippable (see
SKILL.md Pitfalls).
--recalc set fullCalcOnLoad so Excel/LibreOffice
recomputes all formulas on next open
WARNING: openpyxl does NOT shift merged-cell ranges, chart anchors, or
formula references when rows/columns are inserted or deleted. Verify any
sheet containing merges or formulas after structural edits — or use
xlsx_restructure.py, which rewrites references for you.
Usage:
xlsx_edit.py book.xlsx --sheet Data --set B2=42 --set C2=2026-01-01 \
--set "D2==SUM(B2:C2)" --recalc
xlsx_edit.py book.xlsx --sheet Data --append '["Widget", 9.99, true]'
xlsx_edit.py book.xlsx --copy-sheet Data:Backup --rename-sheet Data:Main
"""
from __future__ import annotations
import argparse
import json
import sys
from datetime import date, datetime
from openpyxl import load_workbook
from openpyxl.comments import Comment
from openpyxl.styles import Protection
from openpyxl.utils import get_column_letter, range_boundaries
from openpyxl.workbook.defined_name import DefinedName
from openpyxl.worksheet.table import Table, TableStyleInfo
def infer(text):
if text.startswith("="):
return text # formula
low = text.lower()
if low in ("true", "false"):
return low == "true"
for caster in (int, float):
try:
return caster(text)
except ValueError:
pass
for parser in (date.fromisoformat, datetime.fromisoformat):
try:
return parser(text)
except ValueError:
pass
return text
def parse_idx(arg):
if ":" in arg:
idx, n = arg.split(":", 1)
return int(idx), int(n)
return int(arg), 1
def add_table(ws, spec):
parts = spec.split(":")
if len(parts) < 3:
raise ValueError("--add-table needs NAME:RANGE like Sales:A1:C9")
name = parts[0]
rng = ":".join(parts[1:3])
style = parts[3] if len(parts) > 3 else "TableStyleMedium9"
table = Table(displayName=name, ref=rng)
table.tableStyleInfo = TableStyleInfo(name=style, showRowStripes=True)
ws.add_table(table)
def table_append(ws, name, row_values):
table = ws.tables[name]
min_col, min_row, max_col, max_row = range_boundaries(table.ref)
new_row = max_row + 1
for offset, value in enumerate(row_values):
ws.cell(row=new_row, column=min_col + offset, value=value)
table.ref = (f"{get_column_letter(min_col)}{min_row}:"
f"{get_column_letter(max_col)}{new_row}")
def main(argv=None):
ap = argparse.ArgumentParser(
description="Edit an existing .xlsx workbook.",
epilog="Plain insert/delete does not shift merges/formula refs — "
"use xlsx_restructure.py for reference-aware moves.")
ap.add_argument("file", help="path to .xlsx file")
ap.add_argument("--sheet", help="target sheet (default: active)")
ap.add_argument("--out", help="output path (default: edit in place)")
ap.add_argument("--rename-sheet", action="append", default=[],
metavar="OLD:NEW")
ap.add_argument("--copy-sheet", action="append", default=[],
metavar="SRC:NEW")
ap.add_argument("--insert-rows", action="append", default=[],
metavar="IDX[:N]")
ap.add_argument("--delete-rows", action="append", default=[],
metavar="IDX[:N]")
ap.add_argument("--insert-cols", action="append", default=[],
metavar="IDX[:N]")
ap.add_argument("--delete-cols", action="append", default=[],
metavar="IDX[:N]")
ap.add_argument("--set", action="append", default=[], metavar="CELL=VALUE")
ap.add_argument("--append", action="append", default=[], metavar="ROWJSON")
ap.add_argument("--add-table", action="append", default=[],
metavar="NAME:RANGE[:STYLE]")
ap.add_argument("--table-append", action="append", default=[],
metavar="NAME=ROWJSON")
ap.add_argument("--list-tables", action="store_true",
help="print tables on the target sheet and exit")
ap.add_argument("--define-name", action="append", default=[],
metavar="NAME=REF")
ap.add_argument("--delete-name", action="append", default=[],
metavar="NAME")
ap.add_argument("--hyperlink", action="append", default=[],
metavar="CELL=URL[|TEXT]")
ap.add_argument("--note", action="append", default=[],
metavar="CELL=TEXT[|AUTHOR]")
ap.add_argument("--clear-note", action="append", default=[],
metavar="CELL")
ap.add_argument("--protect", nargs="?", const="", metavar="PASSWORD",
help="protect the target sheet (integrity signal only, "
"NOT security)")
ap.add_argument("--unlock", action="append", default=[], metavar="RANGE",
help="cell range left editable under --protect")
ap.add_argument("--recalc", action="store_true",
help="force full recalculation when the file is opened")
args = ap.parse_args(argv)
wb = load_workbook(args.file)
changes = []
for pair in args.rename_sheet:
old, new = pair.split(":", 1)
wb[old].title = new
changes.append(f"rename {old}->{new}")
for pair in args.copy_sheet:
src, new = pair.split(":", 1)
copy = wb.copy_worksheet(wb[src])
copy.title = new
changes.append(f"copy {src}->{new}")
ws = wb[args.sheet] if args.sheet else wb.active
if args.list_tables:
print(json.dumps({"ok": True, "sheet": ws.title,
"tables": {t.displayName: {
"ref": t.ref,
"style": t.tableStyleInfo.name
if t.tableStyleInfo else None}
for t in ws.tables.values()}},
ensure_ascii=False))
return 0
for arg in args.insert_rows:
idx, n = parse_idx(arg)
ws.insert_rows(idx, n)
changes.append(f"insert_rows {idx}x{n}")
for arg in args.delete_rows:
idx, n = parse_idx(arg)
ws.delete_rows(idx, n)
changes.append(f"delete_rows {idx}x{n}")
for arg in args.insert_cols:
idx, n = parse_idx(arg)
ws.insert_cols(idx, n)
changes.append(f"insert_cols {idx}x{n}")
for arg in args.delete_cols:
idx, n = parse_idx(arg)
ws.delete_cols(idx, n)
changes.append(f"delete_cols {idx}x{n}")
for assignment in args.set:
coord, raw = assignment.split("=", 1)
ws[coord] = infer(raw)
changes.append(f"set {coord}")
for row_json in args.append:
ws.append(json.loads(row_json))
changes.append(f"append row {ws.max_row}")
for spec in args.add_table:
add_table(ws, spec)
changes.append(f"add_table {spec.split(':')[0]}")
for spec in args.table_append:
name, row_json = spec.split("=", 1)
table_append(ws, name, json.loads(row_json))
changes.append(f"table_append {name} -> {ws.tables[name].ref}")
for spec in args.define_name:
name, ref = spec.split("=", 1)
wb.defined_names[name] = DefinedName(name, attr_text=ref)
changes.append(f"define_name {name}")
for name in args.delete_name:
del wb.defined_names[name]
changes.append(f"delete_name {name}")
for spec in args.hyperlink:
coord, rest = spec.split("=", 1)
url, _, text = rest.partition("|")
cell = ws[coord]
cell.hyperlink = url
cell.value = text or (cell.value if cell.value is not None else url)
cell.style = "Hyperlink"
changes.append(f"hyperlink {coord}")
for spec in args.note:
coord, rest = spec.split("=", 1)
text, _, author = rest.partition("|")
ws[coord].comment = Comment(text, author or "xlsx-skill")
changes.append(f"note {coord}")
for coord in args.clear_note:
ws[coord].comment = None
changes.append(f"clear_note {coord}")
if args.protect is not None:
for rng in args.unlock:
for row in ws[rng]:
for cell in row:
cell.protection = Protection(locked=False)
if args.protect:
ws.protection.password = args.protect
ws.protection.sheet = True
changes.append(f"protect {ws.title}"
+ (f" (unlocked {len(args.unlock)} ranges)"
if args.unlock else ""))
if args.recalc:
wb.calculation.fullCalcOnLoad = True
changes.append("fullCalcOnLoad")
out = args.out or args.file
wb.save(out)
print(json.dumps({"ok": True, "output": out, "sheet": ws.title,
"changes": changes}, ensure_ascii=False))
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as exc: # noqa: BLE001
print(json.dumps({"ok": False, "error": str(exc)}), file=sys.stderr)
sys.exit(1)
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Read an .xlsx workbook: inventory, JSON/CSV dumps, formula listing.
Modes (pick one):
--sheets JSON inventory: sheet names, dimensions, row/col counts
--json dump one sheet's rows as a JSON array of arrays
--csv dump one sheet as CSV to stdout or --out
--formulas JSON list of formula cells {"cell", "formula", "cached"}
--notes JSON list of cell notes/comments across sheets
--names JSON map of workbook defined names
Options:
--sheet NAME sheet to dump (default: active sheet)
--data-only load cached formula RESULTS instead of formula strings.
Caveat: openpyxl never computes formulas; cached values
exist only if the file was last saved by Excel/LibreOffice.
--encoding ENC encoding for --csv --out files (default utf-8)
--out PATH write --csv output to a file instead of stdout
Usage:
xlsx_read.py book.xlsx --sheets
xlsx_read.py book.xlsx --json --sheet Data
xlsx_read.py book.xlsx --csv --sheet Data --out data.csv
xlsx_read.py book.xlsx --formulas
xlsx_read.py book.xlsx --notes
xlsx_read.py book.xlsx --names
"""
from __future__ import annotations
import argparse
import csv
import json
import sys
from datetime import date, datetime, time
from openpyxl import load_workbook
def jsonable(value):
if isinstance(value, (datetime, date, time)):
return value.isoformat()
return value
def sheet_rows(ws):
return [[jsonable(c) for c in row] for row in ws.iter_rows(values_only=True)]
def cmd_sheets(wb):
info = []
for ws in wb.worksheets:
info.append({
"name": ws.title,
"dimensions": ws.dimensions,
"max_row": ws.max_row,
"max_col": ws.max_column,
"merged": [str(r) for r in ws.merged_cells.ranges],
"charts": len(getattr(ws, "_charts", [])),
"freeze_panes": ws.freeze_panes,
"autofilter": ws.auto_filter.ref,
"tables": {t.displayName: t.ref for t in ws.tables.values()},
"protected": bool(ws.protection.sheet),
})
names = {name: dn.attr_text for name, dn in wb.defined_names.items()}
print(json.dumps({"sheets": info, "defined_names": names},
ensure_ascii=False, indent=2))
def cmd_notes(wb, sheet):
out = []
sheets = [sheet] if sheet else wb.sheetnames
for name in sheets:
for row in wb[name].iter_rows():
for cell in row:
if cell.comment is not None:
out.append({"sheet": name, "cell": cell.coordinate,
"text": cell.comment.text,
"author": cell.comment.author})
print(json.dumps({"notes": out}, ensure_ascii=False, indent=2))
def cmd_names(wb):
names = {name: dn.attr_text for name, dn in wb.defined_names.items()}
print(json.dumps({"defined_names": names}, ensure_ascii=False, indent=2))
def cmd_formulas(path, sheet):
wb_f = load_workbook(path, data_only=False)
wb_v = load_workbook(path, data_only=True)
out = []
sheets = [sheet] if sheet else wb_f.sheetnames
for name in sheets:
ws_f, ws_v = wb_f[name], wb_v[name]
for row in ws_f.iter_rows():
for cell in row:
if isinstance(cell.value, str) and cell.value.startswith("="):
out.append({
"sheet": name,
"cell": cell.coordinate,
"formula": cell.value,
"cached": jsonable(ws_v[cell.coordinate].value),
})
print(json.dumps({"formulas": out}, ensure_ascii=False, indent=2))
def main(argv=None):
ap = argparse.ArgumentParser(description="Read/inspect an .xlsx workbook.")
ap.add_argument("file", help="path to .xlsx file")
mode = ap.add_mutually_exclusive_group(required=True)
mode.add_argument("--sheets", action="store_true")
mode.add_argument("--json", action="store_true")
mode.add_argument("--csv", action="store_true")
mode.add_argument("--formulas", action="store_true")
mode.add_argument("--notes", action="store_true")
mode.add_argument("--names", action="store_true")
ap.add_argument("--sheet", help="sheet name (default: active)")
ap.add_argument("--data-only", action="store_true",
help="return cached formula results (see module docstring)")
ap.add_argument("--encoding", default="utf-8")
ap.add_argument("--out", help="output file for --csv")
args = ap.parse_args(argv)
if args.formulas:
cmd_formulas(args.file, args.sheet)
return 0
wb = load_workbook(args.file, data_only=args.data_only)
if args.sheets:
cmd_sheets(wb)
return 0
if args.notes:
cmd_notes(wb, args.sheet)
return 0
if args.names:
cmd_names(wb)
return 0
ws = wb[args.sheet] if args.sheet else wb.active
rows = sheet_rows(ws)
if args.json:
print(json.dumps({"sheet": ws.title, "rows": rows}, ensure_ascii=False))
else: # --csv
if args.out:
with open(args.out, "w", newline="", encoding=args.encoding) as fh:
csv.writer(fh).writerows(
[["" if v is None else v for v in r] for r in rows])
print(json.dumps({"ok": True, "out": args.out, "rows": len(rows)}))
else:
w = csv.writer(sys.stdout)
for r in rows:
w.writerow(["" if v is None else v for v in r])
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as exc: # noqa: BLE001
print(json.dumps({"ok": False, "error": str(exc)}), file=sys.stderr)
sys.exit(1)
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Recalculate a workbook's formulas headlessly with LibreOffice.
openpyxl never computes formulas. This script shells out to `soffice`
(LibreOffice) to open the workbook, recalculate, and re-save it, so
cached formula results become available to `xlsx_read.py --data-only`
and `--formulas`.
Behavior:
* soffice on PATH: converts the file to .xlsx in a temp dir (which
recalculates all formulas) and replaces the original (or writes
--out). Prints {"recalculated": true, ...} and exits 0.
* soffice absent: prints {"recalculated": false, "reason": ...} with
installation guidance and STILL exits 0 — callers can branch on the
JSON instead of the exit code.
Note: LibreOffice recalculates .xlsx on load per its default
calculation settings; conversion re-saves with fresh cached values.
Usage:
xlsx_recalc.py book.xlsx
xlsx_recalc.py book.xlsx --out recalced.xlsx
xlsx_recalc.py book.xlsx --timeout 120
"""
from __future__ import annotations
import argparse
import json
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
def count_cached(path):
"""Number of formula cells with a cached value present."""
from openpyxl import load_workbook
wb_f = load_workbook(path, data_only=False)
wb_v = load_workbook(path, data_only=True)
formulas = cached = 0
for name in wb_f.sheetnames:
ws_f, ws_v = wb_f[name], wb_v[name]
for row in ws_f.iter_rows():
for cell in row:
if isinstance(cell.value, str) and cell.value.startswith("="):
formulas += 1
if ws_v[cell.coordinate].value is not None:
cached += 1
return formulas, cached
def main(argv=None):
ap = argparse.ArgumentParser(
description="Recalculate .xlsx formulas headlessly via LibreOffice.")
ap.add_argument("file", help="path to .xlsx file")
ap.add_argument("--out", help="output path (default: replace input)")
ap.add_argument("--timeout", type=int, default=180,
help="seconds to wait for soffice (default 180)")
args = ap.parse_args(argv)
src = Path(args.file).resolve()
if not src.exists():
print(json.dumps({"ok": False, "error": f"no such file: {src}"}),
file=sys.stderr)
return 1
soffice = shutil.which("soffice")
if not soffice:
print(json.dumps({
"ok": True, "recalculated": False,
"reason": "LibreOffice (soffice) not found on PATH",
"guidance": "Install LibreOffice (e.g. `apt install "
"libreoffice-calc` or `brew install --cask "
"libreoffice`), or open the file in Excel/"
"LibreOffice once and re-save it.",
}, ensure_ascii=False))
return 0
with tempfile.TemporaryDirectory() as tmp:
proc = subprocess.run(
[soffice, "--headless", "--calc", "--convert-to", "xlsx:Calc "
"MS Excel 2007 XML", "--outdir", tmp, str(src)],
capture_output=True, text=True, encoding="utf-8",
timeout=args.timeout,
env={"HOME": tmp, "PATH": Path(soffice).parent.as_posix()
+ ":/usr/bin:/bin"})
produced = Path(tmp) / (src.stem + ".xlsx")
if proc.returncode != 0 or not produced.exists():
print(json.dumps({"ok": False,
"error": "soffice conversion failed",
"stderr": proc.stderr.strip()[-500:]}),
file=sys.stderr)
return 1
formulas, cached = count_cached(produced)
dest = Path(args.out).resolve() if args.out else src
shutil.copyfile(produced, dest)
print(json.dumps({
"ok": True, "recalculated": True, "output": str(dest),
"formula_cells": formulas, "with_cached_values": cached,
}, ensure_ascii=False))
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as exc: # noqa: BLE001
print(json.dumps({"ok": False, "error": str(exc)}), file=sys.stderr)
sys.exit(1)
@@ -0,0 +1,337 @@
#!/usr/bin/env python3
"""Reference-aware row/column insert and delete for .xlsx workbooks.
Unlike plain openpyxl insert_rows/delete_cols (and xlsx_edit.py's thin
wrappers), this script also rewrites everything that points at the moved
cells:
* formula references in ALL sheets, including absolute refs ($B$2),
ranges (B2:B9), and cross-sheet refs ('My Sheet'!A1 / Data!$B$8).
References into a deleted region become #REF!.
* merged-cell ranges (shifted; expanded when they span the insertion
point; removed when fully deleted)
* autofilter range, freeze panes, data-validation ranges,
conditional-formatting applied ranges (sqref)
* native table (ListObject) refs on the edited sheet
* workbook-scope defined names that point at the edited sheet
* row heights / column widths
It prints a JSON report of every rewrite it made and lists what it
could NOT shift (chart anchors, images, conditional-format RULE
formulas). Full rules and limits: references/restructuring.md.
One structural operation per invocation:
Usage:
xlsx_restructure.py book.xlsx --sheet Data --insert-rows 3:2
xlsx_restructure.py book.xlsx --sheet Data --delete-rows 5
xlsx_restructure.py book.xlsx --sheet Data --insert-cols B:1 --out new.xlsx
xlsx_restructure.py book.xlsx --sheet Data --delete-cols 4:2
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from openpyxl import load_workbook
from openpyxl.formatting.formatting import ConditionalFormattingList
from openpyxl.utils import (column_index_from_string, get_column_letter,
range_boundaries)
# A1-style reference, optionally sheet-qualified, optionally a range.
# Guards: not preceded by a word char/$/. (avoids ABC123 identifiers) and
# not followed by a word char or "(" (avoids function names like LOG10().
REF_RE = re.compile(
r"(?<![\w$.:])"
r"(?P<sheet>(?:'(?:[^']|'')+'|[A-Za-z_][A-Za-z0-9_.]*)!)?"
r"(?P<start>\$?[A-Za-z]{1,3}\$?[0-9]{1,7})"
r"(?::(?P<end>\$?[A-Za-z]{1,3}\$?[0-9]{1,7}))?"
r"(?![\w(])")
STRING_RE = re.compile(r'"(?:[^"]|"")*"')
COORD_RE = re.compile(r"^(\$?)([A-Za-z]{1,3})(\$?)([0-9]+)$")
def shift_point(v, idx, n, delete):
"""New 1-based index for a single row/col, or None if deleted."""
if delete:
if v < idx:
return v
if v >= idx + n:
return v - n
return None
return v + n if v >= idx else v
def shift_span(a, b, idx, n, delete):
"""New (start, end) for an inclusive span, or None if fully deleted."""
if delete:
na = a if a < idx else (a - n if a >= idx + n else idx)
nb = b if b < idx else (b - n if b >= idx + n else idx - 1)
return None if na > nb else (na, nb)
return (a + n if a >= idx else a, b + n if b >= idx else b)
def shift_range(rng, axis, idx, n, delete):
"""Shift an A1 range string (no sheet prefix). None = fully deleted."""
min_col, min_row, max_col, max_row = range_boundaries(rng)
if axis == "rows":
span = shift_span(min_row, max_row, idx, n, delete)
if span is None:
return None
min_row, max_row = span
else:
span = shift_span(min_col, max_col, idx, n, delete)
if span is None:
return None
min_col, max_col = span
start = f"{get_column_letter(min_col)}{min_row}"
end = f"{get_column_letter(max_col)}{max_row}"
return start if start == end and ":" not in rng else f"{start}:{end}"
class RefRewriter:
"""Rewrite A1 references in formula-like text for one shift op."""
def __init__(self, target_sheet, axis, idx, n, delete):
self.target = target_sheet.lower()
self.axis, self.idx, self.n, self.delete = axis, idx, n, delete
def _shift_coord(self, coord):
m = COORD_RE.match(coord)
col_abs, col, row_abs, row = m.groups()
ci, ri = column_index_from_string(col.upper()), int(row)
if self.axis == "rows":
ri = shift_point(ri, self.idx, self.n, self.delete)
if ri is None:
return None
else:
ci = shift_point(ci, self.idx, self.n, self.delete)
if ci is None:
return None
return f"{col_abs}{get_column_letter(ci)}{row_abs}{ri}"
def _shift_pair(self, start, end):
"""Shift a range preserving $ flags; None = collapsed to #REF!."""
new_start = self._shift_coord(start)
new_end = self._shift_coord(end)
if new_start is None or new_end is None:
# spans may survive partial deletion: clamp via span math
s, e = COORD_RE.match(start), COORD_RE.match(end)
if self.axis == "rows":
span = shift_span(int(s.group(4)), int(e.group(4)),
self.idx, self.n, self.delete)
if span is None:
return None
new_start = f"{s.group(1)}{s.group(2)}{s.group(3)}{span[0]}"
new_end = f"{e.group(1)}{e.group(2)}{e.group(3)}{span[1]}"
else:
span = shift_span(column_index_from_string(s.group(2).upper()),
column_index_from_string(e.group(2).upper()),
self.idx, self.n, self.delete)
if span is None:
return None
new_start = (f"{s.group(1)}{get_column_letter(span[0])}"
f"{s.group(3)}{s.group(4)}")
new_end = (f"{e.group(1)}{get_column_letter(span[1])}"
f"{e.group(3)}{e.group(4)}")
return new_start, new_end
def _sub(self, match, home_sheet):
prefix = match.group("sheet") or ""
if prefix:
name = prefix[:-1]
if name.startswith("'"):
name = name[1:-1].replace("''", "'")
ref_sheet = name
else:
ref_sheet = home_sheet
if ref_sheet.lower() != self.target:
return match.group(0)
start, end = match.group("start"), match.group("end")
if end is None:
new = self._shift_coord(start)
return prefix + ("#REF!" if new is None else new)
pair = self._shift_pair(start, end)
return (prefix + "#REF!" if pair is None
else f"{prefix}{pair[0]}:{pair[1]}")
def rewrite(self, text, home_sheet):
"""Rewrite refs outside quoted string literals. Returns new text."""
out, pos = [], 0
for lit in STRING_RE.finditer(text):
out.append(REF_RE.sub(lambda m: self._sub(m, home_sheet),
text[pos:lit.start()]))
out.append(lit.group(0))
pos = lit.end()
out.append(REF_RE.sub(lambda m: self._sub(m, home_sheet), text[pos:]))
return "".join(out)
def shift_dimensions(dims, idx, n, delete, is_row):
"""Rebuild a row/column dimensions map with shifted keys."""
items = list(dims.items())
saved = {}
for key, dim in items:
pos = key if is_row else column_index_from_string(key)
new = shift_point(pos, idx, n, delete)
if new is not None and new != pos:
saved[new if is_row else get_column_letter(new)] = dim
del dims[key]
for key, dim in saved.items():
if is_row:
dim.index = key
else:
dim.index = column_index_from_string(key)
dims[key] = dim
return len(saved)
def main(argv=None):
ap = argparse.ArgumentParser(
description="Insert/delete rows or columns AND rewrite formula "
"references, merges, filters, validations, tables, and "
"defined names to match.",
epilog="Cannot shift: chart anchors, images, conditional-format "
"rule formulas. See references/restructuring.md.")
ap.add_argument("file", help="path to .xlsx file")
ap.add_argument("--sheet", help="target sheet (default: active)")
ap.add_argument("--out", help="output path (default: edit in place)")
op = ap.add_mutually_exclusive_group(required=True)
op.add_argument("--insert-rows", metavar="IDX[:N]")
op.add_argument("--delete-rows", metavar="IDX[:N]")
op.add_argument("--insert-cols", metavar="COL[:N]",
help="COL is a letter (B) or 1-based number")
op.add_argument("--delete-cols", metavar="COL[:N]")
args = ap.parse_args(argv)
raw = (args.insert_rows or args.delete_rows
or args.insert_cols or args.delete_cols)
idx_s, _, n_s = raw.partition(":")
n = int(n_s) if n_s else 1
axis = "rows" if (args.insert_rows or args.delete_rows) else "cols"
delete = bool(args.delete_rows or args.delete_cols)
if axis == "cols" and idx_s.isalpha():
idx = column_index_from_string(idx_s.upper())
else:
idx = int(idx_s)
wb = load_workbook(args.file)
ws = wb[args.sheet] if args.sheet else wb.active
rewriter = RefRewriter(ws.title, axis, idx, n, delete)
report = {"ok": True, "sheet": ws.title, "axis": axis,
"op": "delete" if delete else "insert", "index": idx, "count": n,
"formulas": [], "merges": [], "tables": {}, "defined_names": {},
"validations": [], "conditional_formats": [],
"not_shifted": ["chart anchors", "images",
"conditional-format rule formulas"]}
# 1. capture merge ranges (openpyxl does not move them), then unmerge
old_merges = [str(r) for r in list(ws.merged_cells.ranges)]
for rng in old_merges:
ws.unmerge_cells(rng)
# 2. structural move of cell values/styles/comments
getattr(ws, f"{report['op']}_{axis}")(idx, n)
# 3. formulas everywhere
for sheet in wb.worksheets:
for row in sheet.iter_rows():
for cell in row:
if isinstance(cell.value, str) and cell.value.startswith("="):
new = rewriter.rewrite(cell.value, sheet.title)
if new != cell.value:
report["formulas"].append(
{"sheet": sheet.title, "cell": cell.coordinate,
"from": cell.value, "to": new})
cell.value = new
# 4. merges back, shifted
for rng in old_merges:
new = shift_range(rng, axis, idx, n, delete)
if new is None:
report["merges"].append({"from": rng, "to": None})
else:
ws.merge_cells(new)
if new != rng:
report["merges"].append({"from": rng, "to": new})
# 5. autofilter + freeze panes
if ws.auto_filter.ref:
new = shift_range(ws.auto_filter.ref, axis, idx, n, delete)
if new != ws.auto_filter.ref:
report["autofilter"] = {"from": ws.auto_filter.ref, "to": new}
ws.auto_filter.ref = new
if ws.freeze_panes:
m = COORD_RE.match(ws.freeze_panes)
ci = column_index_from_string(m.group(2).upper())
ri = int(m.group(4))
if axis == "rows":
ri = shift_point(ri, idx, n, delete) or max(idx, 2)
else:
ci = shift_point(ci, idx, n, delete) or max(idx, 2)
new = f"{get_column_letter(ci)}{ri}"
if new != ws.freeze_panes:
report["freeze_panes"] = {"from": ws.freeze_panes, "to": new}
ws.freeze_panes = new
# 6. data validations + conditional formatting applied ranges
for dv in ws.data_validations.dataValidation:
old = str(dv.sqref)
parts = [shift_range(p, axis, idx, n, delete) for p in old.split()]
parts = [p for p in parts if p]
if parts and " ".join(parts) != old:
dv.sqref = " ".join(parts)
report["validations"].append({"from": old, "to": str(dv.sqref)})
new_cf = ConditionalFormattingList()
for cf in ws.conditional_formatting:
old = str(cf.sqref)
parts = [shift_range(p, axis, idx, n, delete) for p in old.split()]
parts = [p for p in parts if p]
if not parts:
report["conditional_formats"].append({"from": old, "to": None})
continue
new = " ".join(parts)
for rule in cf.rules:
new_cf.add(new, rule)
if new != old:
report["conditional_formats"].append({"from": old, "to": new})
ws.conditional_formatting = new_cf
# 7. native tables on the edited sheet
for table in ws.tables.values():
new = shift_range(table.ref, axis, idx, n, delete)
if new and new != table.ref:
report["tables"][table.displayName] = {"from": table.ref,
"to": new}
table.ref = new
# 8. workbook-scope defined names
for name, dn in wb.defined_names.items():
if dn.attr_text and "!" in dn.attr_text:
new = rewriter.rewrite(dn.attr_text, ws.title)
if new != dn.attr_text:
report["defined_names"][name] = {"from": dn.attr_text,
"to": new}
dn.attr_text = new
# 9. row heights / column widths
if axis == "rows":
shift_dimensions(ws.row_dimensions, idx, n, delete, is_row=True)
else:
shift_dimensions(ws.column_dimensions, idx, n, delete, is_row=False)
out = args.out or args.file
wb.save(out)
report["output"] = out
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as exc: # noqa: BLE001
print(json.dumps({"ok": False, "error": str(exc)}), file=sys.stderr)
sys.exit(1)
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Export one sheet of an .xlsx workbook to CSV.
Dates/datetimes are written in ISO format; None becomes an empty field.
With --data-only, formula cells yield their cached results (present only
if the file was last saved by Excel/LibreOffice; openpyxl never computes).
Usage:
xlsx_to_csv.py book.xlsx out.csv
xlsx_to_csv.py book.xlsx out.csv --sheet Data --encoding utf-8-sig
xlsx_to_csv.py book.xlsx out.csv --delimiter ';' --data-only
"""
from __future__ import annotations
import argparse
import csv
import json
import sys
from datetime import date, datetime, time
from openpyxl import load_workbook
def to_text(value):
if value is None:
return ""
if isinstance(value, datetime):
# Excel stores pure dates as midnight datetimes; emit a bare date.
if value.time() == time(0, 0):
return value.date().isoformat()
return value.isoformat()
if isinstance(value, (date, time)):
return value.isoformat()
return value
def main(argv=None):
ap = argparse.ArgumentParser(description=".xlsx sheet -> CSV exporter.")
ap.add_argument("file", help="input .xlsx path")
ap.add_argument("output", help="output CSV path")
ap.add_argument("--sheet", help="sheet name (default: active)")
ap.add_argument("--encoding", default="utf-8",
help="CSV output encoding (default utf-8)")
ap.add_argument("--delimiter", default=",")
ap.add_argument("--data-only", action="store_true",
help="cached formula results instead of formula strings")
args = ap.parse_args(argv)
wb = load_workbook(args.file, data_only=args.data_only)
ws = wb[args.sheet] if args.sheet else wb.active
with open(args.output, "w", newline="", encoding=args.encoding) as fh:
writer = csv.writer(fh, delimiter=args.delimiter)
count = 0
for row in ws.iter_rows(values_only=True):
writer.writerow([to_text(v) for v in row])
count += 1
print(json.dumps({"ok": True, "output": args.output, "sheet": ws.title,
"rows": count}, ensure_ascii=False))
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as exc: # noqa: BLE001
print(json.dumps({"ok": False, "error": str(exc)}), file=sys.stderr)
sys.exit(1)
@@ -0,0 +1,542 @@
"""End-to-end tests for the xlsx skill helper scripts.
Runs each script as a subprocess under LC_ALL=C to prove all text I/O
uses explicit UTF-8 rather than locale defaults. No network access.
"""
from __future__ import annotations
import csv
import json
import os
import shutil
import subprocess
import sys
from datetime import date
from pathlib import Path
import pytest
from openpyxl import load_workbook
SCRIPTS = Path(__file__).resolve().parent.parent / "scripts"
def run(script, *args, expect_ok=True):
env = dict(os.environ, LC_ALL="C", LANG="C")
env.pop("PYTHONIOENCODING", None)
proc = subprocess.run(
[sys.executable, str(SCRIPTS / script), *map(str, args)],
capture_output=True, text=True, env=env, encoding="utf-8")
if expect_ok:
assert proc.returncode == 0, f"{script} failed: {proc.stderr}"
return proc
SPEC = {
"full_calc_on_load": True,
"sheets": [
{
"name": "Data",
"rows": [
[
{"value": "Region", "bold": True, "fill": "DDEBF7",
"border": "thin", "align": "center", "valign": "center"},
{"value": "Sales", "bold": True, "fill": "DDEBF7"},
{"value": "Growth", "bold": True},
{"value": "Audited", "bold": True},
{"value": "Closed", "bold": True},
{"value": "Status", "bold": True},
],
["North", 1500.5, {"value": 0.125, "format": "0.0%"}, True,
{"value": "2026-01-31", "type": "date",
"format": "yyyy-mm-dd"}, "Yes"],
["South", 900, {"value": -0.03, "format": "0.0%"}, False,
{"value": "2026-02-28", "type": "date",
"format": "yyyy-mm-dd"}, "No"],
["East", 2100, {"value": 0.4, "format": "0.0%"}, True,
{"value": "2026-03-31", "type": "date",
"format": "yyyy-mm-dd"}, "Yes"],
],
"cells": {
"A6": {"value": "Total", "bold": True, "italic": True,
"font_size": 12, "font_color": "1F4E78"},
"B6": {"formula": "SUM(B2:B4)", "format": "$#,##0.00"},
},
"column_widths": {"A": 18, "B": 14},
"row_heights": {"1": 24},
"merges": ["A8:C8"],
"freeze_panes": "A2",
"autofilter": "A1:F4",
"conditional_formats": [
{"range": "B2:B4", "type": "cell_is",
"operator": "greaterThan", "formula": ["1000"],
"fill": "C6EFCE"},
{"range": "C2:C4", "type": "color_scale"},
],
"charts": [
{"type": "bar", "title": "Sales by region", "anchor": "H2",
"data": "B1:B4", "categories": "A2:A4"},
{"type": "line", "title": "Growth", "anchor": "H18",
"data": "C1:C4", "categories": "A2:A4"},
{"type": "pie", "title": "Share", "anchor": "P2",
"data": "B2:B4", "categories": "A2:A4",
"titles_from_data": False},
],
"validations": [
{"range": "F2:F10", "type": "list",
"formula1": '"Yes,No,Maybe"'},
],
},
{"name": "Notes", "rows": [["Zürich", "Фамилия", "12,5%"]]},
],
}
@pytest.fixture
def workbook(tmp_path):
spec_path = tmp_path / "spec.json"
spec_path.write_text(json.dumps(SPEC), encoding="utf-8")
out = tmp_path / "report.xlsx"
proc = run("xlsx_create.py", spec_path, out)
summary = json.loads(proc.stdout)
assert summary["ok"] and summary["sheets"] == ["Data", "Notes"]
return out
def test_create_features_roundtrip(workbook):
wb = load_workbook(workbook)
ws = wb["Data"]
# typed values
assert ws["B2"].value == 1500.5
assert ws["D2"].value is True
e2 = ws["E2"].value
assert (e2.date() if hasattr(e2, "date") else e2) == date(2026, 1, 31)
# formula + number formats
assert ws["B6"].value == "=SUM(B2:B4)"
assert ws["B6"].number_format == "$#,##0.00"
assert ws["C2"].number_format == "0.0%"
assert ws["E2"].number_format == "yyyy-mm-dd"
# styling
assert ws["A1"].font.bold is True
assert ws["A1"].fill.fgColor.rgb.endswith("DDEBF7")
assert ws["A1"].border.left.style == "thin"
assert ws["A1"].alignment.horizontal == "center"
assert ws["A6"].font.italic is True and ws["A6"].font.size == 12
# dimensions
assert ws.column_dimensions["A"].width == 18
assert ws.row_dimensions[1].height == 24
# merges / freeze / autofilter
assert "A8:C8" in [str(r) for r in ws.merged_cells.ranges]
assert ws.freeze_panes == "A2"
assert ws.auto_filter.ref == "A1:F4"
# conditional formatting, charts, validation
assert len(list(ws.conditional_formatting)) == 2
assert len(ws._charts) == 3
types = {type(c).__name__ for c in ws._charts}
assert types == {"BarChart", "LineChart", "PieChart"}
assert len(ws.data_validations.dataValidation) == 1
# recalc flag
assert wb.calculation.fullCalcOnLoad is True
def test_read_sheets_json_formulas(workbook, tmp_path):
inv = json.loads(run("xlsx_read.py", workbook, "--sheets").stdout)
names = [s["name"] for s in inv["sheets"]]
assert names == ["Data", "Notes"]
data_info = inv["sheets"][0]
assert data_info["charts"] == 3
assert "A8:C8" in data_info["merged"]
assert data_info["freeze_panes"] == "A2"
dump = json.loads(
run("xlsx_read.py", workbook, "--json", "--sheet", "Data").stdout)
assert dump["rows"][1][0] == "North"
assert dump["rows"][1][4] == "2026-01-31T00:00:00"
notes = json.loads(
run("xlsx_read.py", workbook, "--json", "--sheet", "Notes").stdout)
assert notes["rows"][0] == ["Zürich", "Фамилия", "12,5%"]
formulas = json.loads(run("xlsx_read.py", workbook, "--formulas").stdout)
entry = [f for f in formulas["formulas"] if f["cell"] == "B6"][0]
assert entry["formula"] == "=SUM(B2:B4)"
# openpyxl never computes: cached value absent on a fresh file
assert entry["cached"] is None
csv_out = tmp_path / "data.csv"
run("xlsx_read.py", workbook, "--csv", "--sheet", "Notes",
"--out", csv_out)
text = csv_out.read_text(encoding="utf-8")
assert "Zürich" in text and "Фамилия" in text
def test_csv_roundtrip_nonascii(tmp_path):
src = tmp_path / "src.csv"
with open(src, "w", newline="", encoding="utf-8") as fh:
w = csv.writer(fh)
w.writerow(["City", "Share", "Surname", "Active", "When"])
w.writerow(["Zürich", "12,5%", "Фамилия", "true", "2026-05-01"])
w.writerow(["Oslo", "7", "Ås", "false", "2026-06-01"])
xlsx = tmp_path / "conv.xlsx"
run("csv_to_xlsx.py", src, xlsx, "--sheet-name", "Import")
wb = load_workbook(xlsx)
ws = wb["Import"]
assert ws["A2"].value == "Zürich"
assert ws["B2"].value == "12,5%" # decimal comma stays a string
assert ws["C2"].value == "Фамилия"
assert ws["D2"].value is True # bool inferred
assert ws["E2"].value.date() == date(2026, 5, 1) # date inferred
assert ws["B3"].value == 7 # int inferred
assert ws["A1"].font.bold is True # styled header
assert ws.freeze_panes == "A2"
back = tmp_path / "back.csv"
run("xlsx_to_csv.py", xlsx, back, "--sheet", "Import")
with open(back, newline="", encoding="utf-8") as fh:
rows = list(csv.reader(fh))
assert rows[1][0] == "Zürich"
assert rows[1][2] == "Фамилия"
assert rows[1][3] == "True"
assert rows[1][4] == "2026-05-01"
# encoding override
latin = tmp_path / "latin.csv"
run("xlsx_to_csv.py", xlsx, latin, "--sheet", "Import",
"--encoding", "utf-8-sig")
assert latin.read_bytes().startswith(b"\xef\xbb\xbf")
def test_edit_existing(workbook, tmp_path):
edited = tmp_path / "edited.xlsx"
proc = run("xlsx_edit.py", workbook, "--sheet", "Notes",
"--out", edited,
"--copy-sheet", "Notes:Backup",
"--rename-sheet", "Data:Main",
"--set", "B1=Änderung",
"--set", "C1=99.5",
"--set", "D1=2026-12-24",
"--set", "E1==SUM(C1:C1)",
"--append", '["appended", 1, false]',
"--insert-rows", "1:1",
"--recalc")
result = json.loads(proc.stdout)
assert result["ok"]
wb = load_workbook(edited)
assert set(wb.sheetnames) == {"Main", "Notes", "Backup"}
ws = wb["Notes"]
# insert-rows ran before --set per documented order, so row 1 is blank
# and original data moved to row 2... check documented ordering:
# structural ops run before --set, so B1 etc. were written after insert.
assert ws["B1"].value == "Änderung"
assert ws["C1"].value == 99.5
assert ws["D1"].value.date() == date(2026, 12, 24)
assert ws["E1"].value == "=SUM(C1:C1)"
assert wb.calculation.fullCalcOnLoad is True
# appended row present
found = [r for r in ws.iter_rows(values_only=True)
if r and r[0] == "appended"]
assert found and found[0][1] == 1 and found[0][2] is False
# copy preserved data
assert wb["Backup"]["A1"].value == "Zürich"
def test_help_and_errors():
for script in ["xlsx_create.py", "xlsx_read.py", "xlsx_edit.py",
"csv_to_xlsx.py", "xlsx_to_csv.py",
"xlsx_restructure.py", "xlsx_recalc.py"]:
proc = run(script, "--help")
assert "usage" in proc.stdout.lower()
bad = run("xlsx_read.py", "/nonexistent.xlsx", "--sheets",
expect_ok=False)
assert bad.returncode != 0
assert json.loads(bad.stderr)["ok"] is False
# ---------------------------------------------------------------------------
# Reference-aware restructuring (xlsx_restructure.py)
# ---------------------------------------------------------------------------
RESTRUCTURE_SPEC = {
"defined_names": {"SalesRange": "'Data'!$B$2:$B$4"},
"sheets": [
{
"name": "Data",
"rows": [
["Region", "Sales", "Weight"],
["North", 100, 0.5],
["South", 200, 0.3],
["East", 300, 0.2],
[None, None, None],
["Total", None, None],
],
"cells": {
"B6": {"formula": "SUM(B2:B4)"},
"C6": {"formula": "$B$2*C2"},
"D6": {"formula": "LOG10(B4)"},
"E6": {"formula": "SUM(B:B)"},
"F6": {"formula": '"row B2: "&B2'},
},
"merges": ["E2:E4", "A7:B7"],
"freeze_panes": "A2",
"autofilter": "A1:C4",
"conditional_formats": [
{"range": "B2:B4", "type": "cell_is",
"operator": "greaterThan", "formula": ["150"],
"fill": "C6EFCE"},
],
"validations": [
{"range": "C2:C4", "type": "list",
"formula1": '"0.2,0.3,0.5"'},
],
"tables": [
{"name": "SalesTbl", "range": "A1:C4"},
],
},
{
"name": "Summary",
"rows": [["Grand total"]],
"cells": {
"B1": {"formula": "SUM(Data!B2:B4)"},
"B2": {"formula": "'Data'!$B$3"},
"B3": {"formula": "SUM(A1:A1)"},
},
},
],
}
@pytest.fixture
def restructure_book(tmp_path):
spec_path = tmp_path / "rspec.json"
spec_path.write_text(json.dumps(RESTRUCTURE_SPEC), encoding="utf-8")
out = tmp_path / "restructure.xlsx"
run("xlsx_create.py", spec_path, out)
return out
def test_restructure_insert_rows_shifts_everything(restructure_book):
# merge A6:C6 gets pushed down; A1:A1 merge is before the insert point
proc = run("xlsx_restructure.py", restructure_book,
"--sheet", "Data", "--insert-rows", "3:2")
report = json.loads(proc.stdout)
assert report["ok"] and report["op"] == "insert"
wb = load_workbook(restructure_book)
data, summary = wb["Data"], wb["Summary"]
# values physically moved
assert data["A2"].value == "North"
assert data["A5"].value == "South" # was row 3
assert data["A8"].value == "Total" # was row 6
# same-sheet formulas rewritten (range expanded across insert point)
assert data["B8"].value == "=SUM(B2:B6)"
# absolute ref before insert point unchanged; relative arm shifted
assert data["C8"].value == "=$B$2*C2"
# function names, whole-column refs, string literals untouched
assert data["D8"].value == "=LOG10(B6)"
assert data["E8"].value == "=SUM(B:B)"
assert data["F8"].value == '="row B2: "&B2'
# cross-sheet formulas on the OTHER sheet rewritten
assert summary["B1"].value == "=SUM(Data!B2:B6)"
assert summary["B2"].value == "='Data'!$B$5"
# Summary-local refs not confused with Data refs
assert summary["B3"].value == "=SUM(A1:A1)"
# merges: E2:E4 spans the insert point -> expanded; A7:B7 -> shifted
merged = [str(r) for r in data.merged_cells.ranges]
assert "E2:E6" in merged and "A9:B9" in merged
# autofilter expanded, freeze panes intact
assert data.auto_filter.ref == "A1:C6"
assert data.freeze_panes == "A2"
# validation + conditional format ranges shifted
dv = data.data_validations.dataValidation[0]
assert str(dv.sqref) == "C2:C6"
cf = list(data.conditional_formatting)[0]
assert str(cf.sqref) == "B2:B6"
# native table expanded
assert data.tables["SalesTbl"].ref == "A1:C6"
# defined name rewritten
assert wb.defined_names["SalesRange"].attr_text == "'Data'!$B$2:$B$6"
# report is honest about limits
assert "chart anchors" in report["not_shifted"]
assert any(f["cell"] == "B1" and f["sheet"] == "Summary"
for f in report["formulas"])
def test_restructure_delete_rows_and_ref_errors(restructure_book):
run("xlsx_restructure.py", restructure_book,
"--sheet", "Data", "--delete-rows", "3")
wb = load_workbook(restructure_book)
data, summary = wb["Data"], wb["Summary"]
assert data["A3"].value == "East" # South deleted
assert data["B5"].value == "=SUM(B2:B3)" # range clamped
# single-cell ref into the deleted row becomes #REF!
assert summary["B2"].value == "='Data'!#REF!"
assert summary["B1"].value == "=SUM(Data!B2:B3)"
assert data.tables["SalesTbl"].ref == "A1:C3"
def test_restructure_insert_cols(restructure_book):
proc = run("xlsx_restructure.py", restructure_book,
"--sheet", "Data", "--insert-cols", "B:1")
report = json.loads(proc.stdout)
assert report["axis"] == "cols" and report["index"] == 2
wb = load_workbook(restructure_book)
data, summary = wb["Data"], wb["Summary"]
assert data["C2"].value == 100 # Sales moved B->C
assert data["C6"].value == "=SUM(C2:C4)"
assert data["D6"].value == "=$C$2*D2"
assert summary["B1"].value == "=SUM(Data!C2:C4)"
assert wb.defined_names["SalesRange"].attr_text == "'Data'!$C$2:$C$4"
merged = [str(r) for r in data.merged_cells.ranges]
assert "F2:F4" in merged # merge shifted right
assert "A7:C7" in merged # merge expanded across col B
# ---------------------------------------------------------------------------
# Tables, defined names, hyperlinks, notes, protection (edit + read paths)
# ---------------------------------------------------------------------------
def test_tables_create_append_list(tmp_path):
spec = {"sheets": [{"name": "T",
"rows": [["Item", "Qty"], ["a", 1], ["b", 2]],
"tables": [{"name": "Stock", "range": "A1:B3",
"style": "TableStyleLight1"}]}]}
spec_path = tmp_path / "tspec.json"
spec_path.write_text(json.dumps(spec), encoding="utf-8")
book = tmp_path / "tables.xlsx"
run("xlsx_create.py", spec_path, book)
wb = load_workbook(book)
tbl = wb["T"].tables["Stock"]
assert tbl.ref == "A1:B3"
assert tbl.tableStyleInfo.name == "TableStyleLight1"
# --add-table + --table-append auto-extends the range
run("xlsx_edit.py", book, "--sheet", "T",
"--add-table", "Extra:D1:E2",
"--table-append", 'Stock=["c", 3]')
wb = load_workbook(book)
ws = wb["T"]
assert ws.tables["Stock"].ref == "A1:B4"
assert ws["A4"].value == "c" and ws["B4"].value == 3
assert ws.tables["Extra"].ref == "D1:E2"
listing = json.loads(
run("xlsx_edit.py", book, "--sheet", "T", "--list-tables").stdout)
assert listing["tables"]["Stock"]["ref"] == "A1:B4"
assert set(listing["tables"]) == {"Stock", "Extra"}
# tables also appear in the read inventory
inv = json.loads(run("xlsx_read.py", book, "--sheets").stdout)
assert inv["sheets"][0]["tables"]["Stock"] == "A1:B4"
def test_names_hyperlinks_notes(tmp_path):
spec = {
"defined_names": {"Rate": "'D'!$B$1"},
"sheets": [{"name": "D", "cells": {
"A1": {"value": "docs",
"hyperlink": "https://example.com/docs"},
"B1": {"value": 0.07, "note": "quarterly rate"},
"C1": {"value": 1, "note": {"text": "check", "author": "QA"}},
}}],
}
spec_path = tmp_path / "nspec.json"
spec_path.write_text(json.dumps(spec), encoding="utf-8")
book = tmp_path / "names.xlsx"
run("xlsx_create.py", spec_path, book)
wb = load_workbook(book)
ws = wb["D"]
assert ws["A1"].hyperlink.target == "https://example.com/docs"
assert ws["B1"].comment.text == "quarterly rate"
assert ws["C1"].comment.author == "QA"
assert wb.defined_names["Rate"].attr_text == "'D'!$B$1"
# edit path: add/delete names, hyperlink, note, clear note
run("xlsx_edit.py", book, "--sheet", "D",
"--define-name", "Extra='D'!$C$1",
"--delete-name", "Rate",
"--hyperlink", "D1=https://example.com/more|More",
"--note", "D1=see more|Reviewer",
"--clear-note", "B1")
wb = load_workbook(book)
ws = wb["D"]
assert "Rate" not in wb.defined_names
assert wb.defined_names["Extra"].attr_text == "'D'!$C$1"
assert ws["D1"].hyperlink.target == "https://example.com/more"
assert ws["D1"].value == "More"
assert ws["D1"].comment.author == "Reviewer"
assert ws["B1"].comment is None
# read path: --notes and --names JSON output
notes = json.loads(run("xlsx_read.py", book, "--notes").stdout)["notes"]
coords = {(n["cell"], n["author"]) for n in notes}
assert ("D1", "Reviewer") in coords and ("C1", "QA") in coords
names = json.loads(run("xlsx_read.py", book, "--names").stdout)
assert names["defined_names"] == {"Extra": "'D'!$C$1"}
def test_sheet_protection(tmp_path):
spec = {"sheets": [{"name": "P", "rows": [["locked", "open"]],
"protection": {"password": "your-password",
"unlock": ["B1:B1"]}}]}
spec_path = tmp_path / "pspec.json"
spec_path.write_text(json.dumps(spec), encoding="utf-8")
book = tmp_path / "prot.xlsx"
run("xlsx_create.py", spec_path, book)
wb = load_workbook(book)
ws = wb["P"]
assert ws.protection.sheet is True
assert ws.protection.password # hash stored
assert ws["B1"].protection.locked is False
assert ws["A1"].protection.locked is not False
inv = json.loads(run("xlsx_read.py", book, "--sheets").stdout)
assert inv["sheets"][0]["protected"] is True
# edit path on a fresh unprotected sheet
plain = tmp_path / "plain.xlsx"
spec_path.write_text(json.dumps(
{"sheets": [{"name": "P", "rows": [["a", "b"]]}]}), encoding="utf-8")
run("xlsx_create.py", spec_path, plain)
run("xlsx_edit.py", plain, "--sheet", "P",
"--protect", "your-password", "--unlock", "B1:B1")
ws = load_workbook(plain)["P"]
assert ws.protection.sheet is True and ws["B1"].protection.locked is False
# ---------------------------------------------------------------------------
# Headless recalculation (xlsx_recalc.py) — branches on soffice presence
# ---------------------------------------------------------------------------
def test_recalc_reports_json_both_ways(tmp_path):
spec = {"sheets": [{"name": "R", "rows": [[2], [3]],
"cells": {"A3": {"formula": "SUM(A1:A2)"}}}]}
spec_path = tmp_path / "cspec.json"
spec_path.write_text(json.dumps(spec), encoding="utf-8")
book = tmp_path / "calc.xlsx"
run("xlsx_create.py", spec_path, book)
# absent-soffice branch is always testable by hiding PATH
env = dict(os.environ, LC_ALL="C", LANG="C", PATH=str(tmp_path))
proc = subprocess.run(
[sys.executable, str(SCRIPTS / "xlsx_recalc.py"), str(book)],
capture_output=True, text=True, env=env, encoding="utf-8")
assert proc.returncode == 0
absent = json.loads(proc.stdout)
assert absent["recalculated"] is False and "soffice" in absent["reason"]
assert "guidance" in absent
if not shutil.which("soffice"):
pytest.skip("LibreOffice not installed; absent branch covered above")
out = tmp_path / "calced.xlsx"
proc = run("xlsx_recalc.py", book, "--out", out, "--timeout", "300")
result = json.loads(proc.stdout)
assert result["recalculated"] is True
assert result["formula_cells"] == 1
assert result["with_cached_values"] == 1
# cached value now visible to --formulas
formulas = json.loads(run("xlsx_read.py", out, "--formulas").stdout)
entry = formulas["formulas"][0]
assert entry["formula"] == "=SUM(A1:A2)" and entry["cached"] == 5