Spredin

Scripting API reference

This is the same document you'll find inside Spredin under Help.

The single, stable contract for driving Spredin by code instead of by clicking the UI. An LLM (local or cloud) reads this as context and writes Python against the global sheet; the spreadsheet is both the data source and the canvas.

Ask in plain English, the Python panel's AI, works from this same reference, and every method listed here exists in the app.

Model#

Think in normal Python + pandas. numpy / pandas / scipy are installed but not imported for you — write import pandas as pd yourself (using pd without it is a NameError). Every run starts fresh: nothing an earlier run defined or imported is there, so a script imports and defines all it uses. sheet is the active sheet — already defined; never reassign it. Write top-level code (no def main(), no if __name__).

Row numbering — three systems, don't mix them#

This is the single most common way a script goes wrong (an IndexError, or a formula written into the header row):

counts from includes the header?
sheet.set(r, c) / value / get 0 — row 0 is the top row yes: row 0 usually is the header, so data starts at row 1
sheet.rows — yes: data rows are 1 .. sheet.rows-1
sheet.read() → DataFrame 0 no — the header became the column names, so len(df) == sheet.rows - 1 and df.iloc[i] is sheet row i+1
A1 labels inside "=…" formulas 1 n/a — sheet row r is spreadsheet row r+1
for r in range(1, sheet.rows):            # data rows, 0-indexed
    sheet.set(r, 3, '=B%d+C%d' % (r+1, r+1))   # A1 rows are r+1
    same_row_in_df = df.iloc[r - 1]        # df has no header row

Never index a DataFrame with a sheet row number — that is off by one and runs off the end.

What you may pass#

Writing a DataFrame#

sheet.write(df, 'A1') writes the column names in row 1 and the values from row 2. That first row is the thing to remember:

sheet.write(df[['Country']], 'D1')                 # names in D1, values from D2
sheet.write(df[['Country']], 'D2', header=False)   # values only, under a header that is already there

sheet.write(df[['Country']], 'D2') would write Country a second time in D2 and move every value one row down. When column names land under a row that already holds them, the run prints a note saying so.

Written by default To decide yourself
Column names Yes. Not when they are only 0, 1, 2… because the frame was built from rows (pd.DataFrame(rows)): those rows already carry their header. header=True / header=False
The index When it says something: it has a name, or its labels are not plain numbers. That is what groupby, pivot_table, describe, value_counts and set_index give. The 0, 1, 2… numbering a read gives, and what is left of it after a filter or a sort, is not written. index=True / index=False
A Series A column of values, with no header. When its index is written, a header row goes with it (Region, Q1). header=True writes its name on top

Two levels of column names (agg(['sum', 'mean'])) are written as one row: Q1 sum, Q1 mean. An index's header is its name, or index when it has none.

Writing a table back over itself. write() writes its cells and nothing else. A cleaned table that is shorter (or narrower) than the one it replaces leaves the old last rows standing below it, and the run prints a note naming them. replace=True clears the table the target cell is in, from that cell down and to the right, before writing:

df = sheet.read().drop_duplicates()
sheet.write(df, 'A1', replace=True)    # no old rows left below

Filtering#

sheet.filter('A2:F100', 'Region', '=', 'East')     # by heading
sheet.filter('A2:F100', 'F', '>', 1000)            # by letter: narrows the first
sheet.filter('A2:F100', 3, 'between', 10, 20)      # by offset in the range
sheet.clear_filter()

It is the sheet's own AutoFilter: the header row shows the filter, the user can change or clear it, charts and SUBTOTAL follow it, and it is saved with the workbook. A second call on another column narrows the first; the same column again replaces that column's condition. Like Data → Filter (AutoFilter), it runs from the row under the header down to the first blank row. Pass the data rows, the range below the header (filter('A2:J5001', …)): the row above it is the header, and is never hidden. A range that starts at row 1 has no row above, so its first row is taken as the header, and the run says so.

column its offset in the range (0 is the first column), its letter, or its heading
op '=' '!=' '>' '>=' '<' '<=' 'between' (two values) 'contains' 'notContains' 'startsWith' 'endsWith' 'empty' 'notEmpty' 'top' / 'bottom' (the value is how many) 'aboveAvg' 'belowAvg'
value a number, text, or a date (datetime.date(2024, 4, 1))

