Skip to main content
paper-xlsx logo

paper-xlsx

PyPI Python versions Test

An import-compatible, agent-safe fork of openpyxl designed to prevent silent loss during supported edits to existing Excel files.

paper-xlsx is an import-compatible hard fork of openpyxl 3.1.5 for safely inspecting, editing, and verifying existing Excel workbooks. It keeps openpyxl's reader, object model, formula tokenizer, and broad file-format support. It adds a preserve-mode save path that retains package content openpyxl does not model.

import openpyxl   # the import name is unchanged; see "Drop-in by design"

Under the default preserve mode, the original file's bytes are the source of truth. paper-xlsx writes supported edits into the retained package and copies untouched parts byte-for-byte. If it cannot express an edit safely, it raises a typed error before saving. Writes to protected cells can also emit an advisory warning.


Why paper-xlsx exists

openpyxl is a widely used Python Excel library, and pandas can use it for .xlsx files through read_excel and ExcelWriter. Its object model is strong, but its save path regenerates the entire file from that model. Content that openpyxl does not fully model can be degraded or removed. Its documentation says:

openpyxl does currently not read all possible items in an Excel file so shapes will be lost from existing files if they are opened and saved with the same name.

openpyxl tutorial (doc/tutorial.rst)

An .xlsx file can open normally even when an edit has damaged its contents. The results can still look reasonable, so automated checks may not detect the problem. These problems occur with openpyxl 3.1.5:

  • Saving after data_only=True can replace formulas with values. data_only=True loads cached results instead of formulas. If you save that workbook, those results are written back as values. In a test with three formulas, none remained after the save.
  • insert_rows() and delete_rows() do not update related references. Cells move, but formulas, defined names, and chart ranges can still point to the old locations. The resulting values can look plausible even when the references are wrong.
  • Formula results can be missing. openpyxl does not calculate formulas. New or saved formulas can have empty cached results until a spreadsheet application recalculates them.
  • VBA can be removed from .xlsm files. Callers must pass keep_vba=True to preserve it.
  • Unsupported drawing content can be lost during save. Shapes, text boxes, sparklines, and newer validation or conditional-formatting extensions can be removed. Supported charts are regenerated, which can remove chart extensions and related parts. Content that openpyxl fully supports, such as merged cells, standard validations, comments, and hyperlinks, is preserved.

People can inspect a workbook after an edit. An automated agent cannot rely on visual review. paper-xlsx preserves supported content and returns a typed refusal when it cannot make an edit safely.

Quick start

pip uninstall -y openpyxl paper-xlsx   # required: see "Drop-in by design"
pip install paper-xlsx
paper-xlsx-doctor                       # verify the install is coherent

[!IMPORTANT] The PyPI distribution is paper-xlsx, but you still write import openpyxl, never import paper_xlsx. Do not install openpyxl and paper-xlsx in the same environment: both distributions own the same import tree, and package managers cannot safely arbitrate that. Always uninstall both first, then install paper-xlsx.

Build a small model, reload it, and make a safe edit with a machine-readable receipt:

from openpyxl import Workbook, load_workbook

wb = Workbook()
ws = wb.active
ws["A1"], ws["B1"] = "Growth rate", 0.05
ws["A2"], ws["B2"] = "Revenue", 1000
ws["B3"] = "=B2 * (1 + B1)"
wb.save("model.xlsx")

wb = load_workbook("model.xlsx")        # preserve mode: on by default

wb.sheetnames                           # inspect structure directly
wb.active["B1"] = 0.07                  # ordinary openpyxl cell API
receipt = wb.save("model_v2.xlsx", receipt=True)
receipt.to_dict()["cells_changed"]
# {'xl/worksheets/sheet1.xml': {'B1': 'changed', 'B3': 'changed'}}

When an edit cannot be made safely, paper-xlsx raises a typed refusal instead of writing a corrupted file. A refused operation leaves the model, the ledger, and the disk exactly as they were:

from openpyxl import load_workbook
from openpyxl.errors import PaperRefusal

values_only = load_workbook("model.xlsx", data_only=True)
try:
    values_only.save("model_values_only.xlsx")
except PaperRefusal as err:
    print(err)
    if err.kind is not None:
        print(err.kind, err.anchor, err.options)  # optional structured context

What paper-xlsx adds

