Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user