Skip to main content

A pure Python implementation of fzf's fuzzy matching (FuzzyMatchV2) and non-interactive filter mode

Project description

purefzf

CI PyPI

A pure Python implementation of fzf's matching engine: the FuzzyMatchV2 scoring algorithm, the extended-search syntax, and the non-interactive --filter mode.

  • Zero dependencies, zero binaries. pip install purefzf is the whole setup. Works where the fzf binary cannot: Pyodide/WASM, AWS Lambda, locked-down CI sandboxes, agent runtimes. Existing Python packages (pyfzf, iterfzf) are wrappers that spawn the fzf binary at runtime.
  • Verified against fzf, not "inspired by" it. The scoring model is ported line-by-line from fzf v0.73.1 (commit ce4bef75) and checked three ways (see Verification):
    • fzf's own unit tests for the algorithm, pattern parser, and tokenizer, ported to pytest — 193 tests, all passing;
    • differential testing against the real fzf binary — 5,237 of 5,240 cases (99.94%) byte-identical output, the remaining 3 traced to an fzf bug, not a porting gap (details);
    • 6,000 randomized cross-checks between the optimized ASCII fast path and the direct port of the Go code.
  • CPython 3.9–3.14 and PyPy, tested in CI.

Usage

CLI

purefzf is fzf --filter: it reads lines from stdin and prints the matches in fzf's ranking order.

$ git ls-files | purefzf -f 'tests py$'
tests/test_party.py
tests/test_display.py
$ history | purefzf -f '!sudo git' --tac | head -5

Supported flags (all with fzf semantics, including exit codes 0/1/2): -f/--filter, -e/--exact, +e, -x/--extended, +x, -i/--ignore-case, +i, --smart-case, --literal, --algo=v1|v2, --scheme=default|path|history, -n/--nth, -d/--delimiter, --tiebreak, +s/--no-sort, --tac, --read0, --print0, --print-query.

Library

import purefzf

# the equivalent of `fzf --filter='qry'`: matched lines in fzf order
purefzf.filter("qry", ["query.py", "quarry.rs", "manifest.json"])
# -> ['query.py', 'quarry.rs']

# same options as the CLI
purefzf.filter("conf", lines, nth="2..", delimiter=":", tiebreak="begin")

# Match objects with score, original index, and matched positions
for m in purefzf.matches("qry", lines, with_positions=True):
    print(m.score, m.text, m.positions)

# low-level: score a single candidate
# (case_sensitive, normalize, forward, text, pattern, with_pos)
result, positions = purefzf.fuzzy_match_v2(
    False, True, True, "src/QueryBuilder.py", "qry", True)
result     # (start, end, score) -> (4, 9, 66)
positions  # [8, 7, 4]

The extended-search syntax works exactly as in fzf: term1 term2 (AND), a | b (OR), 'exact, 'boundary', ^prefix, suffix$, ^equal$, !negation, smart-case, and latin-script normalization (danco matches Danço; disable with --literal).

Scope

purefzf implements the matching engine and filter mode, not the interactive terminal UI. Not included (today): the TUI, previews, key bindings, --with-nth/--accept-nth output transforms, ANSI color processing, and multi-threaded matching. The algorithm layer is complete, so a TUI built on top of it only needs a terminal frontend.

Verification

All three layers run in CI on every commit; the differential layer runs on any machine that has an fzf binary (development-time only — purefzf itself never shells out).

  1. fzf's official unit tests, ported. Every assertion from algo_test.go, pattern_test.go, and tokenizer_test.go at v0.73.1 is reproduced in tests/ with the same inputs and expected scores. The chunk-cache tests are intentionally out of scope (the cache is an interactive-typing optimization, not part of matching semantics).

    $ python -m pytest        # 193 passed
    
  2. Differential testing against the fzf binary. tools/diff_fzf.py compares purefzf --filter with fzf --filter byte by byte across 8 corpora (dictionary words, file paths, source code, structured fields, unicode, empty/whitespace/200KB lines, invalid UTF-8) × 30 option sets × up to 37 queries:

    $ FZF_BIN=$(which fzf) python tools/diff_fzf.py
    5237/5240 byte-identical (99.94%), 3 known divergences (fzf slab reuse, see README), 0 unexplained
    
  3. Fast-path cross-checking. The optimized ASCII path must produce bit-identical results (score, offsets, positions) to the direct port of the Go code on 6,000 randomized inputs per run.

