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=Truecan replace formulas with values.data_only=Trueloads 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()anddelete_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
.xlsmfiles. Callers must passkeep_vba=Trueto 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 writeimport openpyxl, neverimport paper_xlsx. Do not installopenpyxlandpaper-xlsxin the same environment: both distributions own the same import tree, and package managers cannot safely arbitrate that. Always uninstall both first, then installpaper-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 inopenpyxl/preserve/: ledger, splice writer, cross-part discipline, deterministic atomic ZIP I/O, and related preservation code. - A typed refusal taxonomy (
openpyxl/errors.py):PaperRefusaland 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 optionalkind,anchor, andoptionsfields. 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(), andoracle.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 andRecalcResult.pivot_refreshesreports 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(), anddiff_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 returnsNone. - 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, andChart.repoint(...)validates the complete chart patch before changing the model.ws.replace_image(...)retargets one loaded image relationship without changing its anchor, andwb.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 theopenpyxlimport tree.
Default behavior and compatibility
- Preserve mode is the default. Editable supported OOXML workbooks load in preserve mode unless
preserve=Falseis 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=Trueloads retain upstream behavior, andpreserve=Falseselects 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=Falsedoes 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:
- 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.
- 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.
- 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.
- 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
.xlsmand 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, neverimport 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=Falsewhen 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
- Bugs and feature requests: GitHub Issues
- Questions and ideas: GitHub Discussions
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)
| File | Size | Uploaded | |
|---|---|---|---|
| paper_xlsx-0.2.1.tar.gz | 404.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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 logRelease 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