From 164d034ee01a1521af114d79e92c2d63bbc95bd5 Mon Sep 17 00:00:00 2001 From: Redmer Kronemeijer <12477216+redmer@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:30:07 +0200 Subject: [PATCH 1/2] fix: search expression syntax for empty values --- src/pyfwapi/search/ast.py | 19 +++++++++++++++---- tests/test_search_ast.py | 25 ++++++++++++++++++++++--- tests/test_search_expression.py | 6 +++--- 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/pyfwapi/search/ast.py b/src/pyfwapi/search/ast.py index 87251ae..a89ea31 100644 --- a/src/pyfwapi/search/ast.py +++ b/src/pyfwapi/search/ast.py @@ -21,7 +21,9 @@ class SEASTNode: its arguments are instances of the AST that it structures. """ - type: t.Literal["AND", "OR", "NOT", "FIELD_EQ", "FIELD", "VAL_RANGE", "VALUE"] + type: t.Literal[ + "AND", "OR", "NOT", "FIELD_EQ", "FIELD_EMPTY", "FIELD", "VAL_RANGE", "VALUE" + ] args: tuple[t.Self | str, t.Self | str | None] def __str__(self) -> str: @@ -33,8 +35,17 @@ def __str__(self) -> str: return f"{str(arg1)}~~{str(arg2)}" case "FIELD_EQ": return f"{str(arg1)}:{str(arg2)}" + case "FIELD_EMPTY": + # An empty field value must still match, so it's a quoted empty phrase. + return f'{str(arg1)}:""' case "NOT": - return f"NOT ( {str(arg1)} )" + # FotoWeb negation syntax is a leading hyphen, e.g. `-801:""`. + # Parentheses are only needed for compound (AND/OR) subexpressions; + # a simple `field:value` term binds tighter than the `-` operator. + term = str(arg1) + if isinstance(arg1, SEASTNode) and arg1.type in {"AND", "OR"}: + term = f"( {term} )" + return f"-{term}" case "OR" | "AND": return f"( {str(arg1)} ) {self.type} ( {str(arg2)} )" @@ -108,8 +119,8 @@ def VAL_RANGE(start_value: SEASTNode, end_value: SEASTNode): def FIELD_EMPTY(field: SEASTNode): - """Create an empty field expression""" - return SEASTNode(type="FIELD_EQ", args=(field, VALUE(""))) + """Create an empty field expression (field has no value).""" + return SEASTNode(type="FIELD_EMPTY", args=(field, None)) def FIELD_EQ(field: SEASTNode, value: SEASTNode): diff --git a/tests/test_search_ast.py b/tests/test_search_ast.py index e8c330f..e02d55e 100644 --- a/tests/test_search_ast.py +++ b/tests/test_search_ast.py @@ -89,8 +89,9 @@ def test_ast_field_eq(): def test_ast_field_empty(): field = FIELD("description") node = FIELD_EMPTY(field) - assert node.type == "FIELD_EQ" - assert str(node) == "description:" + assert node.type == "FIELD_EMPTY" + # An empty field must be a quoted empty phrase to actually match in FotoWeb. + assert str(node) == 'description:""' def test_ast_not(): @@ -100,7 +101,25 @@ def test_ast_not(): node = NOT(eq_node) assert node.type == "NOT" - assert str(node) == "NOT ( title:hello )" + assert str(node) == "-title:hello" + + +def test_ast_not_empty_field(): + # Regression: 'all assets with any 801 value' must render as -801:"", which + # URL-encodes to ?q=-801%3A%22%22. A leading '"' (i.e. double-quoting of the + # whole expression) would be wrong. + node = NOT(FIELD_EMPTY(FIELD(801))) + rendered = str(node) + assert rendered == '-801:""' + assert not rendered.startswith('"') + + +def test_ast_not_compound_needs_parentheses(): + from pyfwapi.search.ast import AND + + compound = AND(FIELD_EQ(FIELD("a"), VALUE("1")), FIELD_EQ(FIELD("b"), VALUE("2"))) + node = NOT(compound) + assert str(node) == "-( ( a:1 ) AND ( b:2 ) )" def test_ast_or(): diff --git a/tests/test_search_expression.py b/tests/test_search_expression.py index 82e4fd3..0b0a797 100644 --- a/tests/test_search_expression.py +++ b/tests/test_search_expression.py @@ -29,7 +29,7 @@ def test_se_fts(): def test_se_empty(): se = SE().empty("title") - assert str(se.data) == "title:" + assert str(se.data) == 'title:""' def test_se_eq(): @@ -71,7 +71,7 @@ def test_se_dunder_methods(): assert str(se_or.data) == "( tag:cat ) OR ( tag:dog )" se_not = -se1 - assert str(se_not.data) == "NOT ( tag:cat )" + assert str(se_not.data) == "-tag:cat" def test_se_dunder_str(): @@ -86,7 +86,7 @@ def test_se_complex_chaining(): se = se | SE().eq("override", 1) se = se & ~SE().empty("required_field") - expected = "( ( ( status:active ) AND ( date:2020-01-01~~2020-12-31 ) ) OR ( override:1 ) ) AND ( NOT ( required_field: ) )" + expected = '( ( ( status:active ) AND ( date:2020-01-01~~2020-12-31 ) ) OR ( override:1 ) ) AND ( -required_field:"" )' assert str(se) == expected From 3a3502f67a73daf975f606ad7d35aaa3b815f7a1 Mon Sep 17 00:00:00 2001 From: Redmer Kronemeijer <12477216+redmer@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:40:18 +0200 Subject: [PATCH 2/2] docs: add reference --- src/pyfwapi/search/ast.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pyfwapi/search/ast.py b/src/pyfwapi/search/ast.py index a89ea31..1725080 100644 --- a/src/pyfwapi/search/ast.py +++ b/src/pyfwapi/search/ast.py @@ -3,6 +3,8 @@ Search Expressions. Consider using SE (Seach Expression) for an easier, fluent-style API. + +Source: """ import json