Skip to main content

Fuzzy search over records — a behavioural Python port of fuse.js.

Project description

pyfuse

A Python port of fuse.js, the fuzzy-search library, originally built for Port Mortem 2026 (Track H, Open Pair: TypeScript → Python). The main goal was behavioural equivalence with the original, not a lookalike API.


What it is

fuse.js isn't a string-similarity function. It's a small search engine: you give it a list of records, it gives you ranked matches, and it tolerates typos along the way. Under the hood that's a modified Bitap scan plus weighted keys, nested-path lookups, extended-query operators (=exact, ^prefix, !inverse, and friends), $and/$or composition, token search with IDF ranking, and a scoring pass to tie it together.

This is a real re-implementation of that engine in Python, not a wrapper. The package has zero runtime dependencies and there is no Node anywhere in it. fuse.js does show up in this repo, but only as a test oracle — something to diff against.

Why Python needs this

Python's fuzzy-matching story is good if you're comparing two strings. rapidfuzz and thefuzz are fast and well maintained. It falls apart the moment you want to search a collection: weighted fields, nested paths, query operators, relevance ranking. At that point your options are to write scoring by hand or to stand up Elasticsearch, and neither is proportionate to "find the right row in these 5,000 records."

fuse.js is exactly the missing middle, and it only runs in JavaScript — an ecosystem that most data and backend work has already left. So the capability exists, it's just on the wrong side of a language boundary. Moving it is the whole point of this port.

Install

pip install pyfusejs      # the distribution
from pyfuse import Fuse   # the import

The two names differ because pyfuse was already registered on PyPI. This is ordinary — python-dateutil imports as dateutil, beautifulsoup4 as bs4 — but it is worth stating once rather than leaving you to discover it.

Python 3.10+. No runtime dependencies: pyfusejs is the only thing that lands in your environment.

Quick start

from pyfuse import Fuse

books = [
    {"title": "Old Man's War", "author": "John Scalzi"},
    {"title": "The Lock Artist", "author": "Steve Hamilton"},
]

fuse = Fuse(books, {"keys": ["title", "author"], "include_score": True})
for result in fuse.search("old man"):
    print(result.item["title"], result.score)

Options are snake_case the way Python expects. The camelCase spellings are accepted too, so a config file shared with a JS codebase works as-is.

Try it without installing anything

→ Open the live playground

The port is pure Python with zero dependencies, which means it runs unmodified in the browser under Pyodide. That page installs the actual wheel — the same one you'd pip install — and searches with it. Type a query, misspell it on purpose, drag the threshold, edit the corpus. Nothing is sent anywhere; it all runs in your tab.

There's also a guided tour in the terminal:

just demo                  # eight scenes, each printing the code it runs
just demo --interactive    # ...then a live search prompt
just playground            # serve the browser playground locally

Worked examples

Three scripts in examples/ that use the port to solve a real problem rather than to show off an API. Each documents the option choices it made and why, and the numbers in the commentary were measured, not guessed.

What it does
ticket_triage.py Routes messy free-text support tickets to a runbook, with cutoffs for auto-route / suggest / escalate. Shows why long queries need token search, and why IDF misleads on a small corpus.
reconcile.py Fuzzy-joins two record sets after a migration drops the key — ACME CORPORATION LTD vs Acme Corp. Limited. Reports matched, needs-review, and missing in both directions.
search_json.py A general CLI: fuzzy-search any JSON or JSONL file, with inferred or explicit keys, extended operators and highlighted output.
python examples/ticket_triage.py
python examples/reconcile.py
python examples/search_json.py tests/original/test/fixtures/books.json "ste ham"

Build and run

One command:

just build      # install + verify it imports

Or, if you'd rather not have Python locally:

docker build -t pyfuse . && docker run --rm pyfuse

The rest:

just test       # the ported pytest suite (417 tests)
just compat     # the ORIGINAL fuse.js vitest suite, run against this port
just diff       # differential checks against the fuse.js oracle
just fuzz       # 60s differential fuzz -> fuzz/log.txt
just bench      # benchmark vs fuse.js -> bench/results.json
just check      # ruff + mypy --strict
just unsafe     # escape-hatch census

The original fuse.js suite runs against this port

285 of 297 pass (95.96%), with every test file byte-for-byte unmodified.

The suite is JavaScript, run by vitest, and the rules say don't touch it. The way through turned out to be small: all 13 behavioural spec files import the engine from the same specifier, '../dist/fuse.mjs'. Point a vitest alias at a shim that speaks the fuse.js API and forwards every call to Python, and the tests run unchanged without ever knowing what's underneath.

just compat     # see compat/README.md for how it works

None of the 12 failures is a bug in the port. Ten of them hand fuse.js a JavaScript function (sortFn, getFn, a callable tokenize, Fuse.use), and a closure over a live JS heap cannot be serialised into Python at any price. The other two are divergences this port chose on purpose and wrote down (DECISIONS.md §13 and §19) — those tests failing is the documentation being true. All twelve are named individually in compat/README.md, and the run output is committed as compat/results.txt.