Known differences

The complete list — anything not listed here that differs from fzf --filter is a bug, please report it:

  1. fzf's position backtrace reads recycled memory; purefzf doesn't. fzf reuses per-worker score buffers ("slabs") without zeroing, and the preferMatch heuristic in FuzzyMatchV2's backtrace can read cells the current match never wrote. With --tiebreak=chunk or --scheme=path/--tiebreak=pathname (the modes that depend on exact match positions), the same line can therefore rank differently in fzf depending on what the worker processed before it — feed fzf one stream containing a duplicated line and the two copies can come out ranked apart. purefzf behaves like fzf with a freshly zeroed slab: on isolated input the two agree exactly (this is how the 3 differential failures above were verified).
  2. Regex delimiters use Python re, not Go RE2. --delimiter accepts a regex when it isn't a literal string; the two dialects agree on everything typical ([0-9]+, \s+, \t) but are not identical at the edges (e.g. RE2 has no backreferences, re has no \p{...}).
  3. --tiebreak=pathname offsets count runes, not bytes. fzf compares a byte offset with a rune offset when locating the last path separator; for non-ASCII paths purefzf uses rune offsets consistently.

Performance

On selective queries — the typical case for an agent or a script — purefzf 0.2.0 is in the same league as the fzf binary itself: a query like zsh over the 235k-word dictionary takes 12 ms in-process vs 11 ms for spawning fzf, and a no-match query is faster than the spawn. The trick is a bulk prefilter: the corpus is joined once and scanned with a single backtrack-free regex (plus a memchr-speed pass over the rarest pattern character), so per-line Python work only happens on candidate lines. Match-heavy queries are bounded by the scoring DP and stay 10–20× slower than Go.

Experiment: 0.1.0 → 0.2.0

Both versions run in separate subprocesses on the same corpora (0.1.0 installed from PyPI, 0.2.0 from the working tree); for every workload the two outputs are first verified identical (md5), then each version is timed as the median of 7 runs, repeated in 3 independent rounds — the table shows the median round with min–max spread across rounds. The full matrix was run twice end-to-end on an idle machine; both runs agreed within 6%. Environment: Apple M4 Pro, macOS 26.5, CPython 3.12.7, fzf 0.73.1 as the end-to-end reference (its column includes process spawn; the purefzf columns are in-process library calls — that is the embedding scenario purefzf exists for).

workload matches 0.1.0 ms 0.2.0 ms speedup fzf e2e
235k words, zsh 62 73 (73–73) 12 (12–13) 5.9× 11 ms
235k words, ion 16,443 144 (143–144) 90 (89–93) 1.6× 21 ms
235k words, q 3,641 77 (76–77) 13 (13–14) 5.7× 13 ms
235k words, 'tion 7,422 176 (175–177) 31 (31–32) 5.7× 16 ms
235k words, ^ab 666 160 (160–161) 17 (17–17) 9.2× 12 ms
235k words, ing$ 5,540 181 (181–183) 24 (24–25) 7.5× 15 ms
235k words, foo bar 16 255 (255–256) 20 (20–20) 12.6× 13 ms
235k words, ^ab cd$ | ing$ 5 399 (398–400) 24 (24–24) 16.7× 13 ms
235k words, aBc (no match) 0 80 (79–80) 8 (8–8) 9.6× 12 ms
108k paths, test 104,348 1111 (1105–1138) 1028 (1016–1034) 1.1× 73 ms
108k paths, pyini 60,575 610 (606–612) 560 (557–563) 1.1× 49 ms
108k paths, lib/site 107,156 1822 (1814–1836) 1726 (1722–1740) 1.1× 82 ms
6k unicode, danco 3,000 17 (17–17) 18 (18–18) 0.96× 6 ms
100k fields, user1 group3 + --nth=1,2 5,029 509 (507–510) 503 (503–505) 1.0× 18 ms

