Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Project Guidelines

## Running Code

Always use `uv run` to execute Python code and tests (e.g., `uv run python -m pytest`). Never use bare `python` or `py`.

## Code Style

### Strings

- Use double quotes for regular strings.
- Use single quotes only for forward-reference type hint strings (e.g., `'list[LogicalExpression]'` not `"list[LogicalExpression]"`) and nested dict access inside f-strings.
- Use f-strings with double-quotes instead of string addition.

### Formatting

- Format Python dict literals with JSON-style pretty-printing: one key-value pair per line, 4-space indentation. Opening brace on the assignment line, each key on its own indented line, closing brace on its own line.
- Multi-line function parameters: each on its own line, indented 2 levels (8 spaces) from the `def`.

### Docstrings

- Use Google-style docstrings with `Args:`, `Raises:`, `Returns:` sections.
- Include type annotations in parentheses in the `Args:` section (e.g., `param (type): Description.`).

### Imports

- Order: stdlib, third-party, local (with blank lines between groups).

### Type Hints

- Use modern union syntax (`int | float`, not `Union[int, float]`).
- Always annotate return types, including `-> None`.

### File Headers

- `# (c) YEAR Author Name`
- `# This file is licensed under the MIT License. See LICENSE.txt for details.`

### Tests

- Use `unittest.TestCase` subclasses named `Test[FeatureName]`.
- One assertion focus per test method.
15 changes: 15 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"permissions": {
"allow": [
"Bash(git status:*)",
"Bash(git log:*)",
"Bash(git diff:*)",
"Bash(git branch:*)",
"Bash(gh issue view:*)",
"Bash(gh pr view:*)",
"Glob",
"Grep",
"Read"
]
}
}
2 changes: 1 addition & 1 deletion docs/filter_specifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ To evaluate to **True**, either the *if* and *then* conditions must evaluate to

Special variables are reserved criterion names that do not correspond to an instance variable, property, or method on the object. Instead, they return metadata about the object itself. Special variables are identified by the `$` prefix and suffix (e.g., `$CLASS$`).

#### $CLASS$
#### \$CLASS\$

The `$CLASS$` variable returns the class name of the object as a string. It can be used as the criterion in any rule. When used, only the `==` and `!=` operators are supported. Using any other operator with `$CLASS$` will raise a FilterError.

Expand Down
104 changes: 67 additions & 37 deletions src/object_filtering/object_filtering.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,18 @@ def __init__(
logical_expression: bool | dict = True
) -> None:
super().__init__()

if not isinstance(name, str):
raise TypeError("name must be a str.")
if not isinstance(description, str):
raise TypeError("description must be a str.")
if not isinstance(priority, int):
raise TypeError("priority must be an int.")
if not isinstance(object_types, list) or not all(isinstance(foo, str) for foo in object_types):
raise TypeError("object_types must be a list of strings.")
if not isinstance(logical_expression, LogicalExpression):
raise TypeError("logical_expression must be a LogicalExpression.")

self["name"] = name
self["description"] = description
self["priority"] = priority
Expand All @@ -111,14 +123,7 @@ def from_dict(cls, d: dict) -> "ObjectFilter":
ObjectFilter: The constructed ObjectFilter.
"""
_check_keys(d, cls._valid_keys, cls.__name__)
if not isinstance(d["name"], str):
raise TypeError("name must be a str.")
if not isinstance(d["description"], str):
raise TypeError("description must be a str.")
if not isinstance(d["priority"], int):
raise TypeError("priority must be an int.")
if not isinstance(d["object_types"], list):
raise TypeError("object_types must be a list.")

return cls(
name=d["name"],
description=d["description"],
Expand All @@ -145,6 +150,22 @@ def __init__(
multi_value_behavior: MultiValueBehavior = "none"
) -> None:
super().__init__()

if not isinstance(criterion, str):
raise TypeError("criterion must be a str.")
if operator not in VALID_OPERATORS:
raise ValueError(
f"operator must be one of {sorted(VALID_OPERATORS)}, "
f"got '{operator}'."
)
if not isinstance(parameters, list):
raise TypeError("parameters must be a list.")
if multi_value_behavior not in VALID_MULTI_VALUE_BEHAVIORS:
raise ValueError(
f"multi_value_behavior must be one of {sorted(VALID_MULTI_VALUE_BEHAVIORS)}, "
f"got '{multi_value_behavior}'."
)

self["criterion"] = criterion
self["operator"] = operator
self["comparison_value"] = comparison_value
Expand All @@ -168,21 +189,7 @@ def from_dict(cls, d: dict) -> "Rule":
Rule: The constructed Rule.
"""
_check_keys(d, cls._valid_keys, cls.__name__)
if not isinstance(d["criterion"], str):
raise TypeError("criterion must be a str.")
if d["operator"] not in VALID_OPERATORS:
raise ValueError(
f"operator must be one of {sorted(VALID_OPERATORS)}, "
f"got '{d['operator']}'."
)
if not isinstance(d["parameters"], list):
raise TypeError("parameters must be a list.")
if d["multi_value_behavior"] not in VALID_MULTI_VALUE_BEHAVIORS:
raise ValueError(
f"multi_value_behavior must be one of "
f"{sorted(VALID_MULTI_VALUE_BEHAVIORS)}, "
f"got '{d['multi_value_behavior']}'."
)

