ScriptFilterObject: Serialization Rules¶
Every object in an Alfred Script Filter JSON payload — Item,
Icon, Text, ScriptFilter
— inherits from ScriptFilterObject.
The base class provides a single method: to_script_filter().
This method serialises the object to a plain dict that Alfred can consume.
It exists because Alfred’s JSON protocol differs from standard Python serialisation
in several ways that a naïve model_dump() or asdict() call would get wrong.
Why Not model_dump()¶
Pydantic’s model_dump() emits null for None fields, collapses
False / 0 / "" identically to absent keys when
exclude_none=True is used, and recurses uniformly into all nested objects.
Alfred’s protocol requires different treatment for each of these cases.
to_script_filter() implements the exact rules Alfred expects.
The Six Rules¶
Rule 1 — None means absent¶
Alfred interprets a missing key as “use default behaviour”. Sending
"subtitle": null is not equivalent — it is an unexpected value that some
Alfred versions may mishandle. Every field whose Python value is None
is simply omitted from the output dict.
item = Item(title="hello") # subtitle is None
item.to_script_filter()
# → {"title": "hello", "valid": True}
# "subtitle" key is absent entirely
Rule 2 — Falsy primitives are preserved¶
False, 0, and "" are falsy in Python but carry real meaning in
Alfred’s protocol. The most important case: if valid is absent, Alfred
defaults to True. Sending valid=False must appear in the output.
A naïve if v: falsy-filter would silently drop it — a hard-to-diagnose bug.
item = Item(title="disabled", valid=False)
item.to_script_filter()
# → {"title": "disabled", "valid": False} ← False preserved
Rule 3 — Empty nested objects are absent¶
A Text with no fields set serialises to {}.
Sending "text": {} to Alfred is noise and may confuse some Alfred versions.
to_script_filter() calls itself recursively on nested
ScriptFilterObject instances and omits the key when the result is empty.
item = Item(title="hello", text=Text()) # Text() → {}
item.to_script_filter()
# → {"title": "hello", "valid": True}
# "text" key is absent
Rule 4 — Empty top-level dict fields are absent¶
variables: dict = {} on an Item means “no variables” — identical to
the key being absent. Alfred ignores both. An empty top-level dict is omitted.
item = Item(title="hello") # variables defaults to {}
item.to_script_filter()
# → {"title": "hello", "valid": True}
# "variables" key is absent
Rule 5 — variables: {} inside mods is preserved¶
Alfred distinguishes between a mod entry without a variables key
(the mod inherits the item’s variables) and a mod with "variables": {}
(the mod explicitly clears that inheritance). This matters when you want a
modifier key to have a completely independent action.
Rule 4 therefore applies only to top-level ScriptFilterObject fields.
Plain dict values — such as the entries inside mods — are passed
through as-is without any recursive stripping.
item = Item(title="hello")
item.mods = {"cmd": {"variables": {}}} # intentional empty variables
item.to_script_filter()
# → {"title": "hello", "valid": True, "mods": {"cmd": {"variables": {}}}}
# "variables": {} inside mods is preserved
Rule 6 — list fields are always preserved¶
ScriptFilter must always return an items
array — even [] — because Alfred expects the key to be present.
List fields are never omitted regardless of their content.
sf = ScriptFilter() # items defaults to []
sf.to_script_filter()
# → {"items": []} ← empty list preserved
Alias Handling¶
Some fields use a Pydantic alias because their natural Alfred JSON key is a
Python reserved word. Text stores the clipboard-copy text
as copy_text in Python (alias="copy").
to_script_filter() writes the alias as the JSON key:
text = Text(copy_text="copy me")
text.to_script_filter()
# → {"copy": "copy me"}
Summary Table¶
Python value |
Output |
|---|---|
|
Field omitted (Rule 1) |
|
Value preserved as-is (Rule 2) |
Nested |
Field omitted (Rule 3) |
Top-level |
Field omitted (Rule 4) |
|
Passed through unchanged (Rule 5) |
|
Value preserved as-is (Rule 6) |
Tests¶
Each rule has a dedicated test case in tests/test_script_filter_object.py.
Running that file standalone also generates a coverage report for
afwf.script_filter_object:
python tests/test_script_filter_object.py