What "equivalent" actually means here

Structure matches exactly. Result sets, ordering, match indices, keys, refIndex. A 60-second differential fuzz run against the live fuse.js oracle:

cases                  : 51,569  (859/s)
STRUCTURAL DIVERGENCES : 0

Scores agree to about 1e-13 relative, not to the bit. This one isn't a shortcut, it's a wall. CPython and V8 ship different pow and log: CPython's are correctly rounded, V8's are not, and on roughly 10% of the calls the scorer makes they disagree by 1 ULP. Scoring does one pow per matched key, so that error rides through the whole chain. Worst case measured across 45k fuzz cases was 8.5e-14 relative.

And yes, that has one visible consequence. 1 ULP is enough to break an exact score tie, which means the tie-break fires in one engine and not the other, and the two return the same documents in a different order. Under a limit, a different document can make the cut. It happened in 8 of 51,569 cases (0.016%). fuzz/log.txt reports it as its own category instead of burying it in the score-noise bucket — I'd claimed earlier that ordering was never affected, and the fuzz run is what proved me wrong.

The full write-up, including the ~200-line fdlibm Math.pow transcription that got to 95.6% bit-exact and why chasing the last 4.4% was the wrong use of the remaining time, is in DECISIONS.md.

Performance

fuse.js is about 13x faster. That was always going to happen: V8 JITs the Bitap inner loop and CPython interprets it. What this port buys you is reach and parity, not speed, and it would be dishonest to present it otherwise.

pyfuse fuse.js
throughput 7.0 searches/s 92.8 searches/s
latency p50 114.9 ms 9.0 ms
latency p99 637.0 ms 49.8 ms
startup 13.7 ms 7.7 ms
peak RSS 31.3 MiB 53.7 MiB

400 documents, 4 keys, 150 queries, both engines warmed up first. The method, the confounders, and why you shouldn't read too much into the RSS number are all in bench/methodology.md.

Engineering notes

  • Zero runtime dependencies, same as fuse.js itself.
  • mypy --strict clean, and zero cast, type: ignore, eval/exec or bare except anywhere in src/. just unsafe counts them if you don't believe it.
  • 417 native tests, plus 285 of the original suite's 297. Some of the native ones are property-based (hypothesis) and run against the live oracle rather than against fixed expectations.
  • Anything that diverges is in DECISIONS.md with the evidence that led there.

Scope

Ported in full: the Bitap core, indexing and key weighting, scoring, extended search, logical queries, token search with IDF ranking. About 3,283 lines.

Left out: src/workers/*, the Web Worker and worker-thread plumbing. It's JS-runtime machinery rather than search behaviour, and the reasoning is in DECISIONS.md §17.

Attribution and license

Ported from fuse.js (krisk/Fuse), © Kirollos Risk, Apache-2.0. This port is Apache-2.0 as well. See NOTICE.

Pinned source commit: 45bac9f (tag v7.5.0).

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pyfusejs-7.5.0.tar.gz (91.4 kB view details)

Uploaded Source

Built Distribution

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

pyfusejs-7.5.0-py3-none-any.whl (72.6 kB view details)

Uploaded Python 3

File details

Details for the file pyfusejs-7.5.0.tar.gz.

File metadata

  • Download URL: pyfusejs-7.5.0.tar.gz
  • Upload date:
  • Size: 91.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for pyfusejs-7.5.0.tar.gz
Algorithm Hash digest
SHA256 f8765926dfacabba2279f53a89b5106ac36ae4def777a4380045bafc167417a8
MD5 8ecb80a382d440e3c85fb3e5c1dfba44
BLAKE2b-256 0fed01aab14b604b140c9d96711b604742ee64773eb421d73d3c228e2e0a4110

See more details on using hashes here.

Provenance

The following attestation bundles were made for pyfusejs-7.5.0.tar.gz:

Publisher: publish.yml on ayanbag/pyfuse

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pyfusejs-7.5.0-py3-none-any.whl.

File metadata

  • Download URL: pyfusejs-7.5.0-py3-none-any.whl
  • Upload date:
  • Size: 72.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for pyfusejs-7.5.0-py3-none-any.whl
Algorithm Hash digest
SHA256 273d635697e6ef7d63ded8b36d5f4509f2b5e3380129d0d86d26b4081bf7e2c5
MD5 fee60b9c1a161fc6dc61034a5061c1ec
BLAKE2b-256 a93610072a7fddc3a823c7bfee040161c2f1d3f7463bd715c8062236fb878859

See more details on using hashes here.

Provenance

The following attestation bundles were made for pyfusejs-7.5.0-py3-none-any.whl:

Publisher: publish.yml on ayanbag/pyfuse

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page