Raw data for both end-to-end runs: bench/0.2.0-vs-0.1.0.json, bench/0.2.0-vs-0.1.0-run2.json. Reproduce with:

$ python -m venv /tmp/v && /tmp/v/bin/pip install purefzf==0.1.0
$ FZF_BIN=$(which fzf) python tools/bench_ab.py --a /tmp/v/bin/python --a-name 0.1.0
$ FZF_BIN=$(which fzf) python tools/bench.py   # lib vs CLI vs fzf end-to-end

What the numbers say:

  • Selective and multi-term queries got 5.7–16.7× faster. The bulk prefilter rejects non-candidate lines at C speed, so cost now scales with the result size more than the corpus size. Fuzzy terms compile to an anchored greedy chain (^[^\na]*a[^\nb]*b…) whose every step has exactly one outcome — linear worst case by construction (a 200KB degenerate line is a regression test).
  • Match-heavy queries improved only 6–9%. When 96% of 108k lines match, the time goes to the Smith-Waterman scoring of every line; that is the algorithm, not overhead. This is the workload where the Go binary keeps its 10–20× lead, and on CPython we consider it the floor — further gains would need C extensions or NumPy, which would break the pure-Python contract. (PyPy is supported and CI-tested if you need more.)
  • Small corpora are unchanged by design (the prefilter only engages at ≥4,096 lines; below that the per-line path is already sub-ms), and --nth queries bypass the prefilter because field reordering invalidates per-line necessary conditions.
  • The CLI adds ~25 ms of interpreter startup over the library numbers; fzf e2e includes its own ~5 ms process spawn.

How the port stays faithful

The matcher is ported function-by-function from src/algo/algo.go, pattern.go, tokenizer.go, and result.go at fzf v0.73.1, including the parts that are easy to get subtly wrong: the bonus matrix and scheme tables, camelCase/number boundary bonuses, the consecutive-chunk bonus rules, first-char multiplier, the --no-sort --tac sorting quirk, the streaming vs. collected output paths, smart-case and per-term normalization decisions, the exact V2→V1 fallback thresholds (N×M > 102400 or M > 1000), tiebreak rank encoding, and Go's per-byte U+FFFD substitution for invalid UTF-8.

License

MIT. The algorithm and its test suite are ported from junegunn/fzf (MIT licensed) — all credit for the matching algorithm's design belongs there.

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

purefzf-0.2.0.tar.gz (44.1 kB view details)

Uploaded Source

Built Distribution

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

purefzf-0.2.0-py3-none-any.whl (33.2 kB view details)

Uploaded Python 3

File details

Details for the file purefzf-0.2.0.tar.gz.

File metadata

  • Download URL: purefzf-0.2.0.tar.gz
  • Upload date:
  • Size: 44.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for purefzf-0.2.0.tar.gz
Algorithm Hash digest
SHA256 c486d9e3058a633d8d2400d41252758de59a859e7423682de05e3514015edd4b
MD5 634b60c5c91e6d4b43a821c4a40d18fb
BLAKE2b-256 f7fc0593a639f86b4da902b97010e051498184d377804ae6022e58db19fd4eb6

See more details on using hashes here.

Provenance

The following attestation bundles were made for purefzf-0.2.0.tar.gz:

Publisher: release.yml on adam2go/purefzf

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

File details

Details for the file purefzf-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: purefzf-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 33.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for purefzf-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 93907b06261f4080cbc84c3c1c3bda7000b47e6e6d1dcb5b7202efe5a72b5180
MD5 eae774c0ba49b4fd60c2cd2ba4509fe0
BLAKE2b-256 9c4deb807119c7a74e7a492466736a5bf600ed4785d0c79b05975e111fbcaa93

See more details on using hashes here.

Provenance

The following attestation bundles were made for purefzf-0.2.0-py3-none-any.whl:

Publisher: release.yml on adam2go/purefzf

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