paper-xlsx is based on openpyxl 3.1.5. CI runs the upstream test suite alongside Paper's contract tests to catch compatibility regressions. Preserve mode is the default for editable supported OOXML workbooks; pass preserve=False to use upstream-compatible behavior.

Preservation and guarded editing

  • Preserve mode: load_workbook(path) retains the original archive bytes, a dirty ledger wired into openpyxl's setters records supported semantic mutations, and save splices only the dirty byte ranges into the original parts instead of regenerating files. Untouched parts are raw-copied byte-identical; a no-op save produces a byte-identical file. The machinery lives in openpyxl/preserve/: ledger, splice writer, cross-part discipline, deterministic atomic ZIP I/O, and related preservation code.
  • A typed refusal taxonomy (openpyxl/errors.py): PaperRefusal and its subclasses (AmbiguousTargetError, TargetNotFoundError, UnsupportedStructureError, BoundaryViolationError, RelationshipPolicyError, OracleUnavailableError, OracleTimeoutError). Guarded operations validate before committing mutations; a refused operation changes nothing in memory or on disk. Refusals expose optional kind, anchor, and options fields. The exception message is always the complete explanation.
  • The oracle (openpyxl/oracle.py): openpyxl never calculates, and this fork deliberately ships no formula engine. oracle.recalc(), oracle.certify(), oracle.evaluate(), and oracle.evaluate_many() use a headless, profile-isolated LibreOffice process working on temporary copies. recalc(source) returns narrow calculation/error evidence without writing. recalc(source, output_path=...) writes only a separate Paper-preserved candidate: eligible LibreOffice-calculated caches are spliced into the original package structure, the source stays untouched, and full recalculation remains requested. If those writes or that recalculation can affect a local pivot source, the candidate requests cache refresh-on-open and RecalcResult.pivot_refreshes reports that Excel requirement. Recalculation and evaluation status say only whether recognized formula errors were detected; they do not claim Excel equivalence or financial correctness.
  • Targeted inspection helpers: wb.search(...), ws.allowed_values(cell), openpyxl.preserve.scan_errors(), and diff_workbooks(). allowed_values() reports literal lists and deterministic static one-dimensional ranges, or raises a typed refusal when the source cannot be represented exactly. scan_errors() reports cached error values and actual formula error operands, not matching text inside string literals. These helpers supplement ordinary workbook objects without guessing workbook roles or mutation targets.
  • Guarded structural edits: row and column insertion and deletion on loaded preserve-mode sheets rewrite supported dependent formulas, defined names, print areas, table ranges, and chart references, or refuse before mutation, and return an AddressRemap. Sheet renames rewrite supported dependencies through normal title assignment and return no remap. move_range() tracks the moved cells and refuses intersections or outside references it cannot keep coherent; like upstream, it returns None.
  • Narrow mutation helpers: ws.append_table_row(...) expands a supported named worksheet table atomically. It preflights the retained table XML, relationships, geometry, formulas, totals, filters, styles, formats, merged/spill regions, and protection state. A loaded table without retained preserve-mode source, or a connected, extended, sorted, or otherwise unsupported table, refuses before mutation. A refusal is final: do not bypass it with generic row insertion, preserve=False, or raw package editing. openpyxl.preserve.copy_format(...) applies one complete cell style through a range-local transaction, and Chart.repoint(...) validates the complete chart patch before changing the model. ws.replace_image(...) retargets one loaded image relationship without changing its anchor, and wb.set_pivot_refresh_on_load(pivots=[...]) changes only selected pivot-cache refresh metadata. Edits that change an existing pivot's local source, touch that source, transitively affect its formulas (including calculation-relevant cell formatting, row/column display state, and filtering), or accompany a known volatile built-in in the source refuse at validation/save unless that pivot is explicitly selected for refresh. Exact direct ranges, static defined names, and named-table sources are recognized; unresolved local sources refuse conservatively. Runtime volatility of user-defined functions is not declared in OOXML and is not inferred; callers using a UDF in a pivot source must explicitly request refresh. The receipt reports that Excel must refresh the cache on open and that headless readers may still see its old results. Path saves are written to a temporary file, fsynced, and atomically moved into place.
  • paper-xlsx-doctor: a console script that verifies the installed distribution actually owns the openpyxl import tree.

