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