Skip to main content

LexNeedle

Deterministic Unicode-aware dictionary matching for Python 3.14+, with zero runtime dependencies.

Installation

Install the latest published release from PyPI:

uv add lexneedle

To try the unreleased main branch instead:

uv add "git+https://github.com/crootman/LexNeedle"

Quick start

from lexneedle import Matcher

matcher = Matcher()
matcher.add("Panadol", value="paracetamol", metadata={"type": "brand"})

for match in matcher.find("Panadol 500mg tablets"):
    print(match.text, match.value, match.start, match.end)

Match.start and Match.end always index the original Python string, so text[match.start:match.end] == match.text.

Matcher is case-insensitive by default using locale-independent Unicode casefold(). It supports None, NFC, NFD, NFKC, and NFKD normalization; normalization occurs before case folding and preserves source offsets. A reported match must cover whole normalization contribution groups and its source slice must re-transform to the exact transformed match. For example, NFC composes á in "a\u0334\u0301", but the composed character covers all three source code points, so that candidate is intentionally not reported. This keeps offsets and re-matching self-consistent. The default boundary="word" treats Unicode letters, marks, numbers, connector punctuation, and joiners as word characters. It checks the exterior character before and after a candidate independently, so punctuation inside a registered term cannot make an adjacent word character look like a boundary. It is not a full UAX #29 word segmenter. Use boundary="none" for substring matching, a legacy callable receiving (text, position), or side_boundary= with (text, position, "left" | "right") for a side-aware word policy.

The default leftmost_longest strategy chooses the earliest match, then the longest at that position, and skips overlaps. all returns all candidates in source order (longer before shorter at the same offset); longest keeps the longest candidate at each start, including overlaps. Terms that collide after normalization/case folding are rejected, ensuring results never depend on insertion order. global_longest greedily selects longest candidates across the complete input, resolving equal lengths by source position and keyword, then returns the non-overlapping selection in source order. Its selection pass compares each candidate with every already selected match, so it can have quadratic selection work on inputs with many candidates; use leftmost_longest for high-density workloads because it avoids materializing every candidate.

Set whitespace_equivalent=True to make every non-empty run of Unicode whitespace in a term or input match one separator. It is opt-in and preserves the original input slice and offsets; for example, "New York" can match "New\u00a0\tYork". replace_with_matches() returns the replacement result and the selected Match objects without a second scan.

Aliases can share canonical data:

matcher.add(["Panadol", "Panadol Osteo"], value="paracetamol")

replace() uses leftmost-longest matching by default (or global_longest if configured). Values must be strings unless a replacement callable explicitly converts a structured value. save() and Matcher.load() use validated, versioned JSON and reject values JSON cannot represent losslessly. Saving writes a temporary file beside the resolved target and then atomically replaces it, so a write failure does not truncate an existing file. Saving through a symlink updates its target and preserves an existing target's POSIX mode bits; a new file uses mode 0o600 on POSIX, subject to the process umask. Saving does not promise ownership or ACL preservation, and it is not a power-loss durability guarantee; a crash between writing the temporary file and replacing the destination can leave a .lexneedle-*.tmp file beside it. Metadata is copied shallowly and exposed as a read-only outer mapping.

For FlashText migrations, lexneedle.compat.flashtext.KeywordProcessor supports the common keyword add, extract, replace, remove, and lookup methods. Its boundary="none" option enables CJK/substring matching; pass strategy="all" (or per-call extract_keywords(..., strategy="all")) to retain overlaps; pass whitespace_equivalent=True to match Unicode whitespace runs as one separator.

matches_by_document = list(matcher.find_many(["Panadol", "none"]))
matcher = Matcher(boundary=lambda text, position: position in {0, len(text)})
matcher.add("ID-7", value={"id": 7})
assert matcher.replace("ID-7", lambda match: str(match.value["id"])) == "7"
persistent = Matcher()
persistent.add("Panadol", value="paracetamol")
persistent.save("terms.json")
restored = Matcher.load("terms.json")