Default behavior and compatibility

  • Preserve mode is the default. Editable supported OOXML workbooks load in preserve mode unless preserve=False is passed. Filesystem paths are classified by supported OOXML suffix. Seekable file-like sources are classified from their ZIP container and workbook content type, regardless of a missing or misleading filename. read_only=True loads retain upstream behavior, and preserve=False selects the upstream-compatible load/edit/save path.
  • Formula caches are invalidated instead of trusted. When you edit a formula, or write a value into a cell that formulas read, save strips the now-stale cached results from the file and sets the workbook to fully recalculate on open. This prevents a human from opening the edited file in Excel and silently trusting a stale number. The implementation handles array/spill formula followers, namespace-prefixed formula elements, and whole-column array references; style-only edits keep their caches untouched.
  • The stock path stays stock. preserve=False does not run Paper ledgers, scanners, warnings, structural guards, or ZIP eligibility policies. It is the compatibility escape hatch for callers that deliberately want upstream openpyxl behavior.

Archive validation

  • Integrity without package-defined eligibility caps. Paper validates ZIP integrity but does not impose fixed entry-count, byte-size, or compression-ratio limits. Resource limits belong to the caller or execution environment.

How it works

Preserve mode separates the in-memory workbook model from the saved package. Stock openpyxl uses its object model both to represent the workbook and to regenerate the file during save. paper-xlsx keeps the original archive as the source of truth and uses the object model to describe edits:

  1. Byte retention: the load keeps every part of the original archive. Content that openpyxl does not parse or fully represent remains available in the retained bytes. This includes content in drawings, VBA projects, pivot caches, media, and custom XML.
  2. The dirty ledger: instrumented chokepoints in openpyxl's own setters record every semantic mutation. A compare-based diff-save is impossible here, because openpyxl cannot serialize a faithful candidate to compare against; serialization is the lossy act. The ledger records what changed.
  3. The splice writer: touched sheets are stream-patched. Untouched byte ranges are copied verbatim; dirty cells are replaced at their exact scanned spans. Unmodeled XML passes through untouched because it is never interpreted. Untouched parts are raw-copied without recompression.
  4. Cross-part discipline: every operation class has a sanctioned set of parts it may touch, enforced in tests by an exact changed-part budget. The package diff must show exactly the expected parts changed and every other part byte-identical.
A real workbook, edited and saved stock openpyxl 3.1.5 paper-xlsx preserve mode
Shapes, textboxes, mc:AlternateContent silently dropped survive byte-identical
Charts regenerated from the model; chart extLst and auxiliary parts lost untouched charts survive byte-identical; title and series-range edits are spliced
Sparklines, x14 validations/formatting dropped (load-time warning) survive byte-identical
VBA project in .xlsm stripped unless keep_vba=True survives
data_only=True, then save every formula replaced by its cached value, silently refused unless explicitly allowed
insert_rows / delete_rows cells move, references don't; silent corruption references rewritten (or edit refused); AddressRemap returned
Formula caches after an edit cached results can be cleared; formulas are not calculated affected caches invalidated; full recalc requested on open
Unsafe or ambiguous operation best guess, silently typed PaperRefusal, atomic

The full preserve-mode guide, including the refusal taxonomy, receipts, the oracle, and delivery, is in doc/paper.rst.

Testing

The test suite covers realistic spreadsheet edits, including:

  • repairing one member of a shared-formula block without disturbing its siblings or a neighboring array formula,
  • fixing a formula without dropping the workbook's x14 validation dropdowns,
  • renaming a sheet with every dependent formula, defined name, print area, and chart reference rewritten,
  • updating an input in a macro-enabled .xlsm and delivering it with the VBA project intact,
  • retargeting a chart series without disturbing sibling images or drawing anchors,
  • expanding a named table and editing review content while preserving formulas, drawings, and relationships.

The library's test discipline is documented in CONTRIBUTING.md: the upstream pytest suite provides compatibility coverage, while Paper's persistence tests use saved-and-reopened assertions, exact changed-part budgets, refusal-atomicity checks, a provenance-labelled fixture corpus, and a headless LibreOffice load smoke where those checks apply.

Drop-in by design

