log_error Decorator

log_error() catches any exception raised by the decorated main function, writes a timestamped traceback to a rotating log file, then re-raises the exception unchanged. On the happy path the decorator is fully transparent — the return value and behaviour of the function are identical to the unwrapped version.

Why You Need This

Alfred Script Filters run inside Alfred’s own process. When your Python code raises an exception Alfred silently swallows it — the Drop Down Menu goes blank and there is no traceback visible anywhere. log_error() gives you a persistent, on-disk record of every error so you can open the log file and see exactly which line failed.

Usage

Minimal — write to the default log file ~/.alfred-afwf/error.log:

import afwf.api as afwf

@afwf.log_error()
def main(query: str) -> afwf.ScriptFilter:
    ...

Custom log file — useful when a workflow has multiple Script Filters and you want to keep their error logs separate:

import afwf.api as afwf

@afwf.log_error(log_file="~/.alfred-afwf/search_bookmarks.log")
def main(query: str) -> afwf.ScriptFilter:
    ...

Limit traceback depth — keeps the log compact when call stacks are deep:

import afwf.api as afwf

@afwf.log_error(log_file="~/.alfred-afwf/my_workflow.log", tb_limit=5)
def main(query: str) -> afwf.ScriptFilter:
    ...

Control log rotation — lower max_bytes for tighter disk budgets:

import afwf.api as afwf

@afwf.log_error(
    log_file="~/.alfred-afwf/search_bookmarks.log",
    max_bytes=200_000,
    backup_count=1,
)
def main(query: str) -> afwf.ScriptFilter:
    ...

Log Format

Each exception appends one entry to the log file:

[2026-04-08 10:23:45]
Traceback (most recent call last):
  File ".../search_bookmarks.py", line 33, in main
    raise ValueError("This is a simulated Python error triggered by query='error'")
ValueError: This is a simulated Python error triggered by query='error'
------------------------------------------------------------

The timestamp [YYYY-MM-DD HH:MM:SS] is followed by the full Python traceback, and a 60-character separator line is appended so multiple entries remain easy to tell apart.

Log Rotation

log_error() uses logging.handlers.RotatingFileHandler under the hood. Once the active log file exceeds max_bytes (default 500 000 bytes ≈ 500 KB) it is rotated: the current file is renamed to .1, the previous .1 becomes .2, and so on. Files beyond backup_count (default 2) are deleted automatically. Total disk usage is bounded at max_bytes × (backup_count + 1), roughly 1.5 MB with the defaults:

~/.alfred-afwf/search_bookmarks.log     ← current (newest)
~/.alfred-afwf/search_bookmarks.log.1
~/.alfred-afwf/search_bookmarks.log.2   ← oldest, deleted on next rotation

The handler is thread-safe and initialised lazily — no file I/O occurs on the happy path, so there is no measurable overhead per uvx invocation.

Full Example

The following is the complete afwf/examples/search_bookmarks.py. Passing query="error" deliberately raises an exception so you can verify that the log file is written correctly:

# -*- 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