WolfXL 1.3 — Read-side parity (rich text + streaming + password)¶
Historical release note: this preserves release history and is not current public claim evidence. Use
docs/trust/public-evidence.mdand the current SOTA audit for today's claim boundary.
Date: 2026-04-26
WolfXL 1.3 closes the three biggest read-side gaps that survived
1.0–1.2's modify-mode and structural-ops focus: rich-text reads
(Pod-α), streaming reads on huge sheets (Pod-β), and
password-protected workbook reads via msoffcrypto-tool (Pod-γ).
Sprint Ι ("Iota") wraps the read-path parity story — after 1.3,
everything in tests/parity/KNOWN_GAPS.md's Phase 2 / Phase 3 /
Phase 4 sections is closed; only Phase 5 (.xls / .xlsb) carries
forward.
The Pod-δ slice that ships alongside the read-path pods adds a
fix-it for the long-standing native-writer VML margin bug on sheets
with custom column widths (D3), exposes
Workbook.defined_names["X"] = DefinedName(...) end-to-end so
write-mode users can construct named ranges without dropping into
the Rust API (D4), re-engages the parity ratchet on the open
KNOWN_GAPS rows (D1), and registers four custom pytest marks to
silence the long-running PytestUnknownMarkWarning noise (D2).
What's new¶
Rich-text reads: Cell.rich_text (Sprint Ι Pod-α, RFC-040)¶
import wolfxl
wb = wolfxl.load_workbook("template.xlsx")
ws = wb["Notes"]
cell = ws["B7"] # has bold "WARNING: " + plain "do not delete"
print(cell.value) # → "WARNING: do not delete" (flat str — unchanged)
print(cell.rich_text) # → CellRichText([
# TextBlock(font=InlineFont(bold=True), text="WARNING: "),
# "do not delete",
# ])
Cell.rich_text returns a CellRichText-shaped iterable of
TextBlock | str runs when the underlying <is> / <si> carries
<r>/<rPr> formatting. Plain-text cells return None from
Cell.rich_text and continue to surface their value via
Cell.value exactly as before.
InlineFont mirrors <rPr>:
name | size | bold | italic | underline | strike | color | family
| scheme. str(cell.rich_text) == cell.value is invariant —
stringifying a CellRichText concatenates run text in document
order so callers that only need the flat string can keep using
cell.value.
Pod-α also ships rich-text writes (round-trip):
cell.value = CellRichText([...]) works in both write mode and
modify mode. Inline strings (<c t="inlineStr"><is>...</is></c>)
are emitted on the way out — the SST stays untouched, matching
openpyxl's own rich-text emit path verbatim and sidestepping
SST-dedup complexity. Round-trip tests verify wolfxl→openpyxl,
openpyxl→wolfxl, and wolfxl→wolfxl preserve runs byte-for-byte.
Optional reader flag: pass load_workbook(path, rich_text=True) to
make Cell.value return CellRichText directly for rich cells
(matches openpyxl's flag-gated behaviour). Without the flag,
Cell.value keeps flattening to str for backwards compatibility.
Pod-α commit: 381813a. RFC: Plans/rfcs/040-rich-text.md.
Streaming reads on huge sheets (Sprint Ι Pod-β, RFC-041)¶
import wolfxl
# Explicit opt-in
wb = wolfxl.load_workbook("huge.xlsx", read_only=True)
for row in wb.active.iter_rows(values_only=True, max_row=100):
print(row)
# Memory: O(row width), not O(sheet). 1M-row workbooks now stream
# at < 200 MB peak RSS instead of OOM-ing the kernel.
# Implicit auto-trigger (warn-once per workbook)
wb = wolfxl.load_workbook("huge.xlsx")
# RuntimeWarning: WolfXL auto-enabled streaming reads for "huge.xlsx"
# (sheet 'Sheet1': 1,200,000 rows × 8 cols). Pass read_only=False to opt out.
read_only=True engages a quick-xml SAX path that walks
<sheetData> row-by-row and yields one Python tuple per <row>.
Sheets that exceed the auto-trigger threshold (default: 50,000 rows
or 5 million cells) flip to the streaming path implicitly with
a one-time RuntimeWarning so users notice and can opt out via
read_only=False.
Streaming-mode workbooks are read-only — cell.value = …,
ws.append(...), wb.create_sheet(...), and wb.save(...) raise.
read_only=True + modify=True raises at load time. Random-access
cell reads (ws["B7"]) raise per openpyxl. Match the openpyxl
contract end-to-end.
Streaming cells expose values + styles (richer than openpyxl's
values_only=True-only mode): iter_rows() yields read-only
StreamingCell proxies with full .font / .fill / .border /
.alignment / .number_format access, backed by the upfront-loaded
styles table for O(1) lookups. iter_rows(values_only=True) yields
plain tuples, matching openpyxl's contract.
Benchmark on a 100k-row × 10-col fixture (single M-series run):
| config | wall (s) |
|---|---|
| openpyxl eager | 3.980 |
| openpyxl read_only | 4.017 |
| wolfxl eager | 1.422 |
| wolfxl read_only | 0.700 |
WolfXL's streaming path is ~5.7× faster than openpyxl
read_only=True on wall time and ~2× faster than wolfxl's
own bulk-FFI eager path.
Pod-β commit: 75de628. RFC: Plans/rfcs/041-streaming-reads.md.
Password-protected reads via msoffcrypto-tool (Sprint Ι Pod-γ, RFC-042)¶
import wolfxl
wb = wolfxl.load_workbook("budget.xlsx", password="hunter2")
# 1.2 raised CalamineError; 1.3 decrypts via msoffcrypto-tool
# and parses the plaintext through CalamineStyledBook.open_bytes.
Add the optional encrypted extra to your install:
pip install 'wolfxl[encrypted]'
# or, if you maintain pinned deps directly:
# msoffcrypto-tool>=5.4,<6
load_workbook(path, password=...) decrypts the .xlsx via
msoffcrypto-tool (lazy-imported only when password= is non-None,
so users on the common unencrypted path pay no import cost) and
dispatches the plaintext bytes through a tempfile to the existing
path-based readers. The tempfile is tracked on the workbook and
removed via Workbook.close().
Modify mode + password works:
load_workbook(path, password=..., modify=True) → mutate → wb.save(out)
emits a plaintext xlsx. Write-side encryption is explicitly out of
scope; passing password= to wb.save() raises
NotImplementedError.
Errors:
- Wrong password →
msoffcrypto.exceptions.InvalidKeyErrorsurfaces as-is. - Missing
encryptedextra →ImportError("password reads require msoffcrypto-tool; install with: pip install wolfxl[encrypted]"). password=on a non-encrypted file → silently ignored (matches openpyxl's behavior).
Pod-γ commit: f0ea2d1. RFC: Plans/rfcs/042-password-reads.md.
Pod-δ — sweep and follow-ups¶
Four small, independent items that round out the release:
- D1 — Parity ratchet re-enabled. Five new fine-grained
KNOWN_GAPS entries land in
tests/parity/openpyxl_surface.pywithwolfxl_supported=False, so thetest_known_gap_still_gapstest now actually pins the open rows. The integrator flips the rich-text / streaming / password rows toTrueas the matching pods land; the.xls/.xlsbrows stay open. Pod-δ commit:751760f. - D2 — Custom pytest marks registered.
rfc035,rfc031,rfc036, andmanualare added topyproject.toml's[tool.pytest.ini_options].markerslist, silencing the recurringPytestUnknownMarkWarningnoise. Pod-δ commit:ce9dda3. - D3 — Native-writer VML margin honors per-column widths.
crates/wolfxl-writer/src/emit/drawings_vml.rs::compute_marginhard-codedCOL_WIDTH_PT = 48.0; sheets with custom column widths rendered comment popups over the wrong cell area. The newcompute_margin_with_widthswalksworksheet.columnsand sums per-column widths in points, mirroring the modify-mode patcher'scompute_margin_with_widths. Empty<cols>falls back to the legacy math so existing fixtures stay byte-stable. Pod-δ commit:92c901d. ClosesPlans/followups/native-writer-vml-margin-fix.md. - D4 —
Workbook.defined_names["X"] = DefinedName(...)shipped. The Python proxy already routed through_pending_defined_names; Pod-δ adds Excel-compliant name validation (no whitespace, no leading digit, not an A1-style ref, not the R/C R1C1 reserved tokens) and fixes the writer payload so sheet-scope names route viascope=sheetplus the resolved sheet name. Pod-δ commit:b64c364. Closes the Phase 1 KNOWN_GAPS row.
Pod-δ also ships RFC-040 / 041 / 042 spec drafts (commit
6bc120c) and this release-notes scaffold (commit 93fe1c7).
The integrator's ratchet flip-up (71d1d4f) moves the three
landed gap rows from wolfxl_supported=False → True as the
matching pods merged.
Breaking changes¶
Cell.value behaviour on rich-text cells (RFC-040)¶
Cell.value continues to return a flat str for cells with
<is>/<si> rich text — this preserves the 1.2 contract
end-to-end. The new Cell.rich_text accessor is the official
path for callers that need the per-run formatting.
If you previously relied on Cell.value flattening rich text to
plain str (i.e. wrote if isinstance(cell.value, str): …),
your code keeps working as-is — Cell.value still returns a
plain str even on rich-text cells. The break only matters for
callers who want to OPT IN to rich-text awareness; those callers
add a cell.rich_text lookup and fall back to cell.value when
it returns None.
If a future release decides to flip Cell.value to return
CellRichText directly on rich-text cells (currently filed as
RFC-040 §11 OQ-1), a Cell.value_str accessor will land in the
same release as a guaranteed-str escape hatch. Until then, no
behaviour change is required for Cell.value consumers.
Migration guide¶
No source changes are required for callers that worked on 1.2 — every Pod-α / β / γ / δ change is additive. Optional adjustments:
- Rich text consumers: replace any "I want runs but had to
re-parse
Cell.value" workaround withCell.rich_text.str(cell.rich_text)matchescell.valuefor the same cell, so callers that previously didcell.valuekeep working until they decide to opt in to runs. - Large-fixture ingest: drop the explicit
pyexcelerate/python-calamineworkaround you may have used to stream huge sheets —wolfxl.load_workbook(path, read_only=True)now matches the openpyxl streaming contract. The auto-trigger means long-running pipelines that ingest mixed file sizes will see oneRuntimeWarningper huge workbook; suppress withwarnings.simplefilter("ignore", RuntimeWarning)or passread_only=Falseto opt out. - Password-protected files:
pip install 'wolfxl[encrypted]'and passpassword=. Existing pipelines that detected the encrypted file via the previousCalamineErrorshould switch to the pre-flightmsoffcrypto.OfficeFile(...).is_encrypted()check or catch the underlyingmsoffcrypto.exceptions.InvalidKeyErrordirectly. - VML comment positioning: if you re-saved a file with
wolfxl.Workbook()(write mode) and noticed comment popups in the wrong place when columns had custom widths, the bug is fixed in 1.3. No action required — the patch ships in the emitter, not the API surface. - Sheet-scope defined names via
wb.defined_names: previouslywb.defined_names["X"] = DefinedName(name="X", value="...", localSheetId=0)round-tripped, but the Rust writer receivedscope=workbookregardless oflocalSheetId, so the saved file did not carry a<definedName localSheetId="0">. 1.3 routes the scope correctly. If you depended on the old silent-workbook-scope behaviour, droplocalSheetId=from the call site.
Known limitations¶
Carry-forward from 1.2:
.xls/.xlsb: still deferred (Phase 5 /tests/parity/KNOWN_GAPS.md). openpyxl itself doesn't read.xlsor.xlsb; closing this gap requires migrating WolfXL fromcalamine-stylesto upstreamcalamine. No timeline yet.copy_worksheetre-saved by openpyxl: as in 1.2, openpyxl's loader is the lossy step on a wolfxl-emitted clone. Stay inside wolfxl until the final save.- Cross-workbook copy (
copy_worksheet(other_wb_sheet)): remains out of scope per RFC-035 §10. openpyxl rejects the same call. - Chart sheets (
<chartsheet>): remain out of scope per RFC-035 §10.
New limitations introduced by 1.3 (deliberately deferred):
- Rich-text writes: RFC-040 ships read-only. Constructing a
CellRichTextand saving is post-1.3 work — see RFC-040 §10. - Streaming writes (openpyxl's
write_only=True): not in scope for RFC-041. Users who need bulk-write performance keep using the eager write path. - Encrypted writes:
wb.save(..., password=...)raises until the post-1.3 follow-up RFC ships CFB-envelope emission.
See tests/parity/KNOWN_GAPS.md for the full per-feature gap list.
Acknowledgments¶
Sprint Ι ("Iota") pods that landed 1.3:
- Pod-α — RFC-040 rich-text reads + writes (round-trip).
381813a - Pod-β — RFC-041 streaming reads (values + styles).
75de628 - Pod-γ — RFC-042 password-protected reads (msoffcrypto-tool).
f0ea2d1 - Pod-δ (this release scaffold) — D1 ratchet (
751760f), D2 pytest marks (ce9dda3), D3 VML margin fix (92c901d), D4defined_names.__setitem__(b64c364), D5 RFC drafts (6bc120c), D6 release-notes scaffold (93fe1c7).
Specs: Plans/rfcs/040-rich-text.md,
Plans/rfcs/041-streaming-reads.md,
Plans/rfcs/042-password-reads.md. Each ships with a §11 Open
Questions block — Pod owners (and the integrator) should resolve
these in the merge PR rather than carrying them into 1.4.
Thanks to everyone who file-bugged the read-side gaps over the 1.0 → 1.2 cycle — every row in the Phase 2 / 3 / 4 KNOWN_GAPS tables came from a real workload that hit the limitation in production.