The fork keeps the openpyxl import name so existing code does not need new imports. Only the distribution name changes, similar to Pillow (pip install pillow, import PIL).

  • PyPI distribution / GitHub repository: paper-xlsx
  • Python import: openpyxl, never import paper_xlsx, anywhere
  • Fork sentinel: openpyxl.__paper_version__
  • Upstream base: openpyxl 3.1.5

paper-xlsx is versioned independently from its upstream base. openpyxl.__paper_version__ reports the installed paper-xlsx distribution version, while openpyxl.__version__ reports the upstream base version. pandas workflows that use the openpyxl engine use this fork automatically. Preserve mode applies when pandas opens an existing file for editing; a new ExcelWriter workbook uses the standard creation path. Python 3.9–3.13 are supported and tested on Linux. CI also tests Python 3.13 on Windows and with and without lxml.

The upstream openpyxl APIs remain available. Preserve-by-default changes save behavior for existing editable OOXML workbooks; preserve=False restores the upstream-compatible load/edit/save path.

Documentation

  • doc/paper.rst: the preserve-mode guide, covering loading and saving, formula-cache freshness, perception, editing, the oracle, delivery, the refusal taxonomy, and the compatibility opt-out. Ships inside the sdist.
  • The remaining Sphinx docs cover the upstream openpyxl APIs. Use preserve=False when you need upstream save behavior.

Current limitations

Preserve mode refuses operations that it cannot save safely. Current examples include chartsheet edits, table or pivot creation on newly added sheets, and comment changes on sheets that already contain comment parts. See the refusal sites in openpyxl/preserve/saver.py.

Contributing

See CONTRIBUTING.md for the engineering discipline this fork runs on. The short version: the upstream suite must remain green; persistence changes need saved-and-reopened assertions and exact package-delta checks; package-format regressions should use representative frozen fixtures; guarded refusals must be atomic; and new XML handling goes through openpyxl's Serialisable descriptor framework rather than string-formatted XML.

Useful non-code contributions include real-world fixtures authored by desktop Excel or Google Sheets under the provenance rules in CONTRIBUTING.md.

Community

Acknowledgments

paper-xlsx exists because openpyxl's object model and format coverage are excellent. Thanks to Eric Gazoni, Charlie Clark, and the openpyxl contributors (see AUTHORS.rst) for the work this project builds on. Upstream openpyxl lives at foss.heptapod.net/openpyxl/openpyxl.

Citation

If you reference paper-xlsx in research or writing:

@software{paper_xlsx,
  title   = {paper-xlsx: an agent-first structure editor for Excel documents},
  author  = {{Paper Instruments, Inc.}},
  year    = {2026},
  url     = {https://github.com/paper-instruments/paper-xlsx}
}

paper-xlsx is a fork of openpyxl by Eric Gazoni, Charlie Clark, and contributors.

License

MIT, inherited from openpyxl. Original work © 2010 openpyxl; fork additions © 2026 Paper Instruments, Inc. This fork preserves the upstream license and attribution. See LICENCE.rst.

Release files for paper-xlsx 0.2.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for paper-xlsx 0.2.1
File Size Uploaded
paper_xlsx-0.2.1.tar.gz 404.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for paper-xlsx 0.2.1
File Interpreter ABI Platform
paper_xlsx-0.2.1-py3-none-any.whl Python 3 none any Details

Total release size: 881.7 kB

Release files / paper_xlsx-0.2.1.tar.gz

Download URL paper_xlsx-0.2.1.tar.gz
Size 404.3 kB
Tags Source
SHA-256 checksum
How to use checksums
aaf1154f77512569834970bae1eeea829f3f37adff55b6cc73332964924ad548
BLAKE2b-256 checksum
How to use checksums
4ae39cef0eb78f18e3c339d66f5da3bb4dd756212c60ab9583d0ae74222f1153
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.13

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 24, 2026.

Transparency log

Release files / paper_xlsx-0.2.1-py3-none-any.whl

Download URL paper_xlsx-0.2.1-py3-none-any.whl
Size 477.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
aa53327bbc9540fe638a1fc4e2f30910a29666917013a9a18a1ff4abca534824
BLAKE2b-256 checksum
How to use checksums
9d46c43058b6a1e6499624a3b08a571c5bdc08ea86671bbc6e2532e7a30b38af
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.13

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 24, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.2.1 This release

2 release files

0.2.0

2 release files

0.1.3

2 release files

0.1.2

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page