The same spellings are read in a pivot's filters= (every operator above), in validate(op=…) (the comparisons, 'between' and 'notBetween') and as a cond_format kind ('>' '<' '=' 'between' 'contains' 'top' 'bottom'), and a comparison may also be spelt 'gt' or 'greaterThan'. A word a call does not take, or a column that is not in the range, raises an error that lists what is allowed.

What a read gives you#

The cell value(), read_values(), read()
a number, or a formula that gives one int / float
text str
TRUE / FALSE bool
a date or a time its serial number (days since 1899-12-30): pd.to_datetime(col, unit='D', origin='1899-12-30')
empty, or a formula that gives "" None
an error its text: '#DIV/0!', '#N/A', '#VALUE!'…

A formula that reads an empty cell gives 0, as in Excel (=A1 over an empty A1 reads 0), and arithmetic on a formula's "" is '#VALUE!'.

An error is never None, so a broken formula cannot pass for a blank. One error makes a DataFrame column non-numeric, and read() prints a note naming the first one; pd.to_numeric(df[col], errors='coerce') turns errors into NaN when you mean to leave them out.

Sheets and names made in the same run#

Writing a script you can run twice#

A script that makes its own sheet is run again safely with one argument. add_sheet('Report') on a second run returns a differently named sheet; replace=True hands back the same sheet, empty, as a new one would be:

out = sheet.add_sheet('Report', replace=True)   # the same sheet, emptied
out.write(rows, 'A1')
out.chart('A1:B10', 'bar', 'Revenue')
out.freeze(1)

A script that works on a sheet it does not replace (the active sheet, say) adds to what is there, so take off what the last run added first:

sheet.clear_charts()            # charts are added, never replaced
sheet.clear_cond_formats()      # a different rule on the same cells would stack
sheet.cond_format('E2:E%d' % sheet.rows, 'dataBar')
sheet.validate('C2:C%d' % sheet.rows, 'whole', op='>=', f1=0)   # replaces the rule on those cells

The same conditional format on the same cells is added once, however often the script runs.

Methods#

Read / write (pandas-first)#