return cls(
criterion=d["criterion"],
operator=d["operator"],
Expand All @@ -195,14 +202,23 @@ class GroupExpression(_LogicalExpressionBase):
_valid_keys = frozenset({"logical_operator", "logical_expressions"})

logical_operator: LogicalOperator
logical_expressions: list
logical_expressions: 'list[LogicalExpression]'

def __init__(
self,
logical_operator: LogicalOperator = "and",
logical_expressions: list = []
logical_expressions: 'list[LogicalExpression]' = []
) -> None:
super().__init__()

if logical_operator not in VALID_LOGICAL_OPERATORS:
raise ValueError(
f"logical_operator must be one of {sorted(VALID_LOGICAL_OPERATORS)}, "
f"got '{logical_operator}'."
)
if not isinstance(logical_expressions, list) or not all(isinstance(expr, LogicalExpression) for expr in logical_expressions):
raise TypeError("logical_expressions must be a list of LogicalExpressions.")

self["logical_operator"] = logical_operator
self["logical_expressions"] = logical_expressions

Expand All @@ -223,14 +239,7 @@ def from_dict(cls, d: dict) -> "GroupExpression":
GroupExpression: The constructed GroupExpression.
"""
_check_keys(d, cls._valid_keys, cls.__name__)
if d["logical_operator"] not in VALID_LOGICAL_OPERATORS:
raise ValueError(
f"logical_operator must be one of "
f"{sorted(VALID_LOGICAL_OPERATORS)}, "
f"got '{d['logical_operator']}'."
)
if not isinstance(d["logical_expressions"], list):
raise TypeError("logical_expressions must be a list.")

return cls(
logical_operator=d["logical_operator"],
logical_expressions=[
Expand All @@ -249,11 +258,29 @@ class ConditionalExpression(_LogicalExpressionBase):

def __init__(
self,
_if: bool | dict = True,
_then: bool | dict = True,
_else: bool | dict = True
_if: 'bool | LogicalExpression' = True,
_then: 'bool | LogicalExpression' = True,
_else: 'bool | LogicalExpression' = True
) -> None:
"""Creates a ConditionalExpression.

Args:
_if (bool | LogicalExpression): The condition branch.
_then (bool | LogicalExpression): The branch evaluated when the
condition is true.
_else (bool | LogicalExpression): The branch evaluated when the
condition is false.

Raises:
TypeError: If any branch is not a LogicalExpression.
"""
super().__init__()
for name, value in (("if", _if), ("then", _then), ("else", _else)):
if not isinstance(value, (bool, _LogicalExpressionBase)):
raise TypeError(
f"'{name}' must be a bool or LogicalExpression, "
f"got {type(value).__name__}."
)
self["if"] = _if
self["then"] = _then
self["else"] = _else
Expand All @@ -274,6 +301,7 @@ def from_dict(cls, d: dict) -> "ConditionalExpression":
ConditionalExpression: The constructed ConditionalExpression.
"""
_check_keys(d, cls._valid_keys, cls.__name__)

return cls(
_if=logical_expression_from_dict(d["if"]),
_then=logical_expression_from_dict(d["then"]),
Expand Down Expand Up @@ -421,7 +449,9 @@ def dict_to_logical_expression(d: dict) -> LogicalExpression:
elif expr_type == ObjectFilter:
return ObjectFilter.from_dict(d)

def get_logical_expression_type(expression: LogicalExpression) -> type:
def get_logical_expression_type(
expression: LogicalExpression
) -> type[bool] | type[Rule] | type[ConditionalExpression] | type[GroupExpression] | type[ObjectFilter]:
"""Determines the type of a LogicalExpression based on its contents.

Args:
Expand Down
140 changes: 134 additions & 6 deletions tests/test_object_filtering.py
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,122 @@ def test_conditional_expression_from_dict(self):
assert cond["then"] is True
assert cond["else"] is False

def test_rule_init_invalid_criterion(self):
with pytest.raises(TypeError):
object_filtering.Rule(criterion=123, operator="==", comparison_value=1, parameters=[], multi_value_behavior="none")

def test_rule_init_invalid_operator(self):
with pytest.raises(ValueError):
object_filtering.Rule(criterion="x", operator="~", comparison_value=1, parameters=[], multi_value_behavior="none")

def test_rule_init_invalid_parameters(self):
with pytest.raises(TypeError):
object_filtering.Rule(criterion="x", operator="==", comparison_value=1, parameters="not a list", multi_value_behavior="none")

def test_rule_init_invalid_multi_value_behavior(self):
with pytest.raises(ValueError):
object_filtering.Rule(criterion="x", operator="==", comparison_value=1, parameters=[], multi_value_behavior="bad")

def test_group_expression_init_invalid_operator(self):
with pytest.raises(ValueError):
object_filtering.GroupExpression(logical_operator="xor", logical_expressions=[True])

def test_group_expression_init_invalid_expressions_type(self):
with pytest.raises(TypeError):
object_filtering.GroupExpression(logical_operator="and", logical_expressions="not a list")

def test_group_expression_init_invalid_expression_element(self):
with pytest.raises(TypeError):
object_filtering.GroupExpression(logical_operator="and", logical_expressions=[42])

def test_object_filter_init_invalid_name(self):
with pytest.raises(TypeError):
object_filtering.ObjectFilter(name=123, description="desc", priority=0, object_types=["obj"], logical_expression=True)

def test_object_filter_init_invalid_description(self):
with pytest.raises(TypeError):
object_filtering.ObjectFilter(name="test", description=123, priority=0, object_types=["obj"], logical_expression=True)

def test_object_filter_init_invalid_priority(self):
with pytest.raises(TypeError):
object_filtering.ObjectFilter(name="test", description="desc", priority="zero", object_types=["obj"], logical_expression=True)

def test_object_filter_init_invalid_object_types_not_list(self):
with pytest.raises(TypeError):
object_filtering.ObjectFilter(name="test", description="desc", priority=0, object_types="Shape", logical_expression=True)

def test_object_filter_init_invalid_object_types_elements(self):
with pytest.raises(TypeError):
object_filtering.ObjectFilter(name="test", description="desc", priority=0, object_types=[123], logical_expression=True)

def test_object_filter_init_invalid_logical_expression(self):
with pytest.raises(TypeError):
object_filtering.ObjectFilter(name="test", description="desc", priority=0, object_types=["obj"], logical_expression="not valid")

def test_object_filter_init_invalid_logical_expression_dict(self):
with pytest.raises(TypeError):
object_filtering.ObjectFilter(
name="test",
description="desc",
priority=0,
object_types=["obj"],
logical_expression={
"criterion": "x",
"operator": "==",
"comparison_value": 1,
"parameters": [],
"multi_value_behavior": "none"
}
)

def test_conditional_expression_init_invalid_if(self):
with pytest.raises(TypeError):
object_filtering.ConditionalExpression(_if="not a bool", _then=True, _else=True)

def test_conditional_expression_init_invalid_then(self):
with pytest.raises(TypeError):
object_filtering.ConditionalExpression(_if=True, _then=42, _else=True)

def test_conditional_expression_init_invalid_else(self):
with pytest.raises(TypeError):
object_filtering.ConditionalExpression(_if=True, _then=True, _else=[1, 2])

def test_conditional_expression_init_invalid_if_dict(self):
with pytest.raises(TypeError):
object_filtering.ConditionalExpression(
_if={"criterion": "x", "operator": ">=", "comparison_value": 1,
"parameters": [], "multi_value_behavior": "none"},
_then=True,
_else=True
)

def test_conditional_expression_init_invalid_then_dict(self):
with pytest.raises(TypeError):
object_filtering.ConditionalExpression(
_if=True,
_then={"if": True, "then": False, "else": True},
_else=True
)

def test_conditional_expression_init_invalid_else_dict(self):
with pytest.raises(TypeError):
object_filtering.ConditionalExpression(
_if=True,
_then=True,
_else={"logical_operator": "and", "logical_expressions": [True]}
)

def test_conditional_expression_init_valid_logical_expressions(self):
rule = object_filtering.Rule("x", ">=", 1, [], "none")
group = object_filtering.GroupExpression("and", [True])
inner_cond = object_filtering.ConditionalExpression(True, False, True)
cond = object_filtering.ConditionalExpression(
_if=rule, _then=group, _else=inner_cond
)
assert isinstance(cond["if"], object_filtering.Rule)
assert isinstance(cond["then"], object_filtering.GroupExpression)
assert isinstance(cond["else"], object_filtering.ConditionalExpression)

def test_object_filter_from_dict(self):
d = {
"name": "Test Filter",
Expand All @@ -836,12 +952,24 @@ def test_object_filter_from_dict(self):
"logical_expression": {
"logical_operator": "and",
"logical_expressions": [
{"criterion": "x", "operator": ">=", "comparison_value": 2,
"parameters": [], "multi_value_behavior": "none"},
{"if": {"criterion": "y", "operator": ">=",
"comparison_value": 1, "parameters": [],
"multi_value_behavior": "none"},
"then": True, "else": False}
{
"criterion": "x",
"operator": ">=",
"comparison_value": 2,
"parameters": [],
"multi_value_behavior": "none"
},
{
"if": {
"criterion": "y",
"operator": ">=",
"comparison_value": 1,
"parameters": [],
"multi_value_behavior": "none"
},
"then": True,
"else": False
}
]
}
}
Expand Down