lzstring-codec
lz-string for Python, held byte-for-byte to the JavaScript original.
Installed as lzstring-codec, imported as lz_string. The obvious name is not available:
PyPI refuses it as too similar to lzstring, which is one of the two packages this exists
to replace.
It exists because the package that is on PyPI gets the format wrong in ways that quietly corrupt real save files. It was written for saveeditor.online, which reads a few thousand lz-string saves a week and cannot afford to hand any of them back altered.
import lz_string as lz
lz.compress_to_base64('{"gold":9000}') # 'N4Ig5g9gNgJiBcBOADKgvkA='
lz.decompress_from_base64(_) # '{"gold":9000}'
Why
lzstring 1.0.4 on PyPI is a line-by-line transliteration of the JavaScript, and it
inherits neither its speed nor its correctness. Measured against 1202 vectors produced by
lz-string 1.5.0 on npm — the library the games themselves use:
- 294 vectors compress to different bytes, because it walks Python code points where
the format is defined over UTF-16 code units. In practice: an emoji in a player's name is
written truncated, and the game reads back
U+F600, a private-use box. Every save with an astral character is corrupted on write. decompressFromUTF16raisesTypeErroron every input. It subtracts an integer from a string. The function has never worked.- Decompression rebuilds a 65-entry alphabet table per character and reads the bit stream one bit at a time: 22 seconds for a 1.8 MB save, on the request path.
This package is byte-identical to the reference on all 1202 vectors, in all four transports, in both directions, on both backends — and 15–45× faster.
How far that was pushed, because "it round-trips" is not the same as "it is right":
| check | scale | result |
|---|---|---|
| golden corpus vs. node | 1202 vectors x 4 transports x 2 directions x 2 backends | identical |
| real production saves | 38 files up to 1.8 MB, decompress and recompress | identical |
| differential fuzz vs. node | 5000 generated inputs x 4 transports x 2 backends | identical |
| malformed-input fuzz | 16000 probes x 2 backends | agree with each other and with the reference; nothing raised |
The last row is the one that found bugs — two of them in this package, both invisible to every check above it, and both now pinned by tests. SPEC.md §4 says what they were.
And it is not the only one: py-lzstring 0.1.1, a separate port of the same reference,
differs from it on 295 of the same 1202 vectors, first on exactly the same astral
character. Two independent Python ports, the same mistake in both.
See SPEC.md for the format itself and for the full divergence table.
Installing
The package is a compiled extension; pip install builds it, and a Rust toolchain is
required to do so.
uv add git+ssh://git@github.com/Derfirm/lz-string.git # or, in a checkout:
uv sync # builds the extension in place
pip install . # pip works too; maturin does the build
An import without the extension raises rather than falling back to something slower and subtly different.
Build it where it will run. The binary is tied to the C library it was linked against:
one built on Debian bookworm (glibc 2.36) will not load on bullseye (2.31), which is what
python:3.12.11-slim-bullseye — the image this package is destined for — is built on. It
fails at import with GLIBC_2.34 not found, not at build time. Either build inside the
target image, or produce a manylinux wheel.
On two real saves, best of three in one run:
| decompress | compress | |
|---|---|---|
| this package, 259 KB → 975 K chars | 0.004 s | 0.044 s |
lzstring 1.0.4, the same |
1.199 s | 0.312 s |
| this package, 1.8 MB → 13.4 M chars | 0.054 s | 1.547 s |
lzstring 1.0.4, the same |
8.581 s | 4.766 s |
Both halves are ours, and the only Rust dependency is pyo3. The obvious alternative,
the lz-str crate, was the starting point and did not
survive contact with damaged input: it answers the same thing for two failures the reference
keeps apart, skips characters the reference reads as zero bits, and refuses a payload whose
trailing padding character was trimmed. SPEC.md §7 has the measurements.
A pure-Python implementation of the whole format lives in src/lz_string/_reference.py.
It is the test suite's second opinion, not a fallback: nothing imports it at runtime.
Migrating from lzstring
The camelCase API is provided as-is, so the change is one import line:
-from lzstring import LZString
+from lz_string import LZString
Two behaviour changes to know about, both improvements, both in SPEC.md: saves containing
astral characters stop being corrupted, and decompressFromUTF16 starts working.
Tests
uv sync --extra dev # .python-version pins 3.12, as in production
uv run pytest # every test runs against both implementations
uv sync --reinstall-package lzstring-codec # after touching the Rust: rebuild it
./tools/check_linux.sh # built and run inside the target image (needs docker)
./tools/check_linux.sh linux/amd64 # and on production's architecture, cross-compiled
The Linux run is not ceremony. It is done in python:3.12.11-slim-bullseye — the exact image
this is destined for — and it has already caught two things a laptop cannot: a test that
measured the machine rather than the code, and an extension that built cleanly and then
refused to load because it wanted a newer glibc than the image has.
251 tests: the golden corpus per category and transport, the documented behaviour on damaged input, seeded round-trips over random code units and surrogate halves, parity between the shipped extension and the Python reference — including a fuzz of malformed payloads, which is what pins the three corrections the crate underneath needed — and two tests that the extension really does let go of the interpreter while it works.
To regenerate the corpus (deterministic — a diff means the reference moved):
npm --prefix tools install
node tools/gen_vectors.mjs
tools/bench.py reproduces the numbers above.
Releasing
By tag, not by merge. Most merges here are documentation and CI, and PyPI refuses a second upload of the same version — publishing on merge would mean either a version bump per commit or a release job that fails as a matter of course.
# bump `version` in pyproject.toml, note it in CHANGELOG.md, merge, then:
git tag v0.1.0 && git push origin v0.1.0
The workflow builds wheels for linux and macOS on both architectures plus an sdist, checks
that the tag and the version in pyproject.toml agree, and only then uploads.
workflow_dispatch runs everything except the upload, which is how the pipeline is tested
without releasing anything.
The upload needs a credential, and there are two ways to give it one. Either works; the first stores nothing.
Trusted publishing. On PyPI, add a pending publisher for the project name with owner
Derfirm, repository lz-string, workflow release.yml, environment pypi. Nothing to
configure here — with no PYPI_API_TOKEN secret set, the publish step already exchanges a
short-lived OIDC token, which is what it tries today and what fails with "Trusted publishing
exchange failure" until the publisher exists.
A token. Create an environment named pypi in the repository settings, put an API token
in it as PYPI_API_TOKEN, and re-run the publish job of the tagged release. Protection
rules on that environment are where you add an approval step if you want one.
Provenance and licence
MIT (LICENSE). Compression comes from the lz-str
crate and the bindings from pyo3, both MIT/Apache-2.0; the decoder is a
port of pieroxy/lz-string, MIT, which is also the
reference every claim here is measured against. See THIRD_PARTY.md.
No user data lives in this repository: every test input is generated, and the corpus is the reference implementation's own output.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distributions
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file lzstring_codec-0.1.0.tar.gz.
File metadata
- Download URL: lzstring_codec-0.1.0.tar.gz
- Upload date:
- Size: 18.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
13039167779a70f3ce67af5b13e9cae86640f0312a7e99c76f60fe774fb4fb3a
|
|
| MD5 |
1ddbd6ddc31a02c79b0fc43bfb4633b8
|
|
| BLAKE2b-256 |
e2809e1e7d989f4c74c109f0cc9b9309673307d5c34b3fe28e48142d26e95216
|
File details
Details for the file lzstring_codec-0.1.0-cp312-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.
File metadata
- Download URL: lzstring_codec-0.1.0-cp312-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- Upload date:
- Size: 230.3 kB
- Tags: CPython 3.12+, manylinux: glibc 2.17+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
39ee9c82f7c807eb20b4b5922c79f06f634753295bc62cbb569aedf69ee1e086
|
|
| MD5 |
7fc3a9086efdf0cc4081b40d8d516553
|
|
| BLAKE2b-256 |
ac22e996362170f6e0bc3f49dca2a1b53f8b543d7c9b83fa5ea21715658b163d
|
File details
Details for the file lzstring_codec-0.1.0-cp312-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.
File metadata
- Download URL: lzstring_codec-0.1.0-cp312-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
- Upload date:
- Size: 229.9 kB
- Tags: CPython 3.12+, manylinux: glibc 2.17+ ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b0be765af22576fb1f7fbc5deca3ff653c965a8b9e88dc8ba7246992064eef90
|
|
| MD5 |
5968a8a6080555f850e0dac8f3f4b51a
|
|
| BLAKE2b-256 |
2de6fe1b4e08f71980116a9c5b4941a50c561abd5bfa0210ca48394c414a1fb6
|
File details
Details for the file lzstring_codec-0.1.0-cp312-abi3-macosx_11_0_arm64.whl.
File metadata
- Download URL: lzstring_codec-0.1.0-cp312-abi3-macosx_11_0_arm64.whl
- Upload date:
- Size: 210.3 kB
- Tags: CPython 3.12+, macOS 11.0+ ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
afeb89a28303d4c989c90d43068650a715a38b176a56cadf022848894d64e125
|
|
| MD5 |
7f8d01e0c71c98ce8733d50d44fb2f4b
|
|
| BLAKE2b-256 |
b1bc5fb084495d424ead97fda06cca60f91a7c3ff7138786a8f23beecb25a7c6
|
File details
Details for the file lzstring_codec-0.1.0-cp312-abi3-macosx_10_12_x86_64.whl.
File metadata
- Download URL: lzstring_codec-0.1.0-cp312-abi3-macosx_10_12_x86_64.whl
- Upload date:
- Size: 218.8 kB
- Tags: CPython 3.12+, macOS 10.12+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
65f415cec4c3471c2cc2d634050177301a4b208f0e794a9c1eff83fc768b67d3
|
|
| MD5 |
cc0ec8545d08f32363e252a65b9da627
|
|
| BLAKE2b-256 |
4b68bee4a6e89c347e8c32fb96c9c6f1467c7611b80749a1b1dc060c7d03402e
|