Empty terms and transformed collisions raise ConfigurationError. Malformed JSON, unsupported values, duplicate fields, and non-finite numbers raise SerializationError. Membership, lookup, and removal use normalized identity. items() yields (keyword, value) pairs and iteration yields keywords, both in sorted order, so enumeration never depends on insertion order. The compatibility facade intentionally omits FlashText fuzzy search, mapping operators, and mutable non-word-boundary sets. It uses LexNeedle's casefolding, Unicode boundaries, transformed-collision rejection, and structured internals; these differ from FlashText's ASCII boundary and lower() behavior.

Matching work is proportional to scanned candidate paths, rather than a promise of a strict complexity bound. finditer() constructs the aligned transformed input first, then streams leftmost-longest candidates; it does not provide full-input streaming or a bounded-memory guarantee. Offsets count Python code points. The matcher protects common combining, spacing-mark, variation-selector, and ZWJ edges by not reporting candidates that would split them, but it does not claim full Unicode grapheme segmentation.

Benchmarks

Run uv run pytest benchmarks/test_extract.py --benchmark-only for the controlled extraction comparison, or uv run pytest benchmarks/test_matrix.py --benchmark-only for the full matrix. The fast matrix includes 1 KiB and 100 KiB input texts and 10-, 100-, and 1,000-term dictionaries. Set LEXNEEDLE_BENCHMARK_LARGE=1 to add 10,000- and 100,000-term dictionaries and 1 MiB and 10 MiB inputs.

The matrix separately measures construction, varied dictionaries, no/few/many match density, shared prefixes, hundreds-character hits and near misses, overlap strategies, finditer() consumption, replacement, case policy, normalization, isolated alignment transforms, Unicode scripts, serialization, and retained/peak memory. It records configuration and input details in pytest-benchmark extra_info. Memory is measured in a separate traced pass, so timed benchmark calls remain untraced. Use pytest-benchmark's --benchmark-json results.json to save the results.

Use uv run --with flashtext pytest benchmarks/test_extract.py --benchmark-only to include FlashText. The regex and FlashText tests use a controlled ASCII workload with equivalent case-insensitive word-boundary results; they do not establish general Unicode equivalence. No speed claim is made. See the architecture notes.

Limits

Version 0.1.0 intentionally does not implement full-input streaming, frozen concurrent matchers, Aho-Corasick failure links, or memory-optimized compiled dictionaries. Matchers are not synchronized: do not mutate one while another thread reads it; concurrent reads of an unchanged matcher are safe. Benchmarks are supplied as pytest-benchmark workloads; they make no speed claim. For large inputs, process the complete text when possible. Independently matching arbitrary chunks can miss terms crossing a chunk boundary. There is no generally safe fixed overlap when normalization or whitespace equivalence is enabled: a normalized term can cover a longer source sequence and a collapsed whitespace run has unbounded source length. Callers that chunk input must retain enough original text for their own constraints and deduplicate reported spans; true streaming remains unsupported. JSON is the safe, versioned persistence format. Standard-library pickle is supported only for trusted local process transfer; all values, metadata, and custom policies must themselves be pickleable.

Metadata

Release files for lexneedle 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for lexneedle 0.1.0
File Size Uploaded
lexneedle-0.1.0.tar.gz 17.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for lexneedle 0.1.0
File Interpreter ABI Platform
lexneedle-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 40.0 kB

Release files / lexneedle-0.1.0.tar.gz

Download URL lexneedle-0.1.0.tar.gz
Size 17.8 kB
Tags Source
SHA-256 checksum
How to use checksums
8a898d78ec7689b3d34d001744fc73855104466b71d0e1bc40df182ade1a52a7
BLAKE2b-256 checksum
How to use checksums
9c87c23133a7e3bd602d51094d154246c9d04ebdef42ba4f61d409e1eafa6c93
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.17 {"installer":{"name":"uv","version":"0.12.17","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / lexneedle-0.1.0-py3-none-any.whl

Download URL lexneedle-0.1.0-py3-none-any.whl
Size 22.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
6a184b36a925552a2b5cad491d49140491bbbcc4ea98dd8fb6e206632e91c0c9
BLAKE2b-256 checksum
How to use checksums
667458f67b6b7be2ef1151c6f0d5d67a3f5277a3d2c7c39875a172153a794511
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.17 {"installer":{"name":"uv","version":"0.12.17","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

0.1.1

2 release files

This release

0.1.0 This release

2 release 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