Call Returns / effect
sheet.read([range]) Used range (or "A1:C20", "Sales!A1:C20", whole columns "A:C", or a sheet name) as a DataFrame (row 1 → column names: a number heading stays a number, so df[2023]; a date heading is the date it shows, "2024-01-31"; an empty one is ""). Empty cells are None (so isna() finds them); an error cell is its text ('#DIV/0!'); dates are serial day numbers.
sheet.read_values([range]) A range as a plain 2D list (no pandas), with the same values.
sheet.write_col(col, values[, start_row=0]) Write a list down a column, starting at start_row (0 is the sheet's first row — the header row, if there is one). col takes an index, a letter ('E') or a start cell ('E2', which starts at row 2).
sheet.write_row(row, values[, start_col=0]) Write a list across a row.
sheet.write(data[, at='A1'], header=None, index=None, replace=False) Write a whole DataFrame / Series / 2D list / numpy array at a top-left cell in one call (never loop row-by-row). at may name another sheet ('Report!A1'); a sheet that does not exist is an error, never the active sheet. A DataFrame's column names are its first row: see Writing a DataFrame above. replace=True clears the old table at the target first, so a shorter table leaves no old rows below it.

Cells#

Call Returns / effect
sheet.value(row, col) / sheet.value('A1') Computed value of one cell: None if empty, the error's text ('#DIV/0!') if the formula failed.
sheet.is_date(row, col) / sheet.is_date('A1') True when the cell shows a date or time. value() gives a date as a serial day number, so test this before adding a column up.
sheet.get(row, col) / sheet.cell('A1') RAW text of one cell — a formula comes back as its "=…" source, not its result.
sheet.set(row, col, v, as_text=False) / sheet.set_cell('A1', v) Write one cell; a "=..." string is a live formula. as_text=True writes the value as text whatever it looks like (see What you may pass).
sheet.rows, sheet.cols Used size (ints).
sheet.col_letter(col) Column index → letter (0 → 'A', 26 → 'AA'). Use this rather than writing your own.
sheet.a1(row, col) 0-based (row, col) → an A1 label: sheet.a1(2, 1) → 'B3'.
sheet.used_range The used area as an A1 range ('A1:E5') — pass straight to pivot / cond_format / sort.

Spredin ops (call the app engine — no pure-Python equivalent)#

Call Effect
sheet.chart(range, type, title, **options) ECharts chart bound to a range; type: bar/line/area/pie/scatter, and every other type in Help → Charting Guide, which also lists the options. Returns the chart's id (as in sheet.charts()).
sheet.merge(range, combine=False) Merge cells (combine=True joins their text).
sheet.unmerge(range) Split any merged cells inside a region.
sheet.style(range, bold=, italic=, underline=, color=, fill=, align=, valign=, wrap=, border=, font=, size=, number_format=) Format a range; only what you give changes. bold / italic / underline / wrap / border (a thin line on all four sides): True or False. color (the text) / fill: hex, '#206dc5'. align: 'left', 'center', 'right'. valign: 'top', 'middle', 'bottom'. font: a font name. size: in px. number_format: see Number formats. A value it cannot use raises an error that says what it takes.
sheet.sort(range, by=[(column, direction), …]) Sort a range's data rows in place (formulas rebase), first key first: sheet.sort('A2:F9', by=[('Region', 'asc'), ('Total', 'desc')]). A column is its offset in the range, its letter or its heading in the row above the range; a direction is True / 'asc' or False / 'desc'. One key may be given bare: by=(5, False), by='Total'. A key that is no column of the range raises.
sheet.freeze(rows=0, cols=0) Freeze panes — freeze(1) pins the header row; freeze(0, 0) unfreezes.
sheet.cond_format(range, kind, value=/min=/max=/text=/n=, fill=, color=) Conditional formatting. kind: 'greaterThan' / 'lessThan' / 'equalTo' (value=), 'between' (min=, max=), 'textContains' (text=), 'top' / 'bottom' (n=), 'duplicates', 'colorScale' (lo=, hi=), 'dataBar' (bar=). fill= / color= colour the cells that match. An unknown kind, a missing value or a colour that is not hex raises.

Structure (whole rows / columns — 0-indexed; formulas rebase)#

Call Effect
sheet.insert_rows(at, count=1) Insert blank rows before row index at.
sheet.delete_rows(at, count=1) Delete rows from index at (refs into them → #REF!).
sheet.insert_cols(at, count=1) Insert blank columns before column index at.
sheet.delete_cols(at, count=1) Delete columns from index at.
sheet.set_col_width(col, px) / sheet.set_row_height(row, px) Resize a column / row (pixels). Sizes are clamped to a minimum — this cannot hide.
sheet.hide_cols(at, count=1) / sheet.hide_rows(at, count=1) Hide whole columns / rows (0-indexed). The only way to hide — set_col_width(c, 0) leaves it visible.
sheet.unhide_cols(at, count=1) / sheet.unhide_rows(at, count=1) Show hidden columns / rows in the span [at, at+count).

Data tools (Data and Insert menus)#

Call Effect
sheet.filter(range, column, op, value[, value2]) Filter a table, as Data → Filter (AutoFilter) does: hide the rows whose column fails op. See Filtering above. Returns the number of rows hidden.
sheet.clear_filter([range]) Take the sheet's filter off and show its rows (and any hidden rows in range, or on the whole sheet).
sheet.remove_duplicates(range) Drop duplicate rows (keeps the first). Rows are compared by value, text without regard to case; hidden rows are left alone. The range is anything read() takes ('A2:G100', 'Data!A2:G100', whole columns 'A:G', a sheet's name); one it cannot read raises. Returns the count removed.
sheet.pivot(range, rows=['Region'], values=[('Q1','sum')], cols=None, filters=None, show_as='value', name=None) Build a dynamic (re-editable) pivot into a new sheet, named Pivot of <source sheet> unless name= says otherwise. The range is anything read() takes ('Orders!A1:E500', a sheet's name, whole columns 'A:E', which are read down to the last used row). Fields are header names or 0-based offsets; a field must be a column of the range with a heading, and the range needs rows under its headings (as Excel's own rule; otherwise it raises). rows = a list of nested row fields (rows=['Region'], a list even for one); values = a list of fields or (field, agg) pairs (sum/count/average or mean/min/max; any other word raises — work a median out with pandas); cols = one column field; filters = (field, value) or (field, op, v1[, v2]); show_as 'value'/'pctOfTotal' (any other word raises). Returns the new sheet handle. The legacy pivot(range, row_field, value_field, agg, col_field) positional form still works.
pivot_sheet.update_pivot(rows=, values=, cols=, filters=, show_as=) Reconfigure a pivot sheet in place (same args as pivot) and rebuild from its live source — the Excel field-pane edit, from code.
pivot_sheet.refresh_pivot() Recompute a pivot sheet from its (possibly changed) source, keeping the same configuration.
sheet.validate(range, kind, op=, f1=, f2=, options=, error=) Data validation. kind 'list': options=['Low', 'High'], 'Low,High', a range ('H2:H9', 'Lists!A2:A9') or a defined name. 'whole' / 'decimal' / 'date' / 'textLength': op ('>=' '>' '<' '<=' '=' '!=' with f1=, or 'between' / 'notBetween' with f1= and f2=). 'custom': f1='=formula' (kept, not enforced). error= is your own message. A rule the grid could not run raises.
sheet.clear(range) Clear cell contents (styling kept).
sheet.fill_down(range) Fill Down, as in the Edit menu: the first row of the range is copied to the rows below it. A formula keeps its shape and its relative references move row by row ($ pins them); a value is repeated; the first row's formats come along. sheet.set_cell('E2', '=C2*D2'); sheet.fill_down('E2:E%d' % sheet.rows) writes the whole column.
sheet.clear_cond_formats([range]) Remove conditional formats: every rule on the sheet, or those lying inside a range. Returns how many it removed. A rule that only partly lies in the range is left, and a note says so.
sheet.clear_validation([range]) Remove validation rules, the same way. (validate() on a range already replaces the rules inside that range: a cell has one rule.)
sheet.define_name(name, target) Define a workbook name. target is a range ('B2:B13' means B2:B13 of the sheet the handle is for, and is stored with it: Data!$B$2:$B$13), a whole column ('A:A'), a value ('0.2', '"text"') or a formula ('=OFFSET(A1,0,0,COUNTA(A:A),1)'). A name that is neither raises, and so does a name spelled like a cell (Q1), a name with a space, or a sheet that does not exist.
sheet.defined_names() The workbook's defined names as a dict, name → what it stands for: {'Revenue': 'Data!$B$2:$B$13', 'Rate': '0.2'}.
sheet.delete_name(name) Delete a defined name, in any case ('revenue' deletes Revenue). Returns False if there is none by that name. A formula that used it shows #NAME? until the name is defined again. Undone with the script's run.

Import / export (in-memory — no file dialog)#

Call Returns / effect
sheet.to_csv([range], sep=',') A range as CSV text (computed values; dates as the dates they show, e.g. 2024-01-15). The range is anything read() takes: nothing (the used sheet), 'A1:C9', 'Sales!A1:C9', whole columns 'A:C', or a sheet's name. An empty sheet gives ''; a range it cannot read raises.
sheet.to_json([range]) A range as a JSON string (2D array of values; numbers as numbers, dates as the dates they show, errors as their text). The same ranges as to_csv; an empty sheet gives '[]'.
sheet.paste_csv(text, at='A1', sep=',', as_text=False) Parse CSV/TSV text and write it in at a cell. Fields are read the way File → Open reads a CSV, and the way typing them would: $1,200.50, 50%, (300), 2024-01-15 and 2024-01-15 09:30 become numbers, dates and times with their formats, TRUE a logical value, and 007 stays text. So sheet.paste_csv(other.to_csv()) gives dates back as dates. as_text=True writes every field as text, exactly as the file has it.

Saving a workbook is a menu action (File → Save, or the File menu's exports). Inside a script, read() gives you the data and to_csv / to_json hand it back out as text — an agent can persist or transmit that however it likes. The one file a script can write is a PDF (export_pdf, below), and only where the user chooses or allows. A script the AI writes that writes a file is never run unseen: Ask in plain English puts it in the editor for you to read and run.

Reading another workbook — await open_file(path)

Opens a .sprd, .xlsx, .xls, .csv or .tsv from disk as data, without a window and read-only. Note the await.

The path is a full path ('/Users/me/Reports/jan.xlsx'); a relative path, a ~ path or anything but text raises before anything is asked. The first time a script names a file you have not opened yourself in this session, Spredin asks you, showing the full path, before it reads it — Spredin opens only files you choose. Refusing raises an error in the script. A file you opened with File → Open or from the Finder is not asked about again.

wb = await open_file('/Users/me/Reports/jan.xlsx')
print(wb.sheets)                          # ['Summary', 'Raw']
total = wb.sheet('Summary').cell('B12')   # computed value, like sheet.value
rows = wb.sheet('Raw').read_values()      # every cell as a 2-D list
df = wb.sheet('Raw').read()               # a DataFrame, row 1 as the header
wb.close()
Call Returns
wb.sheets / wb.name Sheet names / the file name.
wb.sheet(name=None) A read-only sheet, by name in any case (the first one when no name is given). Its read() reads the whole sheet: no range and no header=.
.value(r, c) / .cell('A1') One computed value (0-based r, c, or an A1 label).
.rows / .cols / .read_values() / .read() Shape, every value, or a DataFrame.
wb.close() Release it (a with await open_file(…) as wb: block does this for you).

Formulas in the file compute against that file's own sheets and defined names. A CSV is converted the way File → Open converts it ($1,200 → 1200, dates → dates). A large .xlsx is read in full, so it can take a few seconds.

Printing & PDF (the page setup and pages File → Print… uses)#

Call Returns / effect
sheet.page_setup(**changes) Read (no arguments) or change how the sheet prints; returns the whole setup as a dict with the keys below. Saved with the workbook, and undone with the script's run.
sheet.page_count(area=None, workbook=False) How many pages a print would make — of the sheet, of just area='A1:F40', or with workbook=True of every visible sheet.
await sheet.export_pdf(path=None, area=None, pages=None, workbook=False) Save a PDF. path is a full path ('/Users/you/Documents/Q3.pdf'; .pdf is added if missing) and Spredin asks the user to allow writing there; with no path the save panel opens. Returns the path written, or None if the user declined.
await sheet.print_out(area=None, pages=None, workbook=False) Print: the Mac's print panel opens over the pages, and the user picks the printer and confirms. True when printed, False when cancelled.

page_setup settings — the same as the Print dialog's, in Excel's terms:

Setting Values
paper 'a4', 'a3', 'a5', 'letter', 'legal', 'tabloid'
orientation 'portrait', 'landscape'
margins 'normal', 'wide', 'narrow', or a dict of points (72 = 1 inch): {'left': 36, 'right': 36} — sides not given stay as they are; also top, bottom, header, footer
scale percent, 10–400 (prints at that size)
fit (pages wide, pages tall), 0 for no limit that way — fit=(1, 0) is "all columns on one page". Give scale or fit, not both.
print_area 'A1:F40', 'A:F'; None prints the used range
title_rows / title_cols rows / columns repeated on every page: '1:1', 'A:A'; None clears
gridlines, headings True / False — print the grid lines, the row numbers and column letters
center_h, center_v True / False — centre on the page
page_order 'down_then_over' (Excel's default) or 'over_then_down'
header, footer text (centred), or {'left': …, 'center': …, 'right': …}; codes &P page, &N pages, &A sheet, &F file, &D date, &T time
row_breaks rows (from 1) where a new page starts: [21, 41]
col_breaks columns where a new page starts: ['F', 'K']

Sheets#

Call Effect
sheet.add_sheet(name, replace=False) Create and return a new sheet handle. replace=True reuses an existing sheet of that name, emptied: its cells, formats, charts, merged cells, hidden rows, row and column sizes, frozen panes, validation and conditional-format rules, filter and comments all go (how it prints is kept). Excel's naming rule applies: at most 31 characters and none of : \ / ? * [ ], or the call raises an error that says so.
sheet.delete_sheet(name) Delete a sheet by name. Returns False if it does not exist, or is the last sheet.
sheet.charts() This sheet's charts as dicts {id, range, type, title}.
sheet.clear_charts() Remove every chart from this sheet; returns how many were removed.
sheet.update_chart(which, type=None, title=None, range=None, **opts) Change a chart already on this sheet, in place. which is its id (what chart() returned, or charts()[i]['id']) or its place in charts() (0 is the first). Give only what changes: type=, title=, range=, and any option chart() takes (x_title=, legend=False, colors=[…]…); an option given as None goes back to the chart's default. Checked as chart() checks it — an unknown chart, type or option raises and names what is allowed. Returns the chart's id; undone with the script's run.

More for working with sheets:

Call Effect
sheet.copy([name], replace=False) A full copy of this sheet as a new sheet; returns its handle. See Copying a sheet below.
sheet.copy_to(dest[, range], at='A1', values=False) Copy a range (default: all of the sheet's cells) onto another sheet, as copy and paste does. dest is a sheet handle or a sheet's name; at is the cell the range's top-left lands on.
sheet.book(name) Get an existing sheet by name, in any case (book("sales") finds Sales). A name that is no sheet raises and lists the sheets.
sheet.names() List all sheet names.
sheet.name This sheet's name.

Copying a sheet

backup = sheet.copy('Sales backup')          # everything, as a second sheet
sheet.copy('Sales backup', replace=True)     # the same on a re-run: one sheet, not two
report = sheet.add_sheet('Report', replace=True)
sheet.copy_to(report, 'A1:E20', at='B3')     # a range, as copy and paste
sheet.copy_to('Report', 'A1:E20', values=True)   # what the formulas show (Paste Values)

copy() copies what the sheet holds, not what it shows. Formulas stay formulas, a date stays a date, and number formats, bold and colours, merged cells, column widths and row heights, hidden rows, frozen panes, conditional formats, validation rules, comments, the filter, page setup and charts come with them. With no name the copy is called <sheet> (2). A formula that names its own sheet (=Sales!B2 on Sales) names the copy in the copy, as in Excel; a reference to any other sheet is left alone. The copy of a pivot sheet is the cells it showed, not a second pivot.

copy_to() copies cells with their formats and merged cells. A formula stays a formula and its relative references move by the distance pasted (=C2*D2 pasted two rows down is =C4*D4), so copy the cells a formula reads along with it, or pass values=True to copy the results. It does not copy column widths, frozen panes or rules: use copy() for the whole sheet.

dst.write(src.read()) copies values only, through a DataFrame: formulas become the numbers they showed and formats are left behind. Use it when that is what you want (data for another table), not to copy a sheet.

Cross-sheet (combine multiple sheets)#

Every handle from sheet.book(name) / sheet.add_sheet(name) supports the same methods (read/write/value/set/chart/style/…), each acting on its own sheet — so a script can read several sheets and write results to another:

import pandas as pd
a = sheet.book('Sales').read()
b = sheet.book('Targets').read()
merged = a.merge(b, on='Region')
out = sheet.add_sheet('Combined')
out.write(merged)

Formulas can reference other sheets directly too: sheet.set('B2', "=Sales!B2-Targets!B2").

Number formats (number_format=)#

The codes are Excel's, dates included. A date is stored as a serial number (days since 1899-12-30); its format only changes how the serial is shown.

Code Result
'general' plain number (the default)
'yyyy-mm-dd', 'mm/dd/yyyy', 'dd-mmm-yy', 'mmm yyyy' Excel date codes: 15 January 2024 shows as 2024-01-15, 01/15/2024, 15-Jan-24, Jan 2024
'hh:mm', 'yyyy-mm-dd hh:mm' a time, or a date with a time
'date' / 'time' / 'datetime' short keywords: 2024-01-15, 14:30:00, or both
'0', '0.00', '#,##0', '#,##0.00' integers / fixed decimals / thousands
'$#,##0', '€#,##0.00' leading symbol = currency
'0%', '0.00%' percent (scales by 100)
'0.00E+00' scientific
'$#,##0;[Red]($#,##0)' ; splits positive;negative;zero — red parens for negatives
'percent', 'number', 'currency', 'thousands', 'integer', 'text' the common words, as the toolbar's codes ('0.0%', '#,##0.00', '$#,##0.00'…)

Anything that is not a format ('banana') raises an error that gives examples; it is never written into the cells.

Dates from a script#

Write datetime.date / datetime.datetime objects, pandas Timestamps, numpy datetime64 values or ISO text ('2024-01-15', '2024-01-15T14:30:00') and the cell becomes a real date — a serial with the 'date' (or 'datetime') format already applied, so =F2+30, sorting and charts work. That is what Excel's VBA does on write. A locale form such as '1/2/2024' is not guessed at and stays text, and a cell that already has a date format keeps it. Reading a date cell back (sheet.value, sheet.read) gives the serial; pd.to_datetime(serial, unit='D', origin='1899-12-30') turns it into a Timestamp.

A time of day (datetime.time(14, 30)) is written as a time, and a length of time (datetime.timedelta, a pandas Timedelta, a numpy timedelta64) as a duration: a number of days, shown in hours (36:00:00, the [h]:mm:ss format). Both are numbers, so a column of lap times adds up. Text such as '14:30:00' that a script writes itself stays text.

Formatting never changes the stored value, only its display.

Formula functions (inside "=…" strings)#

sheet.set(r, c, '=…') writes a live formula that the engine recalculates. The engine implements a specific set of Excel functions — anything outside it evaluates to #NAME?. Ask in plain English (the Python panel) is given the full list with signatures, so it only writes functions the engine has.

Covered, by family: math/trig (SUM ROUND ABS LN LOG EXP PI SIN…), stats (AVERAGE MEDIAN STDEV PERCENTILE NORM.DIST RANK…), lookup (XLOOKUP VLOOKUP INDEX MATCH INDIRECT OFFSET), logical (IF IFS IFERROR AND OR SWITCH), text (LEFT MID TEXTJOIN TEXTBEFORE SUBSTITUTE TEXT), date/time (TODAY EOMONTH NETWORKDAYS YEARFRAC WEEKNUM), finance (NPV IRR XIRR PMT IPMT SLN DDB), engineering (DEC2BIN BITAND…), info (ISNA ISTEXT ERROR.TYPE), arrays (FILTER SORT UNIQUE SEQUENCE TAKE VSTACK; they do not spill, so read one with INDEX or total it), LAMBDA with MAP BYROW REDUCE, and database (DSUM DCOUNT DGET). Browse them in-app by typing = in a cell.

Volatile functions: RAND, RANDBETWEEN and NOW, and every cell that reads them, are worked out again after every edit; TODAY is too once the date has changed. Data → Calculate Now (F9) works out every volatile cell again, TODAY included.

Patterns#

# Analyze
df = sheet.read()
sheet.write(df.describe(), 'H1')

# Derived formula column (live, recalculates): write row 2, fill it down
sheet.set_cell('D1', 'Revenue')
sheet.set_cell('D2', '=B2*C2')
sheet.fill_down('D2:D%d' % sheet.rows)

# Chart
sheet.chart('A1:C13', 'bar', 'Revenue by region')

# Copy a sheet (formulas, formats, widths and all)
dst = sheet.copy('Copy')

# Restructure + tidy
sheet.insert_cols(2)                       # new blank column C
sheet.set(0, 2, 'Margin')
sheet.filter('A2:F100', 'F', '>', 1000)    # hide rows where col F <= 1000 (start below the header)
sheet.remove_duplicates('A2:F100')
sheet.freeze(1)                            # pin the header row

# Pivot to a new sheet by header NAME, then chart it
pv = sheet.pivot('A1:E5', rows=['Region'], values=[('Q1', 'sum'), ('Q2', 'sum')])
pv.chart('A1:C6', 'bar', 'Q1 vs Q2 by region')
# Re-edit the pivot in place (Excel field-pane edit, from code):
pv.update_pivot(rows=['Region'], values=[('Q1', 'sum')], show_as='pctOfTotal')
# A second text column (say Product) can go across the top as the column field:
# pv.update_pivot(rows=['Region'], cols='Product', values=[('Q1', 'sum')])
pv.refresh_pivot()                          # recompute if the source data changed

# Validation + named range
sheet.validate('B2:B100', 'list', options='Low,Medium,High')
sheet.define_name('Revenue', 'C2:C100')
sheet.set('E1', '=SUM(Revenue)')

# Hand data back out as text (no file dialog)
print(sheet.to_csv('A1:C10'))

# Print setup, then a PDF (only when the user asked for one)
sheet.page_setup(orientation='landscape', fit=(1, 0), title_rows='1:1',
                 footer={'right': 'Page &P of &N'})
print(sheet.page_count(), 'pages')
path = await sheet.export_pdf('/Users/me/Documents/Q3 report.pdf')

Rules (for correct, hallucination-free output)#

Roadmap (not available yet)#

A script cannot yet run SQL over a sheet's data or read a database query straight into a DataFrame: bring the result into a sheet with Data → Connect to Database…, then sheet.read() it.