Metadata-Version: 2.4
Name: betteroffice-xlsx
Version: 0.0.2
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Rust
Classifier: Topic :: Office/Business :: Financial :: Spreadsheet
Classifier: Topic :: Software Development :: Libraries
Classifier: Typing :: Typed
License-File: LICENSE
License-File: NOTICE
License-File: THIRD-PARTY-NOTICES.md
License-File: licenses/Carlito-OFL.txt
Summary: Read, recalculate, render, and write XLSX workbooks. Rust engine, no Excel and no LibreOffice.
Keywords: xlsx,excel,spreadsheet,spreadsheet-engine,ooxml,formula,formulas,recalculate,render,openpyxl,rust
Home-Page: https://betteroffice.dev
Author: OpenOOXML
License-Expression: Apache-2.0
Requires-Python: >=3.9
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Project-URL: Changelog, https://github.com/openooxml/betteroffice/blob/main/bindings/python-xlsx/CHANGELOG.md
Project-URL: Documentation, https://docs.betteroffice.dev/docs/python
Project-URL: Homepage, https://betteroffice.dev
Project-URL: Issues, https://github.com/openooxml/betteroffice/issues
Project-URL: Source, https://github.com/openooxml/betteroffice

# betteroffice-xlsx

Read, recalculate, render, and write XLSX workbooks from Python. Where
`openpyxl` hands back a formula's source text or whatever value the authoring
application happened to cache, this evaluates the formula and can rasterize the
sheet: the Rust [BetterOffice](https://betteroffice.dev) XLSX core is compiled
into the wheel — no Excel, no LibreOffice subprocess, no COM.

```bash
pip install betteroffice-xlsx
```

The distribution is hyphenated, the module is not: `import betteroffice_xlsx`.

## Formulas actually calculate

```python
from betteroffice_xlsx import Workbook

wb = Workbook.open_path("budget.xlsx")

sheet = wb["Sheet1"]
sheet["B1"] = 10
sheet["B2"] = 32
sheet["B3"] = "=SUM(B1:B2)"

print(sheet["B3"])            # 42.0   <- computed here, not read from a cache
print(sheet.formula("B3"))    # 'SUM(B1:B2)'

wb.save_path("budget-out.xlsx")
```

Value and formula are separate accessors on purpose: `sheet["B3"]` is the value,
`sheet.formula("B3")` is the source text. Writing a cell recalculates its
dependents, so the value above is computed here rather than read back from the
file.

`Workbook.open` starts from the values the authoring application cached, and a
cell you have not touched keeps that cached value — including if it was stale
when the file was written. Use `open_recalculated`, or call `recalculate()`,
when you need every formula evaluated by this engine rather than trusted from
the file.

## Render a sheet to PNG

```python
png = wb.render_png("Sheet1", scale=2.0, range="A1:H40")
png.write("preview.png")
print(png.width, png.height)
```

Rendering is the same grid layout and display list the browser editor uses, so
server-side output matches what the web canvas paints.

Opening, recalculating, rendering, and saving release the GIL, so they run in
parallel across threads instead of serializing your workers.

## Collaboration

Every workbook opened with `open_collaborative` is a Yrs replica. The binding
exposes the byte-level primitives rather than a transport, so it drops into a
WebSocket server, a queue, or a test harness without committing you to asyncio:

```python
left = Workbook.open_collaborative(data)
right = Workbook.open_collaborative(data)
print(left.client_id, right.client_id)

left["Sheet1"]["B3"] = 1000
right.apply_update(left.diff(right.state_vector()))   # right now agrees

joiner = Workbook.open_collaborative(data)
joiner.apply_update(left.state_as_update())           # catch up from nothing
```

The binding generates a client ID when it is omitted and exposes the chosen ID
through the read-only `client_id` property. A server may pass a deterministic
`client_id` explicitly, but it must be unique among connected peers because Yrs
cannot detect duplicates once two replicas have started authoring. Collaboration
byte inputs accept `bytes`, `bytearray`, and `memoryview`.

## Undo, redo, and batches

```python
wb.set_many("Sheet1", {"H1": 10, "H2": 20, "H3": "=H1+H2"})   # one undo step
wb.undo()
wb.redo()
wb.history()          # History(undo_depth=1, redo_depth=0)
```

Undo covers this replica's own edits. Updates applied from a peer are not in
local history, so undo will not revert someone else's work.

## Agent proposals

An agent can stage edits for a human instead of applying them. Each proposed
edit carries the display text a reviewer would compare — `before` and `after`
are what the cell shows, as strings, while `input` is what would be written:

```python
proposal = wb.propose("copilot", [("Sheet1", "H1", "=B3*2")], note="double the total")

for edit in proposal.edits:
    print(edit.address, repr(edit.before), "->", repr(edit.after))   # H1 '' -> '84'

wb.accept_proposal(proposal.id)     # or wb.reject_proposal(proposal.id)
```

Nothing is written until the proposal is accepted, and accepting applies it as
a single undo step. `proposals()` lists the ones still awaiting a decision.

A proposal goes stale when one of its target cells changes after it was staged.
`accept_proposal` then raises `StaleProposalError`, whose `cells` names the
addresses that moved underneath it — re-propose against the new values, or
apply it anyway with `force=True`:

```python
from betteroffice_xlsx import StaleProposalError

try:
    wb.accept_proposal(proposal.id)
except StaleProposalError as stale:
    print("changed underneath:", stale.cells)   # ['H1']
    wb.accept_proposal(proposal.id, force=True)
```

An unknown proposal ID raises `KeyError`; `reject_proposal` returns `False`
instead when there is nothing left to reject.

## Formatting

```python
wb.set_number_format("Sheet1", "B3:B10", "#,##0.00")
wb.set_style("Sheet1", "A1:D1", bold=True, fill_color="#eeeeee",
             horizontal_alignment="center")
```

`set_number_format` takes `automatic`, `text`, `number`, `percent`,
`scientific`, `currency`, `date`, `time`, or a custom pattern.

## Reading without recalculating

`Workbook.open` keeps whatever values the file already carried. Use
`open_recalculated` to evaluate everything up front, or call `recalculate()`
later:

```python
wb = Workbook.open_recalculated(open("report.xlsx", "rb").read())

summary = wb.recalculate()
print(summary.changed, summary.cycles)
```

## Compared with openpyxl

| | `openpyxl` | `betteroffice-xlsx` |
| --- | --- | --- |
| Read cell values | yes | yes |
| Evaluate formulas | no — returns the formula string, or a stale cached value | yes |
| Render to an image | no | yes, PNG |
| Engine | pure Python | Rust, compiled |

`openpyxl` is a far broader library and covers plenty this does not. If you need
formulas evaluated or a sheet rasterized, that is the gap this fills.

## API

| | |
| --- | --- |
| `Workbook.open(data)` | open from `bytes` |
| `Workbook.open_path(path)` | open from a path |
| `Workbook.open_recalculated(data)` | open and evaluate every formula |
| `Workbook.open_collaborative(data)` | open a Yrs replica — on the class, like the other openers |
| `wb.recalculate()` | re-evaluate; returns a `Calculation` summary |
| `wb.last_calculation()` | the `Calculation` the most recent one produced |
| `wb.sheet_names` / `wb.sheet_count` | sheet metadata |
| `wb[key]` / `wb.sheet(key)` | a `Sheet` by name or index |
| `wb.sheet_index(key)` | resolve a name or index to an index |
| `sheet[addr]` | cell value — see the note below on when it is recalculated |
| `sheet[addr] = value` | set from what a user would type |
| `sheet.formula(addr)` | source formula, or `None` |
| `wb.value(sheet, addr)` / `wb.formula(sheet, addr)` | the same two reads without a `Sheet` |
| `wb.set(sheet, addr, value)` / `wb.set_many(sheet, edits)` | write one cell, or many as one undo step |
| `wb.merged_ranges(sheet, range)` | merged regions overlapping a range |
| `wb.undo()` / `wb.redo()` | walk local history |
| `wb.can_undo` / `wb.can_redo` / `wb.history()` | what history is available |
| `wb.propose(...)` / `proposals()` / `accept_proposal` / `reject_proposal` | staged agent edits |
| `wb.set_style(...)` / `set_number_format(...)` | formatting over a range |
| `wb.diff(sv)` / `apply_update(u)` / `state_vector()` / `state_as_update()` | exchange Yrs updates |
| `wb.client_id` / `wb.is_collaborative` | which kind of workbook you are holding |
| `wb.active_sheet` / `set_active_sheet(...)` | read or persist the active tab |
| `wb.render_png(sheet, ...)` | render to PNG |
| `wb.save()` / `wb.save_path(path)` | serialize to XLSX |

Every call that can trigger a calculation — `open_recalculated`,
`open_collaborative`, `recalculate`, `set`, `set_many`, `undo`, `redo`,
`apply_update`, `propose`, `accept_proposal`, `set_number_format`, and
`set_style` — takes a keyword-only `now_serial`. It is the clock `TODAY()` and
`NOW()` read, as an Excel serial number. The engine has none of its own, so
both return `#VALUE!` unless you pass one:

```python
wb.recalculate(now_serial=45658.5)   # 2025-01-01, midday
```

Cell values come back as `None`, `float`, `str`, `bool`, or `CellError`.
Numbers are `f64` in the engine, so they arrive as `float` and are not narrowed
to `int`. Errors are a `CellError` instance rather than a string, so `#DIV/0!`
as a value is distinguishable from a cell containing that text. `CellError`
compares equal to its code, and hashes like it, so it works as a dict key:

```python
if sheet["D3"] == "#DIV/0!":
    ...
```

Writing accepts `None` (clears the cell), `bool`, `int`, `float`, `Decimal`, and
`str`. `date`, `datetime`, `time`, and `timedelta` raise `TypeError` for now:
converting them needs the workbook's date system, which is not exposed yet, and
stringifying them would write text that only looks like a date. Pass the Excel
serial number as a float if you need a date today.

Strings are interpreted the way Excel interprets typed input: a leading `=` is a
formula, `TRUE`/`FALSE` become booleans, and numeric text becomes a number.
Prefix with an apostrophe to force text.

```python
sheet["A1"] = "'=1+1"   # the text "=1+1"
sheet["A2"] = "=1+1"    # the formula, evaluating to 2.0
```

Mutating calls return a `Mutation` — truthy when something changed, with
`changed` listing the cells the engine *recalculated* as a result, and `cycles`
and `limited` the ones it gave up on. A cell you wrote directly is not itself a
recalculation, so it will not always appear there. Addresses are bare A1 on the
active sheet and `Sheet!A1` anywhere else, so match on the suffix rather than
the whole string.

Errors raise `XlsxError` or a more specific subclass: `ParseError`,
`RangeError`, `RenderError`, `InvalidUpdateError`, `CollaborativeStateError`,
`StaleProposalError`, `NotCollaborativeError`. Invalid peer updates, broken
local collaboration state, stale proposals, and collaboration-only operations
are the last four in that order. `StaleProposalError.cells` lists the changed A1
addresses, and an unknown proposal ID raises `KeyError`.

## Status

`0.0.x`, and the API may change before `0.1.0`.

`save` keeps the parts the model does not represent — charts, drawings, pivot
tables, comments, macros, custom XML, and their relationships — rather than
regenerating the package from the modeled features, and sheets you did not
touch are copied through byte for byte. The stylesheet is left alone unless
styles actually change.

A sheet you *do* edit is reserialized from the model, so unmodeled row, column,
and cell markup on that one sheet is lost, and its autofilter, data-validation,
conditional-formatting, table, and sparkline ranges stay at their source
coordinates. Collaborative sessions compare only the modeled workbook, so two
peers holding the same cells but different macros or custom XML still accept
each other as the same base.

This binding exposes no structural edits: no sheet rename or removal, no row or
column insert or delete. The engine refuses those anyway while a pivot table or
an unmodeled chart part is preserved, because it cannot rewrite the references
they hold.

Wheels are built for Linux (x86_64, aarch64), macOS (arm64, x86_64), and Windows
(x86_64) against the stable ABI for CPython 3.9 and up.

The extension embeds Carlito, a Calibri-metric-compatible face used to measure
and draw cell text, under the SIL Open Font License. Its license travels with
the wheel — see `THIRD-PARTY-NOTICES.md` and `licenses/Carlito-OFL.txt`. The
package's own code is Apache-2.0.

## Links

- [BetterOffice](https://betteroffice.dev) — the project
- [Documentation](https://docs.betteroffice.dev/docs/python)
- [Source](https://github.com/openooxml/betteroffice) — `bindings/python-xlsx`
- [betteroffice-xlsx on crates.io](https://crates.io/crates/betteroffice-xlsx) — the engine this wraps

Apache-2.0.

