Skip to main content

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.
  • decompressFromUTF16 raises TypeError on 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

lzstring_codec-0.1.0.tar.gz (18.5 kB view details)

Uploaded Source

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

lzstring_codec-0.1.0-cp312-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (230.3 kB view details)

Uploaded CPython 3.12+manylinux: glibc 2.17+ x86-64

lzstring_codec-0.1.0-cp312-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (229.9 kB view details)

Uploaded CPython 3.12+manylinux: glibc 2.17+ ARM64

lzstring_codec-0.1.0-cp312-abi3-macosx_11_0_arm64.whl (210.3 kB view details)

Uploaded CPython 3.12+macOS 11.0+ ARM64

lzstring_codec-0.1.0-cp312-abi3-macosx_10_12_x86_64.whl (218.8 kB view details)

Uploaded CPython 3.12+macOS 10.12+ x86-64

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

Hashes for lzstring_codec-0.1.0.tar.gz
Algorithm Hash digest
SHA256 13039167779a70f3ce67af5b13e9cae86640f0312a7e99c76f60fe774fb4fb3a
MD5 1ddbd6ddc31a02c79b0fc43bfb4633b8
BLAKE2b-256 e2809e1e7d989f4c74c109f0cc9b9309673307d5c34b3fe28e48142d26e95216

See more details on using hashes here.

File details

Details for the file lzstring_codec-0.1.0-cp312-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for lzstring_codec-0.1.0-cp312-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 39ee9c82f7c807eb20b4b5922c79f06f634753295bc62cbb569aedf69ee1e086
MD5 7fc3a9086efdf0cc4081b40d8d516553
BLAKE2b-256 ac22e996362170f6e0bc3f49dca2a1b53f8b543d7c9b83fa5ea21715658b163d

See more details on using hashes here.

File details

Details for the file lzstring_codec-0.1.0-cp312-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.

File metadata

File hashes

Hashes for lzstring_codec-0.1.0-cp312-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Algorithm Hash digest
SHA256 b0be765af22576fb1f7fbc5deca3ff653c965a8b9e88dc8ba7246992064eef90
MD5 5968a8a6080555f850e0dac8f3f4b51a
BLAKE2b-256 2de6fe1b4e08f71980116a9c5b4941a50c561abd5bfa0210ca48394c414a1fb6

See more details on using hashes here.

File details

Details for the file lzstring_codec-0.1.0-cp312-abi3-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for lzstring_codec-0.1.0-cp312-abi3-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 afeb89a28303d4c989c90d43068650a715a38b176a56cadf022848894d64e125
MD5 7f8d01e0c71c98ce8733d50d44fb2f4b
BLAKE2b-256 b1bc5fb084495d424ead97fda06cca60f91a7c3ff7138786a8f23beecb25a7c6

See more details on using hashes here.

File details

Details for the file lzstring_codec-0.1.0-cp312-abi3-macosx_10_12_x86_64.whl.

File metadata

File hashes

Hashes for lzstring_codec-0.1.0-cp312-abi3-macosx_10_12_x86_64.whl
Algorithm Hash digest
SHA256 65f415cec4c3471c2cc2d634050177301a4b208f0e794a9c1eff83fc768b67d3
MD5 cc0ec8545d08f32363e252a65b9da627
BLAKE2b-256 4b68bee4a6e89c347e8c32fb96c9c6f1467c7611b80749a1b1dc060c7d03402e

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.1.0 This release

5 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