Pattern: Read-Only Script Filters

A read-only Script Filter returns items for the user to inspect or act on, but the action is performed by Alfred’s built-in widgets (open a URL, open a file) rather than by running additional Python code. The main() function only builds the response; it never writes to disk or makes network calls at action time.

The example workflow contains four read-only Script Filters, each illustrating a distinct sub-pattern.

search-bookmarks: Python-Side Fuzzy Filtering

Keyword: afwf-search-bookmarks

Script: afwf-examples search-bookmarks --query '{query}'

What it does: Maintains a static bookmark list. On every keystroke Alfred passes the current query to Python, which narrows the list with FuzzyItemMatcher and returns the filtered items. Selecting an item opens the URL in the default browser.

# -*- coding: utf-8 -*-

"""
Example: Search Bookmarks
=========================

**What it demonstrates**

Shows how to build a fuzzy-search Script Filter using
:mod:`afwf.opt.fuzzy_item`.  A static list of bookmarks is turned into
:class:`afwf.opt.fuzzy_item.Item` objects; when the user types a query the
list is narrowed with :class:`afwf.opt.fuzzy_item.FuzzyItemMatcher`.  If no
fuzzy match is found the full list is returned so the user always sees
results.  Selecting an item opens the URL in the default browser via the
``open_url`` variable pair.  Type ``error`` as the query to trigger a
simulated error and see how :func:`afwf.log_error` writes a traceback to a
log file.
"""

import afwf.api as afwf
import afwf.opt.fuzzy_item.api as fuzzy_item

BOOKMARKS = [
    ("Alfred App", "https://www.alfredapp.com/"),
    ("Python", "https://www.python.org/"),
    ("GitHub", "https://github.com/"),
    ("Stack Overflow", "https://stackoverflow.com/"),
    ("MDN Web Docs", "https://developer.mozilla.org/"),
    ("PyPI", "https://pypi.org/"),
    ("Read the Docs", "https://readthedocs.org/"),
    ("Hacker News", "https://news.ycombinator.com/"),
    ("Wikipedia", "https://www.wikipedia.org/"),
    ("Google", "https://www.google.com/"),
    ("YouTube", "https://www.youtube.com/"),
    ("Twitter / X", "https://twitter.com/"),
    ("Reddit", "https://www.reddit.com/"),
    ("AWS Console", "https://console.aws.amazon.com/"),
    ("Docker Hub", "https://hub.docker.com/"),
    ("Homebrew", "https://brew.sh/"),
    ("VS Code Docs", "https://code.visualstudio.com/docs"),
    ("Real Python", "https://realpython.com/"),
    ("Anthropic Claude", "https://claude.ai/"),
    ("OpenAI", "https://openai.com/"),
]


@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

Key points:

  • Each item calls item.open_url(url) to set the open_url / open_url_arg variable pair (see Item Variables and Alfred’s Conditional Widget).

  • argumenttype: 1 (required) in info.plist — Alfred always passes the query, even when empty.

  • When no fuzzy match is found, the full list is returned so the pane is never empty.

  • Typing error raises a deliberate exception to demonstrate log_error().

Downstream widgets (from info.plist):

Script Filter ──► Conditional (open_url=y)  ──► Open URL  {var:open_url_arg}
              └──► Conditional (_open_log_file=y) ──► Open File  {var:_open_log_file_path}

open-file: Alfred-Side Filtering via the match Field

Keyword: afwf-open-file

Script: afwf-examples open-file (no query argument)

What it does: Lists every .py file in afwf/examples/ and lets Alfred filter the list interactively as the user types. Selecting an item opens the file with the system default application.

# -*- coding: utf-8 -*-

"""
Example: Open File
==================

**What it demonstrates**

Shows how to build a file-picker Script Filter that lets Alfred's built-in
result filtering do the work.  The handler lists every ``.py`` file in the
``examples/`` directory and attaches ``open_file`` / ``open_file_path``
variables to each item so a downstream *Open File* action can open the
selected file with the system default application.

Because **Alfred filters results** is enabled, the ``main`` function always
returns the full list and Alfred narrows it in real time as the user types —
no Python-side filtering needed.

**Downstream widgets**

1. *Utilities → Conditional* — condition: ``{var:open_file}`` is equal to ``y``
2. *Actions → Open File* — File: ``{var:open_file_path}``
"""

from pathlib import Path
import afwf.api as afwf


@afwf.log_error(log_file=afwf.path_enum.dir_afwf / "open_file.log")
def main() -> afwf.ScriptFilter:
    sf = afwf.ScriptFilter()
    dir_here = Path(__file__).parent
    for p in sorted(dir_here.iterdir(), key=lambda x: x.name):
        if p.suffix.lower() == ".py":
            item = afwf.Item(
                title=p.name,
                subtitle=f"Open {p}",
                autocomplete=p.name,
                match=p.name,
                arg=str(p),
            )
            item.open_file(path=str(p))
            sf.items.append(item)
    return sf

