Fuzzy Matching: opt.fuzzy and opt.fuzzy_item

afwf ships two optional modules for narrowing a list of items by fuzzy string similarity. They are layered: opt.fuzzy provides a generic matcher for any Python type; opt.fuzzy_item specialises it for Item.

Both require the afwf[fuzzy] extra (rapidfuzz >= 3.0.0).

opt.fuzzy — Generic Fuzzy Matching

FuzzyMatcher is a generic dataclass over an arbitrary item type T. It builds an internal name→items map at construction time and exposes a single match() method.

Subclassing

To use it you must subclass and implement get_name():

import dataclasses
from afwf.opt.fuzzy.api import FuzzyMatcher

@dataclasses.dataclass
class Bookmark:
    title: str
    url: str

class BookmarkMatcher(FuzzyMatcher[Bookmark]):
    def get_name(self, item: Bookmark) -> str | None:
        return item.title   # the string that gets fuzzy-matched

bookmarks = [
    Bookmark("Alfred App", "https://www.alfredapp.com/"),
    Bookmark("Python Docs", "https://docs.python.org/"),
]
matcher = BookmarkMatcher.from_items(bookmarks)
results = matcher.match("alfred", threshold=0)
# → [Bookmark("Alfred App", ...)]

If get_name() returns None for an item, that item is silently excluded from the match index — useful for conditionally hiding items.

Factory methods

Factory

When to use

from_items(items)

You have a flat list; get_name extracts the match key

from_mapper(name_to_item_mapper)

You already have a {name: [items]} dict; skips the get_name loop

match() parameters

Parameter

Default

Meaning

name

The search string to match against

threshold

70

Minimum similarity score (0–100). Results below this are discarded. Use threshold=0 to return everything sorted by score.

limit

20

Maximum number of results to return

filter_func

lambda x: True

Extra callable applied after score filtering. Receives a (name, score, index) tuple — the raw rapidfuzz result row.

Results are sorted by score, highest first. If the best match falls below threshold, an empty list is returned immediately.

Duplicate names

Multiple items can share the same name. They are stored together under one key in the internal mapper and all returned when that name matches.

opt.fuzzy_item — Fuzzy Matching for Alfred Items

FuzzyItemMatcher is a ready-made subclass of FuzzyMatcher for Item objects. It does not require you to subclass anything — the match name is stored directly on the item.

Item.set_fuzzy_match_name()

The companion Item (a thin subclass of the core Item) stores the match name in item.variables["fuzzy_match_name"]. Storing it in variables means the name travels with the item through Alfred’s variable inheritance mechanism:

import afwf.opt.fuzzy_item.api as fuzzy_item

item = fuzzy_item.Item(title="Alfred App", subtitle="https://www.alfredapp.com/")
item.set_fuzzy_match_name("Alfred App")
# item.variables == {"fuzzy_match_name": "Alfred App"}

item.fuzzy_match_name   # → "Alfred App"  (read-back property)

FuzzyItemMatcher

matcher = fuzzy_item.FuzzyItemMatcher.from_items(items)
matched = matcher.match("alfred", threshold=0)

FuzzyItemMatcher.get_name() simply reads item.variables.get("fuzzy_match_name"), so items that have not called set_fuzzy_match_name() are silently excluded from matching.

The Standard Script Filter Pattern

Almost every fuzzy Script Filter in afwf.examples follows this pattern:

  1. Build the full item list unconditionally.

  2. If the query is non-empty, run the matcher.

  3. Fall back to the full list when there are no matches — the user always sees something.

@afwf.log_error(
    log_file=afwf.path_enum.dir_home.joinpath(
        ".alfred-afwf/search_bookmarks.log"
    ),  # or just @log_error()
)
def main(query: str) -> afwf.ScriptFilter:
    if query.strip() == "error":
        raise ValueError("This is a simulated Python error triggered by query='error'")

    items = []
    for title, url in BOOKMARKS:
        item = fuzzy_item.Item(title=title, subtitle=url, arg=url)
        item.set_fuzzy_match_name(title)
        item.open_url(url)
        items.append(item)

    if query.strip():
        matcher = fuzzy_item.FuzzyItemMatcher.from_items(items)
        matched = matcher.match(query, threshold=0)
        result_items = matched if matched else items
    else:
        result_items = items

    sf = afwf.ScriptFilter()
    sf.items.extend(result_items)
    return sf

The fall-back to items on no match (matched if matched else items) is intentional: an empty result pane is confusing. Showing the full list lets the user see what is available even when their query does not hit anything.

When to Use opt.fuzzy vs opt.fuzzy_item

Use opt.fuzzy_item when your items are already Item objects destined for Alfred — it requires the least boilerplate.

Use opt.fuzzy (base class) when you are matching over a domain type that is not an Item — for example, a list of database records or file metadata objects — and you want to keep the matching logic decoupled from the Alfred presentation layer.

Installation

pip install "afwf[fuzzy]"
# or with uv:
uv add "afwf[fuzzy]"