diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..5748d7d --- /dev/null +++ b/.claude/CLAUDE.md @@ -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. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..ba87956 --- /dev/null +++ b/.claude/settings.json @@ -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" + ] + } +} \ No newline at end of file diff --git a/docs/filter_specifications.md b/docs/filter_specifications.md index 677b319..433d3f0 100644 --- a/docs/filter_specifications.md +++ b/docs/filter_specifications.md @@ -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. diff --git a/src/object_filtering/object_filtering.py b/src/object_filtering/object_filtering.py index aa5ed68..dd58634 100644 --- a/src/object_filtering/object_filtering.py +++ b/src/object_filtering/object_filtering.py @@ -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 @@ -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"], @@ -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 @@ -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"], @@ -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 @@ -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=[ @@ -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 @@ -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"]), @@ -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: diff --git a/tests/test_object_filtering.py b/tests/test_object_filtering.py index 9f48970..2dc5bf3 100644 --- a/tests/test_object_filtering.py +++ b/tests/test_object_filtering.py @@ -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", @@ -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 + } ] } }