Key points:

  • argumenttype: 2 (no argument) in info.plist — the script receives no query string. It always returns the full file list.

  • Each item sets item.match = p.basename and item.autocomplete = p.basename. Alfred uses the match field for its own client-side filtering, narrowing the displayed list as the user types without re-invoking the script.

  • Each item calls item.open_file(path=p.abspath) to set the open_file / open_file_path variable pair.

When to choose Alfred-side filtering: Use it when the full item list is small, static, or cheap to produce, and you do not need Python-side logic to decide what to show. The script runs once; Alfred handles the rest.

Downstream widgets (from info.plist):

Script Filter ──► Conditional (open_file=y) ──► Open File  {var:open_file_path}

read-file: Conditional Item Display

Keyword: afwf-read-file

Script: afwf-examples read-file (no query argument)

What it does: Reads ~/.alfred-afwf/file.txt and displays its content as a subtitle. If the file does not exist, an error item with the error icon is shown instead.

# -*- coding: utf-8 -*-

"""
Example: Read File
==================

**What it demonstrates**

Shows how to build a Script Filter that reads the content of a file and
displays it as an Alfred item.  This example is designed to work alongside
``write_file.py``: use ``write_file`` to write text into ``file.txt``, then
use ``read_file`` to confirm the content was saved correctly.

If the file does not exist yet, an error item (with the error icon) is shown
instead.
"""

import afwf.api as afwf
from afwf.paths import path_enum

path_file = path_enum.dir_afwf / "file.txt"


@afwf.log_error(log_file=path_enum.dir_afwf / "read_file.log")
def main() -> afwf.ScriptFilter:
    sf = afwf.ScriptFilter()
    if path_file.exists():
        content = path_file.read_text()
        item = afwf.Item(
            title=f"content of {path_file} is",
            subtitle=content,
        )
    else:
        item = afwf.Item(
            title=f"{path_file} does not exist!",
        )
        item.set_icon(afwf.IconFileEnum.error)

    sf.items.append(item)
    return sf

Key points:

  • The handler branches on a file-existence condition and returns a different item in each case — a common pattern for Script Filters that depend on external state that may not yet exist.

  • IconFileEnum() provides bundled icons; IconFileEnum.error gives a consistent visual cue for failure states.

  • This Script Filter is intentionally read-only — it has no downstream action widget. The item is for display only; pressing Enter does nothing meaningful.

  • Designed to work alongside write-file: write something with afwf-write-file, then confirm with afwf-read-file.

memoize: Disk-Cached Computation

Keyword: afwf-memoize

Script: afwf-examples memoize --query '{query}'

What it does: Generates a random integer for the given query key and caches it for 5 seconds. Repeated queries with the same key return the cached value without re-running the function.

# -*- coding: utf-8 -*-

"""
Example: Memoize
================

**What it demonstrates**

Shows how to use :func:`afwf.opt.cache.api.TypedCache.typed_memoize` to cache
expensive function results across Alfred invocations.  The Script Filter
generates a random integer for a given query key and caches it for 5 seconds,
so repeated queries with the same key return the same value until the TTL
expires.  Type ``error`` as the query to trigger a simulated error and see how
:func:`afwf.log_error` writes a traceback to a log file.
"""

import random

import afwf.api as afwf
from afwf.opt.cache.api import TypedCache
from afwf.paths import path_enum

cache = TypedCache(path_enum.dir_afwf / ".cache")


@cache.typed_memoize(tag="memoize", expire=5)
def _get_value(key: str) -> int:
    return random.randint(1, 1000)


@afwf.log_error(log_file=path_enum.dir_afwf / "memoize.log")
def main(query: str) -> afwf.ScriptFilter:
    query = str(query)
    if query.strip() == "error":
        raise ValueError("This is a simulated Python error triggered by query='error'")

    value = _get_value(query)
    sf = afwf.ScriptFilter()
    sf.items.append(afwf.Item(title=f"value is {value}"))
    return sf

Key points:

  • cache = TypedCache(path_enum.dir_afwf / ".cache") is a module-level singleton. It is created once when the module is imported and reused across Alfred invocations (each uvx call is a fresh process, so the cache persists on disk between calls).

  • @cache.typed_memoize(tag="memoize", expire=5) decorates the inner function, not main(). Only the expensive computation is cached; the ScriptFilter assembly runs on every call.

  • This Script Filter has no downstream action widget. It demonstrates caching behaviour, not a user-facing action.

Observing the cache: Call afwf-memoize hello twice within 5 seconds — the integer stays the same. Wait 6 seconds — it changes.