From a2f2083e26f8f461eaafc1034b31a0120878c972 Mon Sep 17 00:00:00 2001 From: SmartLamScott Date: Tue, 24 Mar 2026 10:44:53 -0500 Subject: [PATCH 01/13] bumped version to v0.3.0 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0df1eef..8f852ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ packages = ["object_filtering"] [project] name = "object_filtering" -version = "0.2.0" +version = "0.3.0" authors = [ { name="Scott Ratchford", email="object_filtering@scottratchford.com" }, ] diff --git a/uv.lock b/uv.lock index 2d75e37..3c5883d 100644 --- a/uv.lock +++ b/uv.lock @@ -185,7 +185,7 @@ wheels = [ [[package]] name = "object-filtering" -version = "0.2.0" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, From 044124399bd1ca85df272a45e0b8680d3f618b00 Mon Sep 17 00:00:00 2001 From: SmartLamScott Date: Tue, 24 Mar 2026 10:57:08 -0500 Subject: [PATCH 02/13] fixed #26 --- src/object_filtering/object_filtering.py | 115 ++++++++++++----------- 1 file changed, 58 insertions(+), 57 deletions(-) diff --git a/src/object_filtering/object_filtering.py b/src/object_filtering/object_filtering.py index 9adf6be..4a1f5d3 100644 --- a/src/object_filtering/object_filtering.py +++ b/src/object_filtering/object_filtering.py @@ -119,7 +119,7 @@ def type_name_matches(obj: Any, target_type_names: Iterable[str]) -> bool: return False -def get_logical_expression_type(expression: bool | dict) -> str: +def get_logical_expression_type(expression: bool | dict) -> type: """Determines the type of a logical expression based on its contents. Args: @@ -130,24 +130,23 @@ def get_logical_expression_type(expression: bool | dict) -> str: ValueError: If expression is a dict but its keys do not match any logical expression. Returns: - str: "boolean", "rule", "conditional_expression", "group_expression", or "filter" + type: bool, Rule, ConditionalExpression, GroupExpression, ObjectFilter """ if isinstance(expression, bool): - return "boolean" + return bool elif not isinstance(expression, dict): raise TypeError("expression is not a bool or dict") key_set = set(expression.keys()) - if set(("criterion", "operator", "comparison_value", "parameters", "multi_value_behavior")).issubset(key_set): - return "rule" - if set(("if", "then", "else")).issubset(key_set): - return "conditional_expression" - if set(("logical_operator", "logical_expressions")).issubset(key_set): - return "group_expression" - if set(("name", "description", "priority", "object_types", "logical_expression")).issubset(key_set): - return "filter" - - raise ValueError("expression is not a logical expression of any kind " + \ - "(boolean, rule, group expression, conditional expression, or filter)") + if {"criterion", "operator", "comparison_value", "parameters", "multi_value_behavior"}.issubset(key_set): + return Rule + if {"if", "then", "else"}.issubset(key_set): + return ConditionalExpression + if {"logical_operator", "logical_expressions"}.issubset(key_set): + return GroupExpression + if {"name", "description", "priority", "object_types", "logical_expression"}.issubset(key_set): + return ObjectFilter + + raise ValueError("expression is not a LogicalExpression.") def is_logical_expression_valid(expression: bool | dict, obj: Any = None) -> bool: """Determines whether a logical expression conforms to the format from the documentation. @@ -159,26 +158,25 @@ def is_logical_expression_valid(expression: bool | dict, obj: Any = None) -> boo must be present and whitelisted for its type. Defaults to None. Raises: - ValueError: If expression is not a logical expression of any kind - (boolean, rule, group expression, conditional expression, or filter) + ValueError: If expression is not a LogicalExpression Returns: bool: Whether the logical expression is valid. """ - expression_type = get_logical_expression_type(expression) - if expression_type == "boolean": - return True # True and False are both valid - elif expression_type == "rule": + expr_type = get_logical_expression_type(expression) + if expr_type == bool: + # True and False are both valid + return True + elif expr_type == Rule: return is_rule_valid(expression, obj) - elif expression_type == "conditional_expression": + elif expr_type == ConditionalExpression: return is_conditional_expression_valid(expression, obj) - elif expression_type == "group_expression": + elif expr_type == GroupExpression: return is_group_expression_valid(expression, obj) - elif expression_type == "filter": + elif expr_type == ObjectFilter: return is_filter_valid(expression, obj) else: - raise ValueError("expression is not a logical expression of any kind " + \ - "(boolean, rule, group expression, conditional expression, or filter)") + raise ValueError("expression is not a LogicalExpression.") def is_rule_valid(rule: dict, obj: Any = None) -> bool: """Determines whether a rule conforms to the format from the documentation. @@ -201,8 +199,8 @@ def is_rule_valid(rule: dict, obj: Any = None) -> bool: - comparison_value: The value to compare the value of the criterion with. - parameters (list): Passed into the method if the criterion is a method. """ - if get_logical_expression_type(rule) != "rule": - raise FilterError("rule is not a rule.") + if get_logical_expression_type(rule) != Rule: + raise FilterError("rule is not a Rule.") # value types if not isinstance(rule["criterion"], str): raise FilterError("rule criterion is not a string.") @@ -256,8 +254,8 @@ def is_conditional_expression_valid(expression: dict, obj: Any = None) -> bool: - then (bool | dict): The logical expression to evaluate if the "if" branch evaluates to True. - else (bool | dict): The logical expression to evaluate if the "if" branch evaluates to False. """ - if get_logical_expression_type(expression) != "conditional_expression": - raise FilterError("expression is not a conditional expression.") + if get_logical_expression_type(expression) != ConditionalExpression: + raise FilterError("expression is not a ConditionalExpression.") return all([is_logical_expression_valid(exp, obj) for exp in expression.values()]) def is_group_expression_valid(expression: dict, obj: Any = None) -> bool: @@ -308,7 +306,7 @@ def is_filter_valid(filter: dict, obj: Any = None) -> bool: raise ValueError("Size of filter dictionary must be less than or equal to " + \ "100 kilobytes (1024 bytes per kilobyte).") # filter must contain all of these keys - if get_logical_expression_type(filter) != "filter": + if get_logical_expression_type(filter) != ObjectFilter: return False # validate type of each key's value if not isinstance(filter["name"], str): @@ -364,20 +362,22 @@ def sanitize_filter(filter: dict) -> dict: return sanitized def get_value(obj: Any, rule: dict) -> Any: - """Returns the value of an attribute of `obj`, based on `rule["criterion"]`. + """Returns the value of an attribute of `obj`, based on + `rule["criterion"]`. If the attribute is a method, it must be decorated with `@filter_criterion` (unless `obj` is a `ObjectWrapper`). If `rule["parameters"]` is not empty, each element of `rule["parameters"]` is passed into the method. Args: - obj (Any): The object that the rule will be executed with. - All criteria in the rules must be present and whitelisted for its type. + obj (Any): The object that the rule will be executed with. All criteria + in the rules must be present and whitelisted for its type. rule (dict): The rule to execute. Raises: ValueError: If the criterion is a method without `@filter_criterion`. - ValueError: If the criterion is a method with `@filter_criterion` but `_is_whitelisted` is False. + ValueError: If the criterion is a method with `@filter_criterion` but + `_is_whitelisted` is False. Returns: Any: The value of the attribute of `obj`. @@ -395,11 +395,13 @@ def get_value(obj: Any, rule: dict) -> Any: parameters = rule["parameters"] if callable(method): if not isinstance(obj, ObjectWrapper) and not hasattr(method, "_is_whitelisted"): - raise AttributeError(f"{method}, a criterion in the filter, does not have the " + \ - "@filter_criterion decoractor.") + raise AttributeError( + f"{method}, a criterion in the filter, does not have the @filter_criterion decorator." + ) elif not isinstance(obj, ObjectWrapper) and not method._is_whitelisted: - raise ValueError(f"{method}, a criterion in the filter, has the " + \ - "@filter_criterion decoractor, but _is_whitelisted is set to False.") + raise ValueError( + f"{method}, a criterion in the filter, has the @filter_criterion decorator, but _is_whitelisted is set to False." + ) else: return method(*parameters) else: @@ -413,32 +415,31 @@ def execute_logical_expression_on_object(obj: Any, expression: bool | dict) -> b """Executes a logical expression on an object. Args: - obj (Any): The object that the logical expression will be executed with. - All criteria in the rules must be present and whitelisted for its type. - expression (bool | dict): The logical expression (boolean, rule, conditional - expression, or group expression) to execute. + obj (Any): The object that the logical expression will be executed + with. All criteria in the rules must be present and whitelisted for + its type. + expression (bool | dict): The logical expression (boolean, rule, + conditional expression, or group expression) to execute. Raises: - ValueError: If expression is not a logical expression of any kind (boolean, - rule, group expression, conditional expression, or filter) + ValueError: If expression is not a LogicalExpression Returns: bool: The evaluation of the logical expression. """ expression_type = get_logical_expression_type(expression) - if expression_type == "boolean": + if expression_type == bool: return expression - if expression_type == "rule": + if expression_type == Rule: return execute_rule_on_object(obj, expression) - elif expression_type == "conditional_expression": + elif expression_type == ConditionalExpression: return execute_conditional_expression_on_object(obj, expression) - elif expression_type == "group_expression": + elif expression_type == GroupExpression: return execute_group_expression_on_object(obj, expression) - elif expression_type == "filter": + elif expression_type == ObjectFilter: return execute_filter_on_object(obj, expression) else: - raise ValueError("expression is not a logical expression of any kind " + \ - "(boolean, rule, group expression, conditional expression, or filter)") + raise ValueError("expression is not a LogicalExpression.") def criterion_comparison( obj_value: int | float | str | bool, operator: str, comparison_value: int | float | str | bool @@ -480,8 +481,8 @@ def execute_rule_on_object(obj: Any, rule: dict) -> bool: Returns: bool: The result of the comparison. """ - if get_logical_expression_type(rule) != "rule": - raise ValueError("rule does not match the format of a rule.") + if get_logical_expression_type(rule) != Rule: + raise ValueError("rule is not a Rule.") obj_value = get_value(obj, rule) operator = rule["operator"] @@ -524,8 +525,8 @@ def execute_conditional_expression_on_object(obj: Any, expression: dict) -> bool Returns: bool: The evaluation of the conditional expression. """ - if get_logical_expression_type(expression) != "conditional_expression": - raise ValueError("expression does not match the format of a conditional expression.") + if get_logical_expression_type(expression) != ConditionalExpression: + raise ValueError("expression is not a ConditionalExpression.") if execute_logical_expression_on_object(obj, expression["if"]): return execute_logical_expression_on_object(obj, expression["then"]) else: @@ -546,8 +547,8 @@ def execute_group_expression_on_object(obj: Any, expression: dict) -> bool: Returns: bool: The evaluation of the group expression. """ - if get_logical_expression_type(expression) != "group_expression": - raise ValueError("expression does not match the format of a group expression.") + if get_logical_expression_type(expression) != GroupExpression: + raise ValueError("expression is not a GroupExpression.") if expression["logical_operator"] == "and": return all([execute_logical_expression_on_object(obj, exp) for exp in expression["logical_expressions"]]) elif expression["logical_operator"] == "or": From 57680260378ccbddc7cc309cdf8097ff4e1d48b4 Mon Sep 17 00:00:00 2001 From: SmartLamScott Date: Tue, 24 Mar 2026 10:58:42 -0500 Subject: [PATCH 03/13] fixed #27 --- src/object_filtering/object_filtering.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/object_filtering/object_filtering.py b/src/object_filtering/object_filtering.py index 4a1f5d3..1995bf3 100644 --- a/src/object_filtering/object_filtering.py +++ b/src/object_filtering/object_filtering.py @@ -12,11 +12,11 @@ ABS_TOL = Decimal(0.0001) -VALID_OPERATORS = set(["<", "<=", "==", "!=", ">=", ">"]) -VALID_LOGICAL_OPERATORS = set(["and", "or"]) -VALID_MULTI_VALUE_BEHAVIORS = set(["none", "add", "each_meets_criterion", "each_equal_in_object"]) -SPECIAL_VARIABLES = set(["$CLASS$"]) -CLASS_VARIABLE_OPERATORS = set(["==", "!="]) +VALID_OPERATORS = {"<", "<=", "==", "!=", ">=", ">"} +VALID_LOGICAL_OPERATORS = {"and", "or"} +VALID_MULTI_VALUE_BEHAVIORS = {"none", "add", "each_meets_criterion", "each_equal_in_object"} +SPECIAL_VARIABLES = {"$CLASS$"} +CLASS_VARIABLE_OPERATORS = {"==", "!="} class ObjectFilter(dict): def __init__( From b8cd3fc4ccac9307206968618e86b1dae2803dca Mon Sep 17 00:00:00 2001 From: SmartLamScott Date: Tue, 24 Mar 2026 10:59:51 -0500 Subject: [PATCH 04/13] updated shield to include Python 3.14 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8c348ae..bb34f8e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Object Filtering ![Build Status](https://github.com/KyberCritter/Object-Filtering/actions/workflows/python-package.yml/badge.svg?branch=main) -![Python Versions](https://img.shields.io/badge/python-3.10--3.13-blue) +![Python Versions](https://img.shields.io/badge/python-3.10--3.14-blue) A Python module for determining whether arbitrary Python objects meet a set of defined criteria. Filters use JSON to represent a set of criteria that objects must meet. Filters can be arbitrarily nested and can contain conditional logic. @@ -23,6 +23,6 @@ See `/docs/filter_specifications.md` for details on filter implementation. ## License -(c) 2025 Scott Ratchford. +(c) 2026 Scott Ratchford. `object_filtering` is licensed under the MIT License. See `LICENSE.txt` for details. From b77aa121b653e3e949f8af353edf259e1111cb7f Mon Sep 17 00:00:00 2001 From: SmartLamScott Date: Tue, 24 Mar 2026 11:14:44 -0500 Subject: [PATCH 05/13] updated several type hints and docstrings to use proper type references --- src/object_filtering/object_filtering.py | 165 +++++++++++++---------- 1 file changed, 92 insertions(+), 73 deletions(-) diff --git a/src/object_filtering/object_filtering.py b/src/object_filtering/object_filtering.py index 1995bf3..59e3f1e 100644 --- a/src/object_filtering/object_filtering.py +++ b/src/object_filtering/object_filtering.py @@ -5,7 +5,7 @@ from decimal import Decimal from inspect import getmro from sys import getsizeof -from typing import Any, Callable, Iterable +from typing import Any, Callable, Iterable, Literal from math import isclose import numpy as np @@ -119,15 +119,16 @@ def type_name_matches(obj: Any, target_type_names: Iterable[str]) -> bool: return False -def get_logical_expression_type(expression: bool | dict) -> type: +def get_logical_expression_type(expression: LogicalExpression) -> type: """Determines the type of a logical expression based on its contents. Args: - expression (bool | dict): A logical expression to evaluate. + expression (LogicalExpression): A logical expression to evaluate. Raises: - TypeError: If expression is not a bool or dict. - ValueError: If expression is a dict but its keys do not match any logical expression. + TypeError: If expression is not a LogicalExpression. + ValueError: If expression is a dict but its keys do not match any + LogicalExpression. Returns: type: bool, Rule, ConditionalExpression, GroupExpression, ObjectFilter @@ -148,14 +149,16 @@ def get_logical_expression_type(expression: bool | dict) -> type: raise ValueError("expression is not a LogicalExpression.") -def is_logical_expression_valid(expression: bool | dict, obj: Any = None) -> bool: - """Determines whether a logical expression conforms to the format from the documentation. +def is_logical_expression_valid(expression: LogicalExpression, obj: Any = None) -> bool: + """Determines whether a logical expression conforms to the format from the + documentation. Args: - expression (bool | dict): The logical expression (boolean, rule, - conditional expression, or group expression) to check the validity of. - obj (Any): The object that will be filtered. All criteria in the rules - must be present and whitelisted for its type. Defaults to None. + expression (LogicalExpression): The LogicalExpression to check the + validity of. + obj (Any): The object that will be filtered. All criteria in the Rules + must be present and whitelisted for its type. Defaults to None. If + None, criteria validity checks will be skipped. Raises: ValueError: If expression is not a LogicalExpression @@ -236,36 +239,38 @@ def is_rule_valid(rule: dict, obj: Any = None) -> bool: return True -def is_conditional_expression_valid(expression: dict, obj: Any = None) -> bool: - """Determines whether a rule conforms to the format from the documentation. - Raises an error if the conditional expression is not valid. +def is_conditional_expression_valid(expression: ConditionalExpression, obj: Any = None) -> bool: + """Determines whether a ConditionalExpression conforms to the format from + the documentation. Raises an error if the ConditionalExpression is not + valid. Args: - expression (dict): The conditional expression to check the validity of. - obj (Any): The object that will be filtered. - All criteria in the rules must be present and whitelisted for its type. - Defaults to None. + expression (dict): The ConditionalExpression to check the validity of. + obj (Any): The object that will be filtered. All criteria in the Rules + must be present and whitelisted for its type. Defaults to None. Returns: bool: Whether the conditional expression is valid. - Required keys and their values' data types: - - if (bool | dict): The first logical expression to evaluate - - then (bool | dict): The logical expression to evaluate if the "if" branch evaluates to True. - - else (bool | dict): The logical expression to evaluate if the "if" branch evaluates to False. + - if (LogicalExpression): The first LogicalExpression to evaluate + - then (LogicalExpression): The LogicalExpression to evaluate if the + "if" branch evaluates to True. + - else (LogicalExpression): The LogicalExpression to evaluate if the + "if" branch evaluates to False. """ if get_logical_expression_type(expression) != ConditionalExpression: raise FilterError("expression is not a ConditionalExpression.") return all([is_logical_expression_valid(exp, obj) for exp in expression.values()]) def is_group_expression_valid(expression: dict, obj: Any = None) -> bool: - """Determines whether the group expression conforms to the format from the documentation. + """Determines whether the group expression conforms to the format from the + documentation. Args: expression (dict): The group expression to check the validity of. - obj (Any): The object that will be filtered. - All criteria in the rules must be present and whitelisted for its type. - Defaults to None. + obj (Any): The object that will be filtered. All criteria in the rules + must be present and whitelisted for its type. Defaults to None. Returns: bool: Whether the group expression is valid. @@ -278,17 +283,18 @@ def is_group_expression_valid(expression: dict, obj: Any = None) -> bool: raise FilterError("expression logical_operator is not a valid logical operator.") return all([is_logical_expression_valid(exp, obj) for exp in expression["logical_expressions"]]) -def is_filter_valid(filter: dict, obj: Any = None) -> bool: - """Determines whether a filter conforms to the format from the documentation. +def is_filter_valid(filter: ObjectFilter, obj: Any = None) -> bool: + """Determines whether an ObjectFilter conforms to the format from the + documentation. Args: - filter (dict): The filter to check the validity of. - obj (Any): The object that will be filtered. - All criteria in the rules must be present and whitelisted for its type. - Defaults to None. + filter (ObjectFilter): The ObjectFilter to check the validity of. + obj (Any): The object that will be filtered. All criteria in the Rules + must be present and whitelisted for its type. Defaults to None. Raises: - ValueError: If the filter dictionary exceeds 100 kilobytes (102,400 bytes). + ValueError: If the filter dictionary exceeds 100 kilobytes (102,400 + bytes). Returns: bool: Whether the filter is valid. @@ -298,8 +304,10 @@ def is_filter_valid(filter: dict, obj: Any = None) -> bool: - description (str): A user-friendly description. - priority (int): Order of processing, non-negative. - object_types (list[str]): All allowed object types. - - logical_expression (bool | dict): Any logical expression (except filter). - - multi_value_behavior (str): A string that determines what happens to values returned by an ObjectWrapper. + - logical_expression (bool | dict): May be any type of + LogicalExpression except an ObjectFilter. + - multi_value_behavior (str): A string that determines what happens to + values returned by an ObjectWrapper. """ # sanity check on dict size if getsizeof(filter) > 102400: @@ -310,18 +318,18 @@ def is_filter_valid(filter: dict, obj: Any = None) -> bool: return False # validate type of each key's value if not isinstance(filter["name"], str): - raise FilterError("filter name is not a string.") + raise TypeError("filter name is not a string.") if not isinstance(filter["description"], str): - raise FilterError("filter description is not a string.") + raise TypeError("filter description is not a string.") if not isinstance(filter["priority"], int): - raise FilterError("filter priority is not an int.") + raise TypeError("filter priority is not an int.") if not isinstance(filter["object_types"], list): - raise FilterError("filter object_types is not a list.") + raise TypeError("filter object_types is not a list.") if not isinstance(filter["logical_expression"], (bool, dict)): - raise FilterError("filter logical_expression is not a bool or dict.") + raise TypeError("filter logical_expression is not a LogicalExpression.") # validate values of keys if filter["priority"] < 0: - raise FilterError("filter priority is less than 0.") + raise ValueError("filter priority is less than 0.") if not is_logical_expression_valid(filter["logical_expression"], obj): return False @@ -335,18 +343,20 @@ def sanitize_string(value: str) -> str: """Sanitize a string to contain only ASCII characters 32 to 126.""" return ''.join(char for char in value if 32 <= ord(char) <= 126) -def sanitize_filter(filter: dict) -> dict: - """Sanitize a dictionary, including nested dictionaries, to ensure - all string values contain only ASCII characters 32 to 126. +def sanitize_filter(filter: ObjectFilter) -> ObjectFilter: + """Sanitize an ObjectFilter, including nested LogicalExpressions, to ensure + all string values contain only ASCII characters 32 to 126. Returns an + altered deep copy while preserving the original. Args: - filter (dict): The filter to sanitize. + filter (ObjectFilter): The ObjectFilter to sanitize. Raises: - TypeError: If the filter is not a dict. + TypeError: If the ObjectFilter is not a dict. Returns: - dict: The new filter, with all characters outside of the ASCII range 32 to 126 removed. + ObjectFilter: The new ObjectFilter, with all characters outside of the + ASCII range 32 to 126 removed. """ if not isinstance(filter, dict): raise TypeError("filter must be a dictionary.") @@ -411,21 +421,20 @@ def get_value(obj: Any, rule: dict) -> Any: Execution Functions """ -def execute_logical_expression_on_object(obj: Any, expression: bool | dict) -> bool: +def execute_logical_expression_on_object(obj: Any, expression: LogicalExpression) -> bool: """Executes a logical expression on an object. Args: - obj (Any): The object that the logical expression will be executed - with. All criteria in the rules must be present and whitelisted for - its type. - expression (bool | dict): The logical expression (boolean, rule, - conditional expression, or group expression) to execute. + obj (Any): The object that the LogicalExpression will be executed with. + All criteria in the Rules must be present and whitelisted for its + type. + expression (LogicalExpression): The LogicalExpression to execute. Raises: ValueError: If expression is not a LogicalExpression Returns: - bool: The evaluation of the logical expression. + bool: The evaluation of the LogicalExpression """ expression_type = get_logical_expression_type(expression) if expression_type == bool: @@ -442,7 +451,9 @@ def execute_logical_expression_on_object(obj: Any, expression: bool | dict) -> b raise ValueError("expression is not a LogicalExpression.") def criterion_comparison( - obj_value: int | float | str | bool, operator: str, comparison_value: int | float | str | bool + obj_value: int | float | str | bool, + operator: Literal["<", "<=", "==", "!=", ">=", ">"], + comparison_value: int | float | str | bool ) -> bool: if operator == "<": return obj_value < comparison_value @@ -630,17 +641,19 @@ def execute_filter_list_on_object(obj: Any, filter_list: list[dict], sanitize: b def execute_filter_list_on_array( obj_array: np.ndarray[Any], filter_list: list[dict], sanitize: bool = True ) -> np.ndarray[bool]: - """Evaluates a list of filters on every object in an array. - Returns an array with the evaluation result of the filter list on each element. + """Evaluates a list of filters on every object in an array. Returns an + array with the evaluation result of the filter list on each element. Args: obj_array (np.ndarray[Any]): Array of any type of object. - filter_list (list[dict]): A list of filters to execute on the elements of `obj_array`. - sanitize (bool, optional): Whether or not to remove character from the filter outside - the ASCII range 32 to 126. Defaults to True. + filter_list (list[dict]): A list of ObjectFilters to execute on the + elements of `obj_array`. + sanitize (bool, optional): Whether or not to remove character from the + ObjectFilter outside the ASCII range 32 to 126. Defaults to True. Returns: - np.ndarray[bool]: For each element of `obj_array`, whether the filter list evaluated to True. + np.ndarray[bool]: For each element of `obj_array`, whether the + ObjectFilter list evaluated to True. """ filter_list = sort_filter_list(filter_list) if sanitize: @@ -649,36 +662,42 @@ def execute_filter_list_on_array( all(execute_filter_list_on_object(obj, filter_list, sanitize=False)) for obj in obj_array ], dtype=bool) -def execute_filter_list_on_object_get_first_success(obj: Any, filter_list: list[dict], sanitize: bool = True) -> str: - """Evaluates a list of filters on an object. - Returns the name of the first successful filter, if any exists. +def execute_filter_list_on_object_get_first_success( + obj: Any, + filter_list: list[ObjectFilter], + sanitize: bool = True + ) -> str: + """Evaluates a list of ObjectFilters on an object. Returns the name of the + first successful filter, if any exists. This function sorts `filter_list` before executing its elements. - Filters are primarily ordered by `filter["priority"]` and secondarily ordered by `filter["name"]`. + ObjectFilters are primarily ordered by `filter["priority"]` and secondarily + ordered by `filter["name"]`. Args: obj (Any): Any object. - filter_list (list[dict]): A list of filters to execute on `obj`. - sanitize (bool, optional): Whether or not to remove character from - the filter outside the ASCII range 32 to 126. Defaults to True. + filter_list (list[ObjectFilter]): A list of ObjectFilters to execute + on `obj`. + sanitize (bool, optional): Whether or not to santize the ObjectFilters + before execution. Defaults to True. Raises: - ValueError: If `obj` did not pass any filter in `filter_list` + ValueError: If `obj` did not pass any ObjectFilter in `filter_list` Returns: - str: The name of the first successful filter in `filter_list` + str: The name of the first successful ObjectFilter in `filter_list` """ filter_list = sort_filter_list(filter_list) results = execute_filter_list_on_object(obj, filter_list, sanitize=sanitize) for index, passed in enumerate(results): if passed: return filter_list[index]["name"] - raise ValueError("obj did not pass any filters in filter_list") + raise ValueError("obj did not pass any ObjectFilters in filter_list") class ObjectWrapper: - """A class that accepts objects of mixed types. - Evaluates methods and accesses instance variables and properties for each. - Ignores presence or lack of @filter_criterion. + """A class that accepts objects of mixed types. Evaluates methods and + accesses instance variables and properties for each. Ignores presence or + lack of @filter_criterion. """ def __init__(self, obj: Any | Iterable[Any]): From 3bec1db9a2b6e2ce7713a3396143c04008e0dd3d Mon Sep 17 00:00:00 2001 From: SmartLamScott Date: Tue, 24 Mar 2026 11:35:56 -0500 Subject: [PATCH 06/13] finished #29 --- src/object_filtering/object_filtering.py | 158 +++++++++++++---------- 1 file changed, 92 insertions(+), 66 deletions(-) diff --git a/src/object_filtering/object_filtering.py b/src/object_filtering/object_filtering.py index 59e3f1e..2a07f4d 100644 --- a/src/object_filtering/object_filtering.py +++ b/src/object_filtering/object_filtering.py @@ -20,8 +20,12 @@ class ObjectFilter(dict): def __init__( - self, name: str = "", description: str = "", priority: int = 0, - object_types: list = ["object"], logical_expression: bool | dict = True + self, + name: str = "", + description: str = "", + priority: int = 0, + object_types: list = ["object"], + logical_expression: bool | dict = True ) -> None: super().__init__() self["name"] = name @@ -32,8 +36,11 @@ def __init__( class Rule(dict): def __init__( - self, criterion: str = "__class__", operator: str = "==", - comparison_value: int | float | str | bool = "", parameters: list = [], + self, + criterion: str = "__class__", + operator: Literal["==", "!=", ">", ">=", "<", "<="] = "==", + comparison_value: int | float | str | bool = "", + parameters: list = [], multi_value_behavior: str = "none" ) -> None: super().__init__() @@ -45,7 +52,9 @@ def __init__( class GroupExpression(dict): def __init__( - self, logical_operator: str = "and", logical_expressions: list = [] + self, + logical_operator: Literal["and", "or"] = "and", + logical_expressions: list = [] ) -> None: super().__init__() self["logical_operator"] = logical_operator @@ -53,13 +62,15 @@ def __init__( class ConditionalExpression(dict): def __init__( - self, if_branch: bool | dict = True, then_branch: bool | dict = True, - else_branch: bool | dict = True + self, + _if: bool | dict = True, + _then: bool | dict = True, + _else: bool | dict = True ) -> None: super().__init__() - self["if"] = if_branch - self["then"] = then_branch - self["else"] = else_branch + self["if"] = _if + self["then"] = _then + self["else"] = _else LogicalExpression = bool | Rule | ConditionalExpression | GroupExpression | ObjectFilter @@ -83,8 +94,9 @@ def __init__(self, *args): """ def type_name_matches(obj: Any, target_type_names: Iterable[str]) -> bool: - """Evaluates whether obj is an instance of a class with a name matching target_type_name. - If obj is an ObjectWrapper, the types of the elements of obj._obj are checked instead. + """Evaluates whether obj is an instance of a class with a name matching + target_type_name. If obj is an ObjectWrapper, the types of the elements of + obj._obj are checked instead. Args: obj (Any): The object to check the type of. @@ -120,10 +132,10 @@ def type_name_matches(obj: Any, target_type_names: Iterable[str]) -> bool: return False def get_logical_expression_type(expression: LogicalExpression) -> type: - """Determines the type of a logical expression based on its contents. + """Determines the type of a LogicalExpression based on its contents. Args: - expression (LogicalExpression): A logical expression to evaluate. + expression (LogicalExpression): A LogicalExpression to evaluate. Raises: TypeError: If expression is not a LogicalExpression. @@ -150,7 +162,7 @@ def get_logical_expression_type(expression: LogicalExpression) -> type: raise ValueError("expression is not a LogicalExpression.") def is_logical_expression_valid(expression: LogicalExpression, obj: Any = None) -> bool: - """Determines whether a logical expression conforms to the format from the + """Determines whether a LogicalExpression conforms to the format from the documentation. Args: @@ -164,7 +176,7 @@ def is_logical_expression_valid(expression: LogicalExpression, obj: Any = None) ValueError: If expression is not a LogicalExpression Returns: - bool: Whether the logical expression is valid. + bool: Whether the LogicalExpression is valid. """ expr_type = get_logical_expression_type(expression) if expr_type == bool: @@ -263,21 +275,23 @@ def is_conditional_expression_valid(expression: ConditionalExpression, obj: Any raise FilterError("expression is not a ConditionalExpression.") return all([is_logical_expression_valid(exp, obj) for exp in expression.values()]) -def is_group_expression_valid(expression: dict, obj: Any = None) -> bool: - """Determines whether the group expression conforms to the format from the +def is_group_expression_valid(expression: GroupExpression, obj: Any = None) -> bool: + """Determines whether the GroupExpression conforms to the format from the documentation. Args: - expression (dict): The group expression to check the validity of. - obj (Any): The object that will be filtered. All criteria in the rules + expression (GroupExpression): The GroupExpression to check the validity + of. + obj (Any): The object that will be filtered. All criteria in the Rules must be present and whitelisted for its type. Defaults to None. Returns: - bool: Whether the group expression is valid. + bool: Whether the GroupExpression is valid. - Required keys and their values' data types: - logical_operator (str): "and" or "or" - - logical_expressions list([bool | dict]): The logical expressions to evaluate. + - logical_expressions (list[LogicalExpression]): The LogicalExpressions + to evaluate. """ if not expression["logical_operator"] in VALID_LOGICAL_OPERATORS: # must be "and" or "or" raise FilterError("expression logical_operator is not a valid logical operator.") @@ -293,11 +307,10 @@ def is_filter_valid(filter: ObjectFilter, obj: Any = None) -> bool: must be present and whitelisted for its type. Defaults to None. Raises: - ValueError: If the filter dictionary exceeds 100 kilobytes (102,400 - bytes). + ValueError: If the ObjectFilter exceeds 100 kilobytes (102,400 bytes) Returns: - bool: Whether the filter is valid. + bool: Whether the ObjectFilter is valid. - Keys and their required data types: - name (str): A print-friendly name for ordering. @@ -359,7 +372,7 @@ def sanitize_filter(filter: ObjectFilter) -> ObjectFilter: ASCII range 32 to 126 removed. """ if not isinstance(filter, dict): - raise TypeError("filter must be a dictionary.") + raise TypeError("filter must be an ObjectFilter.") sanitized = {} for key, value in filter.items(): @@ -406,11 +419,11 @@ def get_value(obj: Any, rule: dict) -> Any: if callable(method): if not isinstance(obj, ObjectWrapper) and not hasattr(method, "_is_whitelisted"): raise AttributeError( - f"{method}, a criterion in the filter, does not have the @filter_criterion decorator." + f"{method}, a criterion in the ObjectFilter, does not have the @filter_criterion decorator." ) elif not isinstance(obj, ObjectWrapper) and not method._is_whitelisted: raise ValueError( - f"{method}, a criterion in the filter, has the @filter_criterion decorator, but _is_whitelisted is set to False." + f"{method}, a criterion in the ObjectFilter, has the @filter_criterion decorator, but _is_whitelisted is set to False." ) else: return method(*parameters) @@ -422,7 +435,7 @@ def get_value(obj: Any, rule: dict) -> Any: """ def execute_logical_expression_on_object(obj: Any, expression: LogicalExpression) -> bool: - """Executes a logical expression on an object. + """Executes a LogicalExpression on an object. Args: obj (Any): The object that the LogicalExpression will be executed with. @@ -543,20 +556,20 @@ def execute_conditional_expression_on_object(obj: Any, expression: dict) -> bool else: return execute_logical_expression_on_object(obj, expression["else"]) -def execute_group_expression_on_object(obj: Any, expression: dict) -> bool: - """Executes a group expression on an object. +def execute_group_expression_on_object(obj: Any, expression: GroupExpression) -> bool: + """Executes a GroupExpression on an object. Args: - obj (Any): The object that the group expression will be executed with. + obj (Any): The object that the GroupExpression will be executed with. All criteria in the rules must be present and whitelisted for its type. - expression (dict): The group expression to execute. + expression (GroupExpression): The GroupExpression to execute. Raises: - ValueError: If expression does not match the format of a group expression. - ValueError: If expression["logical_operator"] != "and" and expression["logical_operator"] != "or" + ValueError: If expression does not match the format of a GroupExpression. + ValueError: If expression["logical_operator"] is not "and" or "or" Returns: - bool: The evaluation of the group expression. + bool: The evaluation of the GroupExpression. """ if get_logical_expression_type(expression) != GroupExpression: raise ValueError("expression is not a GroupExpression.") @@ -565,73 +578,84 @@ def execute_group_expression_on_object(obj: Any, expression: dict) -> bool: elif expression["logical_operator"] == "or": return any([execute_logical_expression_on_object(obj, exp) for exp in expression["logical_expressions"]]) else: - raise ValueError("Group expression's logical operator must be \"and\" or \"or\".") + raise ValueError("GroupExpression's logical operator must be \"and\" or \"or\".") -def execute_filter_on_object(obj, filter: dict, sanitize: bool = True) -> bool: - """Evaluates a filter on an object. - Returns True if all logical expressions succeed or False if any of them fail. +def execute_filter_on_object(obj, filter: ObjectFilter, sanitize: bool = True) -> bool: + """Evaluates a ObjectFilter on an object. Returns True if all + LogicalExpressions succeed or False if any fail. Args: obj: Any object. - filter (dict): A filter to execute. - sanitize (bool, optional): Whether or not to remove character from the filter outside - the ASCII range 32 to 126. Defaults to True. + filter (ObjectFilter): An ObjectFilter to execute. + sanitize (bool, optional): Whether or not to santize the ObjectFilters + before execution. Defaults to True. Raises: - ValueError: If the filter is not valid, according to the documentation. + ValueError: If the ObjectFilter is not valid, according to the + documentation. Returns: - bool: Whether all the logical expressions in the filter evaluated to True. + bool: Whether all the LogicalExpressions in the ObjectFilter evaluated + to True. """ if sanitize: filter = sanitize_filter(filter) if not is_filter_valid(filter, obj): - raise ValueError("Filter is not valid.") + raise ValueError("ObjectFilter is not valid.") return execute_logical_expression_on_object(obj, filter["logical_expression"]) def execute_filter_on_array(obj_array: np.ndarray[Any], filter: dict, sanitize: bool = True) -> np.ndarray[bool]: - """Evaluates a filter on each element in an array. - Returns an array with the result of evaluating the filter on each element. + """Evaluates an ObjectFilter on each element in an array. Returns an array + with the result of evaluating the ObjectFilter on each element. Args: obj_array (np.ndarray[Any]): Array of any type of object. - filter (dict): A filter to execute. - sanitize (bool, optional): Whether or not to remove character from the filter outside - the ASCII range 32 to 126. Defaults to True. + filter (ObjectFilter): An ObjectFilter to execute. + sanitize (bool, optional): Whether or not to santize the ObjectFilters + before execution. Defaults to True. Raises: - ValueError: If the filter is not valid, according to the documentation. + ValueError: If the ObjectFilter is not valid, according to the + documentation. Returns: - np.ndarray[bool]: For each element of obj_array, whether the filter evaluated to True. + np.ndarray[bool]: For each element of obj_array, whether the + ObjectFilter evaluated to True. """ if sanitize: filter = sanitize_filter(filter) # use first element because np.ndarray element types are homogeneous if not is_filter_valid(filter, obj_array[0]): - raise ValueError("Filter is not valid.") + raise ValueError("ObjectFilter is not valid.") return np.array([execute_filter_on_object(obj, filter, sanitize=False) for obj in obj_array], dtype=bool) def sort_filter_list(filter_list: list[dict]) -> list[dict]: return sorted(filter_list, key=lambda x: (x["priority"], x["name"])) -def execute_filter_list_on_object(obj: Any, filter_list: list[dict], sanitize: bool = True) -> np.ndarray[bool]: - """Evaluates a list of filters on an object. - Returns an array with the evaluation result of each filter. +def execute_filter_list_on_object( + obj: Any, + filter_list: list[ObjectFilter], + sanitize: bool = True + ) -> np.ndarray[bool]: + """Evaluates a list of filters on an object. Returns an array with the + evaluation result of each ObjectFilter. This function sorts `filter_list` before executing its elements. - Filters are primarily ordered by `filter["priority"]` and secondarily ordered by `filter["name"]`. + ObjectFilters are primarily ordered by priority (ascending) and secondarily + ordered by name (ascending). Args: obj (Any): Any object. - filter_list (list[dict]): A list of filters to execute on `obj`. - sanitize (bool, optional): Whether or not to remove character from the filter outside - the ASCII range 32 to 126. Defaults to True. + filter_list (list[ObjectFilter]): A list of ObjectFilter to execute on + `obj`. + sanitize (bool, optional): Whether or not to santize the ObjectFilters + before execution. Defaults to True. Returns: - np.ndarray[bool]: For each filter, whether it evaluated to True on `obj`. + np.ndarray[bool]: For each ObjectFilter, whether it evaluated to True + on `obj`. """ filter_list = sort_filter_list(filter_list) if sanitize: @@ -639,17 +663,19 @@ def execute_filter_list_on_object(obj: Any, filter_list: list[dict], sanitize: b return np.array([execute_filter_on_object(obj, f, sanitize=False) for f in filter_list], dtype=bool) def execute_filter_list_on_array( - obj_array: np.ndarray[Any], filter_list: list[dict], sanitize: bool = True + obj_array: np.ndarray[Any], + filter_list: list[dict], + sanitize: bool = True ) -> np.ndarray[bool]: """Evaluates a list of filters on every object in an array. Returns an - array with the evaluation result of the filter list on each element. + array with the evaluation result of the ObjectFilter list on each element. Args: obj_array (np.ndarray[Any]): Array of any type of object. filter_list (list[dict]): A list of ObjectFilters to execute on the elements of `obj_array`. - sanitize (bool, optional): Whether or not to remove character from the - ObjectFilter outside the ASCII range 32 to 126. Defaults to True. + sanitize (bool, optional): Whether or not to santize the ObjectFilters + before execution. Defaults to True. Returns: np.ndarray[bool]: For each element of `obj_array`, whether the From 810b35d2e6ae2d1368269d3e0187f011b2565af3 Mon Sep 17 00:00:00 2001 From: SmartLamScott Date: Tue, 24 Mar 2026 13:02:58 -0500 Subject: [PATCH 07/13] fixed #32 --- src/object_filtering/object_filtering.py | 70 ++++++++++++++++++++--- tests/test_object_filtering.py | 73 ++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 9 deletions(-) diff --git a/src/object_filtering/object_filtering.py b/src/object_filtering/object_filtering.py index 2a07f4d..1cee762 100644 --- a/src/object_filtering/object_filtering.py +++ b/src/object_filtering/object_filtering.py @@ -12,13 +12,58 @@ ABS_TOL = Decimal(0.0001) -VALID_OPERATORS = {"<", "<=", "==", "!=", ">=", ">"} -VALID_LOGICAL_OPERATORS = {"and", "or"} -VALID_MULTI_VALUE_BEHAVIORS = {"none", "add", "each_meets_criterion", "each_equal_in_object"} -SPECIAL_VARIABLES = {"$CLASS$"} -CLASS_VARIABLE_OPERATORS = {"==", "!="} +VALID_OPERATORS = frozenset({"<", "<=", "==", "!=", ">=", ">"}) +VALID_LOGICAL_OPERATORS = frozenset({"and", "or"}) +VALID_MULTI_VALUE_BEHAVIORS = frozenset({"none", "add", "each_meets_criterion", "each_equal_in_object"}) +SPECIAL_VARIABLES = frozenset({"$CLASS$"}) +CLASS_VARIABLE_OPERATORS = frozenset({"==", "!="}) + +class _LogicalExpressionBase(dict): + """Base class for all LogicalExpression dict subclasses. Provides dot + notation access and restricts keys to the set defined by each subclass. + """ + _valid_keys: frozenset[str] = frozenset() + _key_aliases: dict[str, str] = {} + + def _resolve_key(self, key: str) -> str: + return self._key_aliases.get(key, key) + + def __setitem__(self, key, value): + if key not in self._valid_keys: + raise KeyError( + f"'{key}' is not a valid key for {type(self).__name__}. " + f"Valid keys: {sorted(self._valid_keys)}" + ) + super().__setitem__(key, value) + + def __delitem__(self, key): + raise TypeError(f"Cannot delete keys from {type(self).__name__}.") + + def __getattr__(self, name): + resolved = self._resolve_key(name) + if resolved in self._valid_keys: + try: + return self[resolved] + except KeyError: + raise AttributeError( + f"'{type(self).__name__}' has no key '{resolved}'" + ) + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) + + def __setattr__(self, name, value): + resolved = self._resolve_key(name) + if resolved in self._valid_keys: + self[resolved] = value + else: + raise AttributeError( + f"'{name}' is not a valid attribute for {type(self).__name__}." + ) + +class ObjectFilter(_LogicalExpressionBase): + _valid_keys = frozenset({"name", "description", "priority", "object_types", "logical_expression"}) -class ObjectFilter(dict): def __init__( self, name: str = "", @@ -34,7 +79,9 @@ def __init__( self["object_types"] = object_types self["logical_expression"] = logical_expression -class Rule(dict): +class Rule(_LogicalExpressionBase): + _valid_keys = frozenset({"criterion", "operator", "comparison_value", "parameters", "multi_value_behavior"}) + def __init__( self, criterion: str = "__class__", @@ -50,7 +97,9 @@ def __init__( self["parameters"] = parameters self["multi_value_behavior"] = multi_value_behavior -class GroupExpression(dict): +class GroupExpression(_LogicalExpressionBase): + _valid_keys = frozenset({"logical_operator", "logical_expressions"}) + def __init__( self, logical_operator: Literal["and", "or"] = "and", @@ -60,7 +109,10 @@ def __init__( self["logical_operator"] = logical_operator self["logical_expressions"] = logical_expressions -class ConditionalExpression(dict): +class ConditionalExpression(_LogicalExpressionBase): + _valid_keys = frozenset({"if", "then", "else"}) + _key_aliases = {"_if": "if", "_then": "then", "_else": "else"} + def __init__( self, _if: bool | dict = True, diff --git a/tests/test_object_filtering.py b/tests/test_object_filtering.py index 0a84ac2..d87269a 100644 --- a/tests/test_object_filtering.py +++ b/tests/test_object_filtering.py @@ -694,6 +694,79 @@ def test_init(self): assert object_filtering.execute_conditional_expression_on_object("test", conditional_expression) assert {"conditional_expression": conditional_expression} + def test_dot_notation_read(self): + rule = object_filtering.Rule("area", "==", 2, [], "none") + assert rule.criterion == "area" + assert rule.operator == "==" + assert rule.comparison_value == 2 + assert rule.parameters == [] + assert rule.multi_value_behavior == "none" + + object_filter = object_filtering.ObjectFilter("test", "desc", 0, ["object"], True) + assert object_filter.name == "test" + assert object_filter.description == "desc" + assert object_filter.priority == 0 + assert object_filter.object_types == ["object"] + assert object_filter.logical_expression == True + + group = object_filtering.GroupExpression("and", [True]) + assert group.logical_operator == "and" + assert group.logical_expressions == [True] + + cond = object_filtering.ConditionalExpression(True, False, True) + assert cond._if == True + assert cond._then == False + assert cond._else == True + + def test_dot_notation_write(self): + rule = object_filtering.Rule("area", "==", 2, [], "none") + rule.criterion = "x" + assert rule["criterion"] == "x" + assert rule.criterion == "x" + + object_filter = object_filtering.ObjectFilter("test", "desc", 0, ["object"], True) + object_filter.name = "updated" + assert object_filter["name"] == "updated" + + cond = object_filtering.ConditionalExpression(True, False, True) + cond._if = False + assert cond["if"] == False + + def test_invalid_key_bracket(self): + rule = object_filtering.Rule() + with pytest.raises(KeyError): + rule["bad_key"] = 123 + + object_filter = object_filtering.ObjectFilter() + with pytest.raises(KeyError): + object_filter["unknown"] = "value" + + group = object_filtering.GroupExpression() + with pytest.raises(KeyError): + group["extra"] = True + + cond = object_filtering.ConditionalExpression() + with pytest.raises(KeyError): + cond["when"] = True + + def test_invalid_key_dot(self): + rule = object_filtering.Rule() + with pytest.raises(AttributeError): + rule.bad_key = 123 + + with pytest.raises(AttributeError): + _ = rule.nonexistent + + def test_delete_key(self): + rule = object_filtering.Rule() + with pytest.raises(TypeError): + del rule["criterion"] + + def test_invalid_attribute_read(self): + group = object_filtering.GroupExpression() + with pytest.raises(AttributeError): + _ = group.nonexistent + class TestMixedTypeFilters(unittest.TestCase): def test_mixed_type_filter(self): shape = Shape(2, 2) From 4bbb6ad2b908105157eb9c904fa7504d85325988 Mon Sep 17 00:00:00 2001 From: SmartLamScott Date: Tue, 24 Mar 2026 13:12:49 -0500 Subject: [PATCH 08/13] use Literals, not frozensets, to whitelist values --- src/object_filtering/object_filtering.py | 31 +++++++++++++++--------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/object_filtering/object_filtering.py b/src/object_filtering/object_filtering.py index 1cee762..c5e17a9 100644 --- a/src/object_filtering/object_filtering.py +++ b/src/object_filtering/object_filtering.py @@ -5,18 +5,27 @@ from decimal import Decimal from inspect import getmro from sys import getsizeof -from typing import Any, Callable, Iterable, Literal +from typing import Any, Callable, Iterable, Literal, get_args from math import isclose import numpy as np ABS_TOL = Decimal(0.0001) -VALID_OPERATORS = frozenset({"<", "<=", "==", "!=", ">=", ">"}) -VALID_LOGICAL_OPERATORS = frozenset({"and", "or"}) -VALID_MULTI_VALUE_BEHAVIORS = frozenset({"none", "add", "each_meets_criterion", "each_equal_in_object"}) + +Operator = Literal["<", "<=", "==", "!=", ">=", ">"] +VALID_OPERATORS = frozenset(get_args(Operator)) + +LogicalOperator = Literal["and", "or"] +VALID_LOGICAL_OPERATORS = frozenset(get_args(LogicalOperator)) + +MultiValueBehavior = Literal["none", "add", "each_meets_criterion", "each_equal_in_object"] +VALID_MULTI_VALUE_BEHAVIORS = frozenset(get_args(MultiValueBehavior)) + +ClassVariableOperator = Literal["==", "!="] +CLASS_VARIABLE_OPERATORS = frozenset(get_args(ClassVariableOperator)) + SPECIAL_VARIABLES = frozenset({"$CLASS$"}) -CLASS_VARIABLE_OPERATORS = frozenset({"==", "!="}) class _LogicalExpressionBase(dict): """Base class for all LogicalExpression dict subclasses. Provides dot @@ -85,10 +94,10 @@ class Rule(_LogicalExpressionBase): def __init__( self, criterion: str = "__class__", - operator: Literal["==", "!=", ">", ">=", "<", "<="] = "==", + operator: Operator = "==", comparison_value: int | float | str | bool = "", parameters: list = [], - multi_value_behavior: str = "none" + multi_value_behavior: MultiValueBehavior = "none" ) -> None: super().__init__() self["criterion"] = criterion @@ -102,7 +111,7 @@ class GroupExpression(_LogicalExpressionBase): def __init__( self, - logical_operator: Literal["and", "or"] = "and", + logical_operator: LogicalOperator = "and", logical_expressions: list = [] ) -> None: super().__init__() @@ -517,7 +526,7 @@ def execute_logical_expression_on_object(obj: Any, expression: LogicalExpression def criterion_comparison( obj_value: int | float | str | bool, - operator: Literal["<", "<=", "==", "!=", ">=", ">"], + operator: Operator, comparison_value: int | float | str | bool ) -> bool: if operator == "<": @@ -547,8 +556,8 @@ def execute_rule_on_object(obj: Any, rule: dict) -> bool: """Returns the result of the comparison operation defined by the rule. Args: - obj (Any): The object that the rule will be executed with. - All criteria in the rules must be present and whitelisted for its type. + obj (Any): The object that the rule will be executed with. All criteria + in the rules must be present and whitelisted for its type. rule (dict): The rule to execute. Raises: From c1cf7ceffbf1a0fa112f7d74882f6d36238f3c49 Mon Sep 17 00:00:00 2001 From: SmartLamScott Date: Tue, 24 Mar 2026 13:35:52 -0500 Subject: [PATCH 09/13] fixed #30, allow dot notation autocomplete and type hints --- src/object_filtering/__init__.py | 5 + .../natural_language/natural_language.py | 71 +++--- src/object_filtering/object_filtering.py | 206 ++++++++++++++++++ tests/test_object_filtering.py | 98 +++++++++ 4 files changed, 341 insertions(+), 39 deletions(-) diff --git a/src/object_filtering/__init__.py b/src/object_filtering/__init__.py index 8142ecb..a389150 100644 --- a/src/object_filtering/__init__.py +++ b/src/object_filtering/__init__.py @@ -37,6 +37,11 @@ execute_filter_list_on_array, execute_filter_list_on_object_get_first_success, ObjectWrapper, + Operator, + LogicalOperator, + MultiValueBehavior, + ClassVariableOperator, + logical_expression_from_dict, ) from .natural_language import ( diff --git a/src/object_filtering/natural_language/natural_language.py b/src/object_filtering/natural_language/natural_language.py index 55b9a69..10b5dff 100644 --- a/src/object_filtering/natural_language/natural_language.py +++ b/src/object_filtering/natural_language/natural_language.py @@ -1,14 +1,12 @@ -# (c) 2025 Scott Ratchford +# (c) 2026 Scott Ratchford # This file is licensed under the MIT License. See LICENSE.txt for details. -#!/usr/bin/env python3 -import json -import sys from ..object_filtering import ( ObjectFilter, Rule, GroupExpression, ConditionalExpression, LogicalExpression, ) + # Map operators to English phrases OPERATOR_MAP = { "<": "is less than", @@ -20,11 +18,13 @@ } def explain_expression(expr: LogicalExpression, depth: int = 0) -> str: - """Recursively convert a logical expression into an indented, multi-line English-language description. + """Recursively convert a logical expression into an indented, multi-line + English-language description. Args: expr (LogicalExpression): The LogicalExpression to explain. - depth (int, optional): Depth of the expression within a larger expression. Defaults to 0. + depth (int, optional): Depth of the expression within a larger + expression. Defaults to 0. Raises: TypeError: If the type of expr is not LogicalExpression. @@ -39,11 +39,8 @@ def explain_expression(expr: LogicalExpression, depth: int = 0) -> str: text = "This condition is always true." if expr else "This condition is always false." return f"{indent}{text}" - # Rule objects or dicts - if isinstance(expr, (Rule, dict)) and set(expr.keys()).issuperset({"criterion", "operator", "comparison_value"}): - criterion = expr["criterion"] - op = expr["operator"] - val = expr["comparison_value"] + # Rule objects + if isinstance(expr, Rule): params = expr.get("parameters", []) if params: if len(params) == 1: @@ -51,16 +48,15 @@ def explain_expression(expr: LogicalExpression, depth: int = 0) -> str: else: joined = ", ".join(map(str, params[:-1])) param_str = f" with parameters {joined} and {params[-1]}" - text = f"The result of calling {criterion}{param_str} {OPERATOR_MAP[op]} {val}." + text = f"The result of calling {expr.criterion}{param_str} {OPERATOR_MAP[expr.operator]} {expr.comparison_value}." else: - text = f"{criterion} {OPERATOR_MAP[op]} {val}." + text = f"{expr.criterion} {OPERATOR_MAP[expr.operator]} {expr.comparison_value}." return f"{indent}{text}" - # GroupExpression objects or dicts - if isinstance(expr, (GroupExpression, dict)) and "logical_operator" in expr: - conj = expr["logical_operator"] + # GroupExpression objects + if isinstance(expr, GroupExpression): parts = expr.get("logical_expressions", []) - if conj == "and": + if expr.logical_operator == "and": header = "All of the following conditions must be met:" else: header = "At least one of the following conditions must be met:" @@ -70,22 +66,24 @@ def explain_expression(expr: LogicalExpression, depth: int = 0) -> str: lines.append(f"{indent} - {sub_text}") return "\n".join(lines) - # ConditionalExpression objects or dicts - if isinstance(expr, (ConditionalExpression, dict)) and set(expr.keys()).issuperset({"if", "then", "else"}): - cond_lines = explain_expression(expr["if"], depth + 1).strip() - then_lines = explain_expression(expr["then"], depth + 1).strip() - else_lines = explain_expression(expr["else"], depth + 1).strip() - lines = [f"{indent}If the following condition holds:", - f"{indent} - {cond_lines}", - f"{indent}Then:", - f"{indent} - {then_lines}", - f"{indent}Otherwise:", - f"{indent} - {else_lines}"] + # ConditionalExpression objects + if isinstance(expr, ConditionalExpression): + cond_lines = explain_expression(expr._if, depth + 1).strip() + then_lines = explain_expression(expr._then, depth + 1).strip() + else_lines = explain_expression(expr._else, depth + 1).strip() + lines = [ + f"{indent}If the following condition holds:", + f"{indent} - {cond_lines}", + f"{indent}Then:", + f"{indent} - {then_lines}", + f"{indent}Otherwise:", + f"{indent} - {else_lines}" + ] return "\n".join(lines) raise TypeError(f"Unsupported expression type: {expr}") -def explain_filter(obj_filter: ObjectFilter) -> str: +def explain_filter(object_filter: ObjectFilter) -> str: """Generate an English explanation of the entire ObjectFilter. Args: @@ -94,17 +92,12 @@ def explain_filter(obj_filter: ObjectFilter) -> str: Returns: str: An English-language description of the ObjectFilter. """ - name = obj_filter.get("name", "(unnamed)") - desc = obj_filter.get("description", "") - types = obj_filter.get("object_types", []) - expr = obj_filter.get("logical_expression", True) - - header = f"Filter \"{name}\": {desc}".strip() - if len(types) > 1: - type_list = ", ".join(types[:-1]) + f", and {types[-1]}" + header = f"Filter \"{object_filter.name}\": {object_filter.description}".strip() + if len(object_filter.object_types) > 1: + type_list = ", ".join(object_filter.object_types[:-1]) + f", and {object_filter.object_types[-1]}" applies = f"This filter applies to objects of types: {type_list}." else: - applies = f"This filter applies to objects of type: {types[0]}." + applies = f"This filter applies to objects of type: {object_filter.object_types[0]}." - criteria = explain_expression(expr) + criteria = explain_expression(object_filter.logical_expression) return f"{header}\n{applies}\n{criteria}" diff --git a/src/object_filtering/object_filtering.py b/src/object_filtering/object_filtering.py index c5e17a9..dba1c2f 100644 --- a/src/object_filtering/object_filtering.py +++ b/src/object_filtering/object_filtering.py @@ -73,6 +73,12 @@ def __setattr__(self, name, value): class ObjectFilter(_LogicalExpressionBase): _valid_keys = frozenset({"name", "description", "priority", "object_types", "logical_expression"}) + name: str + description: str + priority: int + object_types: list + logical_expression: bool | dict + def __init__( self, name: str = "", @@ -88,9 +94,48 @@ def __init__( self["object_types"] = object_types self["logical_expression"] = logical_expression + @classmethod + def from_dict(cls, d: dict) -> "ObjectFilter": + """Creates an ObjectFilter from a plain dict, recursively converting + nested LogicalExpressions. + + Args: + d (dict): A dictionary with ObjectFilter keys. + + Raises: + KeyError: If d contains unrecognized keys or is missing required + keys. + TypeError: If value types do not match expectations. + + Returns: + 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"], + priority=d["priority"], + object_types=d["object_types"], + logical_expression=logical_expression_from_dict(d["logical_expression"]) + ) + class Rule(_LogicalExpressionBase): _valid_keys = frozenset({"criterion", "operator", "comparison_value", "parameters", "multi_value_behavior"}) + criterion: str + operator: Operator + comparison_value: int | float | str | bool + parameters: list + multi_value_behavior: MultiValueBehavior + def __init__( self, criterion: str = "__class__", @@ -106,9 +151,52 @@ def __init__( self["parameters"] = parameters self["multi_value_behavior"] = multi_value_behavior + @classmethod + def from_dict(cls, d: dict) -> "Rule": + """Creates a Rule from a plain dict. + + Args: + d (dict): A dictionary with Rule keys. + + Raises: + KeyError: If d contains unrecognized keys or is missing required + keys. + ValueError: If operator or multi_value_behavior values are not + valid. + + Returns: + 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"], + comparison_value=d["comparison_value"], + parameters=d["parameters"], + multi_value_behavior=d["multi_value_behavior"] + ) + class GroupExpression(_LogicalExpressionBase): _valid_keys = frozenset({"logical_operator", "logical_expressions"}) + logical_operator: LogicalOperator + logical_expressions: list + def __init__( self, logical_operator: LogicalOperator = "and", @@ -118,10 +206,47 @@ def __init__( self["logical_operator"] = logical_operator self["logical_expressions"] = logical_expressions + @classmethod + def from_dict(cls, d: dict) -> "GroupExpression": + """Creates a GroupExpression from a plain dict, recursively converting + nested LogicalExpressions. + + Args: + d (dict): A dictionary with GroupExpression keys. + + Raises: + KeyError: If d contains unrecognized keys or is missing required + keys. + ValueError: If logical_operator is not valid. + + Returns: + 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=[ + logical_expression_from_dict(exp) + for exp in d["logical_expressions"] + ] + ) + class ConditionalExpression(_LogicalExpressionBase): _valid_keys = frozenset({"if", "then", "else"}) _key_aliases = {"_if": "if", "_then": "then", "_else": "else"} + _if: bool | dict + _then: bool | dict + _else: bool | dict + def __init__( self, _if: bool | dict = True, @@ -133,8 +258,89 @@ def __init__( self["then"] = _then self["else"] = _else + @classmethod + def from_dict(cls, d: dict) -> "ConditionalExpression": + """Creates a ConditionalExpression from a plain dict, recursively + converting nested LogicalExpressions. + + Args: + d (dict): A dictionary with ConditionalExpression keys. + + Raises: + KeyError: If d contains unrecognized keys or is missing required + keys. + + Returns: + 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"]), + _else=logical_expression_from_dict(d["else"]) + ) + LogicalExpression = bool | Rule | ConditionalExpression | GroupExpression | ObjectFilter +def _check_keys(d: dict, valid_keys: frozenset[str], class_name: str) -> None: + """Validates that a dict has exactly the expected keys. + + Args: + d (dict): The dictionary to check. + valid_keys (frozenset[str]): The set of required keys. + class_name (str): The name of the target class, for error messages. + + Raises: + KeyError: If there are missing or extra keys. + """ + key_set = set(d.keys()) + missing = valid_keys - key_set + extra = key_set - valid_keys + if missing: + raise KeyError( + f"Missing keys for {class_name}: {sorted(missing)}" + ) + if extra: + raise KeyError( + f"Unrecognized keys for {class_name}: {sorted(extra)}" + ) + +def logical_expression_from_dict(expression: bool | dict) -> LogicalExpression: + """Converts a plain dict (or bool) into the appropriate LogicalExpression + subclass, recursively converting any nested expressions. + + Args: + expression (bool | dict): A bool or dict representing a + LogicalExpression. + + Raises: + TypeError: If expression is not a bool or dict. + ValueError: If the dict's keys do not match any LogicalExpression + type. + + Returns: + LogicalExpression: The constructed LogicalExpression. + """ + if isinstance(expression, bool): + return expression + if isinstance(expression, _LogicalExpressionBase): + return expression + if not isinstance(expression, dict): + raise TypeError( + f"Expected a bool or dict, got {type(expression).__name__}." + ) + expr_type = get_logical_expression_type(expression) + if expr_type == Rule: + return Rule.from_dict(expression) + elif expr_type == ConditionalExpression: + return ConditionalExpression.from_dict(expression) + elif expr_type == GroupExpression: + return GroupExpression.from_dict(expression) + elif expr_type == ObjectFilter: + return ObjectFilter.from_dict(expression) + else: + raise ValueError("Dict keys do not match any LogicalExpression type.") + def filter_criterion(func): """Decorator that whitelists method use for filters. """ diff --git a/tests/test_object_filtering.py b/tests/test_object_filtering.py index d87269a..d51d32b 100644 --- a/tests/test_object_filtering.py +++ b/tests/test_object_filtering.py @@ -767,6 +767,104 @@ def test_invalid_attribute_read(self): with pytest.raises(AttributeError): _ = group.nonexistent + def test_rule_from_dict(self): + d = {"criterion": "area", "operator": ">=", "comparison_value": 4, + "parameters": [], "multi_value_behavior": "none"} + rule = object_filtering.Rule.from_dict(d) + assert isinstance(rule, object_filtering.Rule) + assert rule.criterion == "area" + assert rule.operator == ">=" + assert rule.comparison_value == 4 + + def test_rule_from_dict_invalid_operator(self): + d = {"criterion": "area", "operator": "~", "comparison_value": 4, + "parameters": [], "multi_value_behavior": "none"} + with pytest.raises(ValueError): + object_filtering.Rule.from_dict(d) + + def test_rule_from_dict_invalid_multi_value_behavior(self): + d = {"criterion": "area", "operator": "==", "comparison_value": 4, + "parameters": [], "multi_value_behavior": "bad"} + with pytest.raises(ValueError): + object_filtering.Rule.from_dict(d) + + def test_rule_from_dict_extra_key(self): + d = {"criterion": "area", "operator": "==", "comparison_value": 4, + "parameters": [], "multi_value_behavior": "none", "extra": True} + with pytest.raises(KeyError): + object_filtering.Rule.from_dict(d) + + def test_rule_from_dict_missing_key(self): + d = {"criterion": "area", "operator": "=="} + with pytest.raises(KeyError): + object_filtering.Rule.from_dict(d) + + def test_group_expression_from_dict(self): + d = {"logical_operator": "and", "logical_expressions": [ + {"criterion": "x", "operator": ">=", "comparison_value": 2, + "parameters": [], "multi_value_behavior": "none"}, + True + ]} + group = object_filtering.GroupExpression.from_dict(d) + assert isinstance(group, object_filtering.GroupExpression) + assert isinstance(group.logical_expressions[0], object_filtering.Rule) + assert group.logical_expressions[1] is True + + def test_group_expression_from_dict_invalid_operator(self): + d = {"logical_operator": "xor", "logical_expressions": [True]} + with pytest.raises(ValueError): + object_filtering.GroupExpression.from_dict(d) + + def test_conditional_expression_from_dict(self): + d = {"if": {"criterion": "x", "operator": ">=", "comparison_value": 1, + "parameters": [], "multi_value_behavior": "none"}, + "then": True, "else": False} + cond = object_filtering.ConditionalExpression.from_dict(d) + assert isinstance(cond, object_filtering.ConditionalExpression) + assert isinstance(cond["if"], object_filtering.Rule) + assert cond["then"] is True + assert cond["else"] is False + + def test_object_filter_from_dict(self): + d = { + "name": "Test Filter", + "description": "A test", + "priority": 0, + "object_types": ["Shape"], + "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} + ] + } + } + f = object_filtering.ObjectFilter.from_dict(d) + assert isinstance(f, object_filtering.ObjectFilter) + assert f.name == "Test Filter" + group = f.logical_expression + assert isinstance(group, object_filtering.GroupExpression) + assert isinstance(group.logical_expressions[0], object_filtering.Rule) + assert isinstance(group.logical_expressions[1], object_filtering.ConditionalExpression) + + def test_logical_expression_from_dict_passthrough(self): + rule = object_filtering.Rule("x", "==", 1, [], "none") + result = object_filtering.logical_expression_from_dict(rule) + assert result is rule + + def test_logical_expression_from_dict_bool(self): + assert object_filtering.logical_expression_from_dict(True) is True + assert object_filtering.logical_expression_from_dict(False) is False + + def test_logical_expression_from_dict_invalid_type(self): + with pytest.raises(TypeError): + object_filtering.logical_expression_from_dict(42) + + class TestMixedTypeFilters(unittest.TestCase): def test_mixed_type_filter(self): shape = Shape(2, 2) From ff719b307e80be129f7f5bf0dacc8fb097266827 Mon Sep 17 00:00:00 2001 From: SmartLamScott Date: Tue, 24 Mar 2026 14:09:01 -0500 Subject: [PATCH 10/13] more natural language tests, $CLASS$ has special explanation --- .../natural_language/natural_language.py | 30 +- tests/test_natural_language.py | 306 +++++++++++++++++- 2 files changed, 329 insertions(+), 7 deletions(-) diff --git a/src/object_filtering/natural_language/natural_language.py b/src/object_filtering/natural_language/natural_language.py index 10b5dff..93190f0 100644 --- a/src/object_filtering/natural_language/natural_language.py +++ b/src/object_filtering/natural_language/natural_language.py @@ -3,7 +3,7 @@ from ..object_filtering import ( ObjectFilter, Rule, GroupExpression, ConditionalExpression, - LogicalExpression, + LogicalExpression, SPECIAL_VARIABLES, ) @@ -17,6 +17,29 @@ ">": "is greater than" } +# Map special variables to English phrases +SPECIAL_VARIABLE_MAP = { + "$CLASS$": { + "==": "the object is a {value}.", + "!=": "the object is not a {value}.", + }, +} + +def _explain_special_variable(rule: Rule) -> str: + """Convert a Rule with a special variable criterion into a natural + language phrase. + + Args: + rule (Rule): A Rule whose criterion is a special variable. + + Returns: + str: A natural language description of the rule. + """ + templates = SPECIAL_VARIABLE_MAP.get(rule.criterion) + if templates and rule.operator in templates: + return templates[rule.operator].format(value=rule.comparison_value) + return f"{rule.criterion} {OPERATOR_MAP[rule.operator]} {rule.comparison_value}." + def explain_expression(expr: LogicalExpression, depth: int = 0) -> str: """Recursively convert a logical expression into an indented, multi-line English-language description. @@ -41,6 +64,11 @@ def explain_expression(expr: LogicalExpression, depth: int = 0) -> str: # Rule objects if isinstance(expr, Rule): + # Special variable handling + if expr.criterion in SPECIAL_VARIABLES: + text = _explain_special_variable(expr) + return f"{indent}{text}" + params = expr.get("parameters", []) if params: if len(params) == 1: diff --git a/tests/test_natural_language.py b/tests/test_natural_language.py index ecb3044..9b857ec 100644 --- a/tests/test_natural_language.py +++ b/tests/test_natural_language.py @@ -1,4 +1,4 @@ -# (c) 2025 Scott Ratchford +# (c) 2026 Scott Ratchford # This file is licensed under the MIT License. See LICENSE.txt for details. import unittest @@ -14,11 +14,11 @@ def __init__(self, x: int | float, y: int | float): @object_filtering.filter_criterion def area(self) -> int | float: return self.x * self.y - + @object_filtering.filter_criterion def volume(self, z: int | float) -> int | float: return self.area() * z - + @object_filtering.filter_criterion def area_if_stretched(self, x_2: int | float, y_2: int | float) -> int | float: return self.x * x_2 + self.y * y_2 @@ -77,7 +77,6 @@ def secret_method(self) -> None: class TestNaturalLanguage(unittest.TestCase): def test_natural_language_explanation(self): - # square = Shape(3, 4) expl = object_filtering.natural_language.explain_filter(large_filter) expected_expl = "Filter \"Shape Area\": Determines whether Shape is large.\n" expected_expl += "This filter applies to objects of type: Shape.\n" @@ -90,5 +89,300 @@ def test_natural_language_explanation(self): assert expl == expected_expl -if __name__ == '__main__': - pytest.main() +class TestExplainExpressionBool(unittest.TestCase): + def test_true(self): + result = object_filtering.explain_expression(True) + assert result == "This condition is always true." + + def test_false(self): + result = object_filtering.explain_expression(False) + assert result == "This condition is always false." + + def test_true_indented(self): + result = object_filtering.explain_expression(True, depth=2) + assert result == " This condition is always true." + + def test_false_indented(self): + result = object_filtering.explain_expression(False, depth=1) + assert result == " This condition is always false." + +class TestExplainExpressionRule(unittest.TestCase): + def test_no_parameters(self): + rule = object_filtering.Rule( + criterion="x", operator=">=", comparison_value=5, + parameters=[], multi_value_behavior="none" + ) + result = object_filtering.explain_expression(rule) + assert result == "x is greater than or equal to 5." + + def test_single_parameter(self): + rule = object_filtering.Rule( + criterion="volume", operator="<", comparison_value=10, + parameters=[3], multi_value_behavior="none" + ) + result = object_filtering.explain_expression(rule) + assert result == "The result of calling volume with parameter 3 is less than 10." + + def test_two_parameters(self): + rule = object_filtering.Rule( + criterion="area_if_stretched", operator="==", comparison_value=12, + parameters=[2, 3], multi_value_behavior="none" + ) + result = object_filtering.explain_expression(rule) + assert result == "The result of calling area_if_stretched with parameters 2 and 3 equals 12." + + def test_three_parameters(self): + rule = object_filtering.Rule( + criterion="foo", operator="!=", comparison_value=0, + parameters=[1, 2, 3], multi_value_behavior="none" + ) + result = object_filtering.explain_expression(rule) + assert result == "The result of calling foo with parameters 1, 2 and 3 does not equal 0." + + def test_all_operators(self): + lines = [] + for op, phrase in [ + ("<", "is less than"), + ("<=", "is less than or equal to"), + ("==", "equals"), + ("!=", "does not equal"), + (">=", "is greater than or equal to"), + (">", "is greater than"), + ]: + rule = object_filtering.Rule( + criterion="x", operator=op, comparison_value=1, + parameters=[], multi_value_behavior="none" + ) + result = object_filtering.explain_expression(rule) + lines.append(f"{op}: {result}") + assert result == f"x {phrase} 1." + + def test_string_comparison_value(self): + rule = object_filtering.Rule( + criterion="name", operator="==", comparison_value="foo", + parameters=[], multi_value_behavior="none" + ) + result = object_filtering.explain_expression(rule) + assert result == "name equals foo." + + def test_class_equals(self): + rule = object_filtering.Rule( + criterion="$CLASS$", operator="==", comparison_value="Shape", + parameters=[], multi_value_behavior="none" + ) + result = object_filtering.explain_expression(rule) + assert result == "the object is a Shape." + + def test_class_not_equals(self): + rule = object_filtering.Rule( + criterion="$CLASS$", operator="!=", comparison_value="Point", + parameters=[], multi_value_behavior="none" + ) + result = object_filtering.explain_expression(rule) + assert result == "the object is not a Point." + +class TestExplainExpressionGroup(unittest.TestCase): + def test_and_group(self): + group = object_filtering.GroupExpression( + logical_operator="and", + logical_expressions=[ + object_filtering.Rule("x", ">=", 1, [], "none"), + object_filtering.Rule("y", "<=", 10, [], "none"), + ] + ) + result = object_filtering.explain_expression(group) + expected = ( + "All of the following conditions must be met:\n" + " - x is greater than or equal to 1.\n" + " - y is less than or equal to 10." + ) + assert result == expected + + def test_or_group(self): + group = object_filtering.GroupExpression( + logical_operator="or", + logical_expressions=[ + object_filtering.Rule("x", ">", 5, [], "none"), + object_filtering.Rule("y", ">", 5, [], "none"), + ] + ) + result = object_filtering.explain_expression(group) + expected = ( + "At least one of the following conditions must be met:\n" + " - x is greater than 5.\n" + " - y is greater than 5." + ) + assert result == expected + + def test_group_with_bool(self): + group = object_filtering.GroupExpression( + logical_operator="and", + logical_expressions=[True, False] + ) + result = object_filtering.explain_expression(group) + expected = ( + "All of the following conditions must be met:\n" + " - This condition is always true.\n" + " - This condition is always false." + ) + assert result == expected + + def test_nested_groups(self): + inner = object_filtering.GroupExpression( + logical_operator="or", + logical_expressions=[ + object_filtering.Rule("x", "==", 1, [], "none"), + object_filtering.Rule("y", "==", 1, [], "none"), + ] + ) + outer = object_filtering.GroupExpression( + logical_operator="and", + logical_expressions=[ + object_filtering.Rule("area", ">", 0, [], "none"), + inner, + ] + ) + result = object_filtering.explain_expression(outer) + expected = ( + "All of the following conditions must be met:\n" + " - area is greater than 0.\n" + " - At least one of the following conditions must be met:\n" + " - x equals 1.\n" + " - y equals 1." + ) + assert result == expected + +class TestExplainExpressionConditional(unittest.TestCase): + def test_simple_conditional(self): + cond = object_filtering.ConditionalExpression( + _if=object_filtering.Rule("x", ">=", 2, [], "none"), + _then=object_filtering.Rule("y", ">=", 2, [], "none"), + _else=False + ) + result = object_filtering.explain_expression(cond) + expected = ( + "If the following condition holds:\n" + " - x is greater than or equal to 2.\n" + "Then:\n" + " - y is greater than or equal to 2.\n" + "Otherwise:\n" + " - This condition is always false." + ) + assert result == expected + + def test_conditional_with_bool_branches(self): + cond = object_filtering.ConditionalExpression( + _if=object_filtering.Rule("$CLASS$", "==", "Shape", [], "none"), + _then=True, + _else=False + ) + result = object_filtering.explain_expression(cond) + expected = ( + "If the following condition holds:\n" + " - the object is a Shape.\n" + "Then:\n" + " - This condition is always true.\n" + "Otherwise:\n" + " - This condition is always false." + ) + assert result == expected + + def test_conditional_with_group_then(self): + cond = object_filtering.ConditionalExpression( + _if=object_filtering.Rule("$CLASS$", "==", "Shape", [], "none"), + _then=object_filtering.GroupExpression("and", [ + object_filtering.Rule("x", ">=", 1, [], "none"), + object_filtering.Rule("y", ">=", 1, [], "none"), + ]), + _else=True + ) + result = object_filtering.explain_expression(cond) + assert "If the following condition holds:" in result + assert "the object is a Shape." in result + assert "All of the following conditions must be met:" in result + assert "x is greater than or equal to 1." in result + assert "y is greater than or equal to 1." in result + assert "This condition is always true." in result + +class TestExplainFilter(unittest.TestCase): + def test_simple_rule_filter(self): + f = object_filtering.ObjectFilter( + name="Area Check", + description="Checks minimum area.", + priority=0, + object_types=["Shape"], + logical_expression=object_filtering.Rule("area", ">=", 4, [], "none") + ) + result = object_filtering.explain_filter(f) + expected = ( + "Filter \"Area Check\": Checks minimum area.\n" + "This filter applies to objects of type: Shape.\n" + "area is greater than or equal to 4." + ) + assert result == expected + + def test_bool_filter(self): + f = object_filtering.ObjectFilter( + name="Pass All", + description="Always passes.", + priority=0, + object_types=["object"], + logical_expression=True + ) + result = object_filtering.explain_filter(f) + expected = ( + "Filter \"Pass All\": Always passes.\n" + "This filter applies to objects of type: object.\n" + "This condition is always true." + ) + assert result == expected + + def test_multiple_object_types(self): + f = object_filtering.ObjectFilter( + name="Multi Type", + description="Accepts multiple types.", + priority=0, + object_types=["Shape", "Point"], + logical_expression=True + ) + result = object_filtering.explain_filter(f) + assert "This filter applies to objects of types: Shape, and Point." in result + + def test_three_object_types(self): + f = object_filtering.ObjectFilter( + name="Tri Type", + description="Three types.", + priority=0, + object_types=["Shape", "Point", "Line"], + logical_expression=True + ) + result = object_filtering.explain_filter(f) + assert "This filter applies to objects of types: Shape, Point, and Line." in result + + def test_conditional_filter(self): + f = object_filtering.ObjectFilter( + name="Conditional Check", + description="Conditionally checks area.", + priority=0, + object_types=["Shape", "Point"], + logical_expression=object_filtering.ConditionalExpression( + _if=object_filtering.Rule("$CLASS$", "==", "Shape", [], "none"), + _then=object_filtering.Rule("area", ">=", 4, [], "none"), + _else=True + ) + ) + result = object_filtering.explain_filter(f) + assert "Filter \"Conditional Check\": Conditionally checks area." in result + assert "If the following condition holds:" in result + assert "the object is a Shape." in result + assert "area is greater than or equal to 4." in result + assert "This condition is always true." in result + +class TestExplainExpressionInvalid(unittest.TestCase): + def test_invalid_type(self): + with pytest.raises(TypeError): + object_filtering.explain_expression(42) + + def test_plain_dict(self): + with pytest.raises(TypeError): + object_filtering.explain_expression({"criterion": "x"}) From 8f3185a88853cc2f8d2b9136320704a1ff2040bc Mon Sep 17 00:00:00 2001 From: SmartLamScott Date: Tue, 24 Mar 2026 14:09:53 -0500 Subject: [PATCH 11/13] test case ensuring $CLASS$ cannot be compared with an operator that is not == or != --- .gitignore | 3 ++- tests/test_object_filtering.py | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8666592..bf2703d 100644 --- a/.gitignore +++ b/.gitignore @@ -162,4 +162,5 @@ cython_debug/ #.idea/ /docs/object_filtering.ebnf /dist_old -/.vscode \ No newline at end of file +/.vscode +temp/ diff --git a/tests/test_object_filtering.py b/tests/test_object_filtering.py index d51d32b..204aa3e 100644 --- a/tests/test_object_filtering.py +++ b/tests/test_object_filtering.py @@ -898,6 +898,18 @@ def test_class_variable_rule_validity(self): with pytest.raises(object_filtering.FilterError): object_filtering.is_rule_valid(RULE_CLASS_INVALID_OP, SHAPE_BIG) + def test_class_variable_invalid_operators(self): + for op in ("<", "<=", ">=", ">"): + rule = { + "criterion": "$CLASS$", + "operator": op, + "comparison_value": "Shape", + "parameters": [], + "multi_value_behavior": "none" + } + with pytest.raises(object_filtering.FilterError): + object_filtering.is_rule_valid(rule, SHAPE_BIG) + def test_class_variable_rule_execution(self): assert object_filtering.execute_rule_on_object(SHAPE_BIG, RULE_CLASS_EQ) assert object_filtering.execute_rule_on_object(SHAPE_BIG, RULE_CLASS_NEQ) From 87669bcd45f717c964cdb095be93554aa711bfe1 Mon Sep 17 00:00:00 2001 From: SmartLamScott Date: Tue, 24 Mar 2026 14:32:08 -0500 Subject: [PATCH 12/13] dict to LogicalExpression function --- src/object_filtering/__init__.py | 2 + src/object_filtering/object_filtering.py | 23 +++ tests/test_object_filtering.py | 193 ++++++++++++++++++++++- 3 files changed, 214 insertions(+), 4 deletions(-) diff --git a/src/object_filtering/__init__.py b/src/object_filtering/__init__.py index a389150..12c5191 100644 --- a/src/object_filtering/__init__.py +++ b/src/object_filtering/__init__.py @@ -8,6 +8,7 @@ VALID_MULTI_VALUE_BEHAVIORS, SPECIAL_VARIABLES, CLASS_VARIABLE_OPERATORS, + _LogicalExpressionBase, ObjectFilter, Rule, GroupExpression, @@ -42,6 +43,7 @@ MultiValueBehavior, ClassVariableOperator, logical_expression_from_dict, + dict_to_logical_expression, ) from .natural_language import ( diff --git a/src/object_filtering/object_filtering.py b/src/object_filtering/object_filtering.py index dba1c2f..2cf9bb1 100644 --- a/src/object_filtering/object_filtering.py +++ b/src/object_filtering/object_filtering.py @@ -398,6 +398,29 @@ def type_name_matches(obj: Any, target_type_names: Iterable[str]) -> bool: return False +def dict_to_logical_expression(d: dict) -> LogicalExpression: + """Converts a plain dict into the appropriate LogicalExpression subclass, + recursively converting any nested expressions. + + Args: + d (dict): A dict whose keys match a LogicalExpression type. + + Raises: + ValueError: If the dict's keys do not match any LogicalExpression type. + + Returns: + LogicalExpression: The constructed LogicalExpression. + """ + expr_type = get_logical_expression_type(d) + if expr_type == Rule: + return Rule.from_dict(d) + elif expr_type == ConditionalExpression: + return ConditionalExpression.from_dict(d) + elif expr_type == GroupExpression: + return GroupExpression.from_dict(d) + elif expr_type == ObjectFilter: + return ObjectFilter.from_dict(d) + def get_logical_expression_type(expression: LogicalExpression) -> type: """Determines the type of a LogicalExpression based on its contents. diff --git a/tests/test_object_filtering.py b/tests/test_object_filtering.py index 204aa3e..bc474cf 100644 --- a/tests/test_object_filtering.py +++ b/tests/test_object_filtering.py @@ -1,8 +1,10 @@ -# (c) 2025 Scott Ratchford +# (c) 2026 Scott Ratchford # This file is licensed under the MIT License. See LICENSE.txt for details. import unittest + from src import object_filtering + import pytest @@ -864,6 +866,192 @@ def test_logical_expression_from_dict_invalid_type(self): with pytest.raises(TypeError): object_filtering.logical_expression_from_dict(42) + def test_isinstance_rule(self): + rule = object_filtering.Rule("area", "==", 2, [], "none") + assert isinstance(rule, object_filtering.Rule) + assert isinstance(rule, object_filtering._LogicalExpressionBase) + assert isinstance(rule, dict) + + def test_isinstance_group_expression(self): + group = object_filtering.GroupExpression("and", [True]) + assert isinstance(group, object_filtering.GroupExpression) + assert isinstance(group, object_filtering._LogicalExpressionBase) + assert isinstance(group, dict) + + def test_isinstance_conditional_expression(self): + cond = object_filtering.ConditionalExpression(True, False, True) + assert isinstance(cond, object_filtering.ConditionalExpression) + assert isinstance(cond, object_filtering._LogicalExpressionBase) + assert isinstance(cond, dict) + + def test_isinstance_object_filter(self): + f = object_filtering.ObjectFilter("test", "desc", 0, ["obj"], True) + assert isinstance(f, object_filtering.ObjectFilter) + assert isinstance(f, object_filtering._LogicalExpressionBase) + assert isinstance(f, dict) + + def test_isinstance_bool_not_base(self): + assert not isinstance(True, object_filtering._LogicalExpressionBase) + assert not isinstance(False, object_filtering._LogicalExpressionBase) + + def test_subclass_hierarchy(self): + assert issubclass(object_filtering.Rule, object_filtering._LogicalExpressionBase) + assert issubclass(object_filtering.GroupExpression, object_filtering._LogicalExpressionBase) + assert issubclass(object_filtering.ConditionalExpression, object_filtering._LogicalExpressionBase) + assert issubclass(object_filtering.ObjectFilter, object_filtering._LogicalExpressionBase) + assert not issubclass(bool, object_filtering._LogicalExpressionBase) + + def test_dict_with_rule_criteria_is_not_rule(self): + d = { + "criterion": "area", + "operator": ">=", + "comparison_value": 4, + "parameters": [], + "multi_value_behavior": "none" + } + assert not isinstance(d, object_filtering.Rule) + assert not isinstance(d, object_filtering._LogicalExpressionBase) + assert object_filtering.get_logical_expression_type(d) is object_filtering.Rule + + def test_dict_with_group_criteria_is_not_group(self): + d = { + "logical_operator": "and", + "logical_expressions": [True] + } + assert not isinstance(d, object_filtering.GroupExpression) + assert not isinstance(d, object_filtering._LogicalExpressionBase) + assert object_filtering.get_logical_expression_type(d) is object_filtering.GroupExpression + + def test_dict_with_conditional_criteria_is_not_conditional(self): + d = { + "if": True, + "then": True, + "else": False + } + assert not isinstance(d, object_filtering.ConditionalExpression) + assert not isinstance(d, object_filtering._LogicalExpressionBase) + assert object_filtering.get_logical_expression_type(d) is object_filtering.ConditionalExpression + + def test_dict_with_object_filter_criteria_is_not_object_filter(self): + d = { + "name": "test", + "description": "desc", + "priority": 0, + "object_types": ["obj"], + "logical_expression": True + } + assert not isinstance(d, object_filtering.ObjectFilter) + assert not isinstance(d, object_filtering._LogicalExpressionBase) + assert object_filtering.get_logical_expression_type(d) is object_filtering.ObjectFilter + +class TestDictToLogicalExpression(unittest.TestCase): + def test_dict_to_rule(self): + d = { + "criterion": "area", + "operator": ">=", + "comparison_value": 4, + "parameters": [], + "multi_value_behavior": "none" + } + result = object_filtering.dict_to_logical_expression(d) + assert isinstance(result, object_filtering.Rule) + assert result.criterion == "area" + assert result.operator == ">=" + assert result.comparison_value == 4 + + def test_dict_to_group_expression(self): + d = { + "logical_operator": "and", + "logical_expressions": [ + { + "criterion": "x", + "operator": ">=", + "comparison_value": 2, + "parameters": [], + "multi_value_behavior": "none" + }, + True + ] + } + result = object_filtering.dict_to_logical_expression(d) + assert isinstance(result, object_filtering.GroupExpression) + assert result.logical_operator == "and" + assert isinstance(result.logical_expressions[0], object_filtering.Rule) + assert result.logical_expressions[1] is True + + def test_dict_to_conditional_expression(self): + d = { + "if": { + "criterion": "x", + "operator": ">=", + "comparison_value": 1, + "parameters": [], + "multi_value_behavior": "none" + }, + "then": True, + "else": False + } + result = object_filtering.dict_to_logical_expression(d) + assert isinstance(result, object_filtering.ConditionalExpression) + assert isinstance(result["if"], object_filtering.Rule) + assert result["then"] is True + assert result["else"] is False + + def test_dict_to_object_filter(self): + d = { + "name": "Test", + "description": "desc", + "priority": 0, + "object_types": ["Shape"], + "logical_expression": True + } + result = object_filtering.dict_to_logical_expression(d) + assert isinstance(result, object_filtering.ObjectFilter) + assert result.name == "Test" + assert result.logical_expression is True + + def test_dict_to_object_filter_nested(self): + d = { + "name": "Nested", + "description": "desc", + "priority": 1, + "object_types": ["Shape"], + "logical_expression": { + "logical_operator": "or", + "logical_expressions": [ + { + "criterion": "x", + "operator": "==", + "comparison_value": 1, + "parameters": [], + "multi_value_behavior": "none" + }, + { + "if": True, + "then": False, + "else": True + } + ] + } + } + result = object_filtering.dict_to_logical_expression(d) + assert isinstance(result, object_filtering.ObjectFilter) + group = result.logical_expression + assert isinstance(group, object_filtering.GroupExpression) + assert isinstance(group.logical_expressions[0], object_filtering.Rule) + assert isinstance(group.logical_expressions[1], object_filtering.ConditionalExpression) + + def test_invalid_keys_raises_value_error(self): + d = { + "foo": "bar", + "baz": 123 + } + with pytest.raises(ValueError): + object_filtering.dict_to_logical_expression(d) + + def test_empty_dict_raises_value_error(self): + with pytest.raises(ValueError): + object_filtering.dict_to_logical_expression({}) class TestMixedTypeFilters(unittest.TestCase): def test_mixed_type_filter(self): @@ -941,6 +1129,3 @@ def test_class_variable_with_object_wrapper(self): multi_wrapper = object_filtering.ObjectWrapper([SHAPE_1, SHAPE_2]) assert object_filtering.get_value(multi_wrapper, RULE_CLASS_EQ) == ["Shape", "Shape"] - -if __name__ == '__main__': - pytest.main() From f6280c093b5a6153bb246e322be13eec8c317b15 Mon Sep 17 00:00:00 2001 From: SmartLamScott Date: Tue, 24 Mar 2026 14:40:29 -0500 Subject: [PATCH 13/13] implicitly perform type conversions from dict to LogicalExpression --- src/object_filtering/object_filtering.py | 36 ++++- tests/test_object_filtering.py | 163 +++++++++++++++++++++++ 2 files changed, 196 insertions(+), 3 deletions(-) diff --git a/src/object_filtering/object_filtering.py b/src/object_filtering/object_filtering.py index 2cf9bb1..aa5ed68 100644 --- a/src/object_filtering/object_filtering.py +++ b/src/object_filtering/object_filtering.py @@ -468,6 +468,8 @@ def is_logical_expression_valid(expression: LogicalExpression, obj: Any = None) Returns: bool: Whether the LogicalExpression is valid. """ + if isinstance(expression, dict) and not isinstance(expression, _LogicalExpressionBase): + expression = dict_to_logical_expression(expression) expr_type = get_logical_expression_type(expression) if expr_type == bool: # True and False are both valid @@ -504,6 +506,8 @@ def is_rule_valid(rule: dict, obj: Any = None) -> bool: - comparison_value: The value to compare the value of the criterion with. - parameters (list): Passed into the method if the criterion is a method. """ + if isinstance(rule, dict) and not isinstance(rule, _LogicalExpressionBase): + rule = dict_to_logical_expression(rule) if get_logical_expression_type(rule) != Rule: raise FilterError("rule is not a Rule.") # value types @@ -561,6 +565,8 @@ def is_conditional_expression_valid(expression: ConditionalExpression, obj: Any - else (LogicalExpression): The LogicalExpression to evaluate if the "if" branch evaluates to False. """ + if isinstance(expression, dict) and not isinstance(expression, _LogicalExpressionBase): + expression = dict_to_logical_expression(expression) if get_logical_expression_type(expression) != ConditionalExpression: raise FilterError("expression is not a ConditionalExpression.") return all([is_logical_expression_valid(exp, obj) for exp in expression.values()]) @@ -583,6 +589,8 @@ def is_group_expression_valid(expression: GroupExpression, obj: Any = None) -> b - logical_expressions (list[LogicalExpression]): The LogicalExpressions to evaluate. """ + if isinstance(expression, dict) and not isinstance(expression, _LogicalExpressionBase): + expression = dict_to_logical_expression(expression) if not expression["logical_operator"] in VALID_LOGICAL_OPERATORS: # must be "and" or "or" raise FilterError("expression logical_operator is not a valid logical operator.") return all([is_logical_expression_valid(exp, obj) for exp in expression["logical_expressions"]]) @@ -612,6 +620,8 @@ def is_filter_valid(filter: ObjectFilter, obj: Any = None) -> bool: - multi_value_behavior (str): A string that determines what happens to values returned by an ObjectWrapper. """ + if isinstance(filter, dict) and not isinstance(filter, _LogicalExpressionBase): + filter = dict_to_logical_expression(filter) # sanity check on dict size if getsizeof(filter) > 102400: raise ValueError("Size of filter dictionary must be less than or equal to " + \ @@ -661,9 +671,11 @@ def sanitize_filter(filter: ObjectFilter) -> ObjectFilter: ObjectFilter: The new ObjectFilter, with all characters outside of the ASCII range 32 to 126 removed. """ + if isinstance(filter, dict) and not isinstance(filter, _LogicalExpressionBase): + filter = dict_to_logical_expression(filter) if not isinstance(filter, dict): raise TypeError("filter must be an ObjectFilter.") - + sanitized = {} for key, value in filter.items(): if isinstance(value, dict): @@ -695,6 +707,8 @@ def get_value(obj: Any, rule: dict) -> Any: Returns: Any: The value of the attribute of `obj`. """ + if isinstance(rule, dict) and not isinstance(rule, _LogicalExpressionBase): + rule = dict_to_logical_expression(rule) # special variable handling if rule["criterion"] == "$CLASS$": if isinstance(obj, ObjectWrapper): @@ -739,6 +753,8 @@ def execute_logical_expression_on_object(obj: Any, expression: LogicalExpression Returns: bool: The evaluation of the LogicalExpression """ + if isinstance(expression, dict) and not isinstance(expression, _LogicalExpressionBase): + expression = dict_to_logical_expression(expression) expression_type = get_logical_expression_type(expression) if expression_type == bool: return expression @@ -795,9 +811,11 @@ def execute_rule_on_object(obj: Any, rule: dict) -> bool: Returns: bool: The result of the comparison. """ + if isinstance(rule, dict) and not isinstance(rule, _LogicalExpressionBase): + rule = dict_to_logical_expression(rule) if get_logical_expression_type(rule) != Rule: raise ValueError("rule is not a Rule.") - + obj_value = get_value(obj, rule) operator = rule["operator"] comparison_value = rule["comparison_value"] @@ -839,6 +857,8 @@ def execute_conditional_expression_on_object(obj: Any, expression: dict) -> bool Returns: bool: The evaluation of the conditional expression. """ + if isinstance(expression, dict) and not isinstance(expression, _LogicalExpressionBase): + expression = dict_to_logical_expression(expression) if get_logical_expression_type(expression) != ConditionalExpression: raise ValueError("expression is not a ConditionalExpression.") if execute_logical_expression_on_object(obj, expression["if"]): @@ -861,6 +881,8 @@ def execute_group_expression_on_object(obj: Any, expression: GroupExpression) -> Returns: bool: The evaluation of the GroupExpression. """ + if isinstance(expression, dict) and not isinstance(expression, _LogicalExpressionBase): + expression = dict_to_logical_expression(expression) if get_logical_expression_type(expression) != GroupExpression: raise ValueError("expression is not a GroupExpression.") if expression["logical_operator"] == "and": @@ -888,11 +910,13 @@ def execute_filter_on_object(obj, filter: ObjectFilter, sanitize: bool = True) - bool: Whether all the LogicalExpressions in the ObjectFilter evaluated to True. """ + if isinstance(filter, dict) and not isinstance(filter, _LogicalExpressionBase): + filter = dict_to_logical_expression(filter) if sanitize: filter = sanitize_filter(filter) if not is_filter_valid(filter, obj): raise ValueError("ObjectFilter is not valid.") - + return execute_logical_expression_on_object(obj, filter["logical_expression"]) def execute_filter_on_array(obj_array: np.ndarray[Any], filter: dict, sanitize: bool = True) -> np.ndarray[bool]: @@ -913,6 +937,8 @@ def execute_filter_on_array(obj_array: np.ndarray[Any], filter: dict, sanitize: np.ndarray[bool]: For each element of obj_array, whether the ObjectFilter evaluated to True. """ + if isinstance(filter, dict) and not isinstance(filter, _LogicalExpressionBase): + filter = dict_to_logical_expression(filter) if sanitize: filter = sanitize_filter(filter) # use first element because np.ndarray element types are homogeneous @@ -922,6 +948,7 @@ def execute_filter_on_array(obj_array: np.ndarray[Any], filter: dict, sanitize: return np.array([execute_filter_on_object(obj, filter, sanitize=False) for obj in obj_array], dtype=bool) def sort_filter_list(filter_list: list[dict]) -> list[dict]: + filter_list = [dict_to_logical_expression(f) if isinstance(f, dict) and not isinstance(f, _LogicalExpressionBase) else f for f in filter_list] return sorted(filter_list, key=lambda x: (x["priority"], x["name"])) def execute_filter_list_on_object( @@ -947,6 +974,7 @@ def execute_filter_list_on_object( np.ndarray[bool]: For each ObjectFilter, whether it evaluated to True on `obj`. """ + filter_list = [dict_to_logical_expression(f) if isinstance(f, dict) and not isinstance(f, _LogicalExpressionBase) else f for f in filter_list] filter_list = sort_filter_list(filter_list) if sanitize: filter_list = [sanitize_filter(f) for f in filter_list] @@ -971,6 +999,7 @@ def execute_filter_list_on_array( np.ndarray[bool]: For each element of `obj_array`, whether the ObjectFilter list evaluated to True. """ + filter_list = [dict_to_logical_expression(f) if isinstance(f, dict) and not isinstance(f, _LogicalExpressionBase) else f for f in filter_list] filter_list = sort_filter_list(filter_list) if sanitize: filter_list = [sanitize_filter(f) for f in filter_list] @@ -1003,6 +1032,7 @@ def execute_filter_list_on_object_get_first_success( Returns: str: The name of the first successful ObjectFilter in `filter_list` """ + filter_list = [dict_to_logical_expression(f) if isinstance(f, dict) and not isinstance(f, _LogicalExpressionBase) else f for f in filter_list] filter_list = sort_filter_list(filter_list) results = execute_filter_list_on_object(obj, filter_list, sanitize=sanitize) for index, passed in enumerate(results): diff --git a/tests/test_object_filtering.py b/tests/test_object_filtering.py index bc474cf..9f48970 100644 --- a/tests/test_object_filtering.py +++ b/tests/test_object_filtering.py @@ -1053,6 +1053,169 @@ def test_empty_dict_raises_value_error(self): with pytest.raises(ValueError): object_filtering.dict_to_logical_expression({}) +class TestImplicitDictConversion(unittest.TestCase): + """Tests that plain dicts are implicitly converted to LogicalExpression + types when passed into functions, without mutating the original dict.""" + + RULE_DICT = { + "criterion": "area", + "operator": "==", + "comparison_value": 2, + "parameters": [], + "multi_value_behavior": "none" + } + GROUP_DICT = { + "logical_operator": "and", + "logical_expressions": [ + { + "criterion": "area", + "operator": "==", + "comparison_value": 2, + "parameters": [], + "multi_value_behavior": "none" + } + ] + } + COND_DICT = { + "if": { + "criterion": "area", + "operator": "==", + "comparison_value": 2, + "parameters": [], + "multi_value_behavior": "none" + }, + "then": True, + "else": False + } + FILTER_DICT = { + "name": "test", + "description": "test", + "priority": 0, + "object_types": ["Shape"], + "logical_expression": { + "criterion": "area", + "operator": "==", + "comparison_value": 2, + "parameters": [], + "multi_value_behavior": "none" + } + } + + def _assert_still_plain_dict(self, d): + assert type(d) is dict + + def test_execute_logical_expression_on_object_with_dict(self): + d = dict(self.RULE_DICT) + assert object_filtering.execute_logical_expression_on_object(SHAPE_1, d) + self._assert_still_plain_dict(d) + + def test_execute_rule_on_object_with_dict(self): + d = dict(self.RULE_DICT) + assert object_filtering.execute_rule_on_object(SHAPE_1, d) + self._assert_still_plain_dict(d) + + def test_execute_conditional_expression_on_object_with_dict(self): + d = { + "if": dict(self.RULE_DICT), + "then": True, + "else": False + } + assert object_filtering.execute_conditional_expression_on_object(SHAPE_1, d) + self._assert_still_plain_dict(d) + + def test_execute_group_expression_on_object_with_dict(self): + d = { + "logical_operator": "and", + "logical_expressions": [dict(self.RULE_DICT)] + } + assert object_filtering.execute_group_expression_on_object(SHAPE_1, d) + self._assert_still_plain_dict(d) + + def test_execute_filter_on_object_with_dict(self): + d = dict(self.FILTER_DICT) + assert object_filtering.execute_filter_on_object(SHAPE_1, d) + self._assert_still_plain_dict(d) + + def test_is_logical_expression_valid_with_dict(self): + d = dict(self.RULE_DICT) + assert object_filtering.is_logical_expression_valid(d, SHAPE_1) + self._assert_still_plain_dict(d) + + def test_is_rule_valid_with_dict(self): + d = dict(self.RULE_DICT) + assert object_filtering.is_rule_valid(d, SHAPE_1) + self._assert_still_plain_dict(d) + + def test_is_conditional_expression_valid_with_dict(self): + d = { + "if": dict(self.RULE_DICT), + "then": True, + "else": False + } + assert object_filtering.is_conditional_expression_valid(d, SHAPE_1) + self._assert_still_plain_dict(d) + + def test_is_group_expression_valid_with_dict(self): + d = { + "logical_operator": "and", + "logical_expressions": [dict(self.RULE_DICT)] + } + assert object_filtering.is_group_expression_valid(d, SHAPE_1) + self._assert_still_plain_dict(d) + + def test_is_filter_valid_with_dict(self): + d = dict(self.FILTER_DICT) + assert object_filtering.is_filter_valid(d, SHAPE_1) + self._assert_still_plain_dict(d) + + def test_get_value_with_dict(self): + d = dict(self.RULE_DICT) + assert object_filtering.get_value(SHAPE_1, d) == 2 + self._assert_still_plain_dict(d) + + def test_sanitize_filter_with_dict(self): + d = dict(self.FILTER_DICT) + result = object_filtering.sanitize_filter(d) + self._assert_still_plain_dict(d) + assert isinstance(result, dict) + + def test_sort_filter_list_with_dicts(self): + d1 = dict(self.FILTER_DICT) + d2 = { + **self.FILTER_DICT, + "priority": 1, + "name": "another" + } + result = object_filtering.sort_filter_list([d2, d1]) + self._assert_still_plain_dict(d1) + self._assert_still_plain_dict(d2) + assert result[0]["name"] == "test" + + def test_execute_filter_on_object_invalid_dict_raises_value_error(self): + d = { + "foo": "bar", + "baz": 123 + } + with pytest.raises(ValueError): + object_filtering.execute_filter_on_object(SHAPE_1, d) + + def test_execute_rule_on_object_invalid_dict_raises_value_error(self): + d = { + "foo": "bar", + "baz": 123 + } + with pytest.raises(ValueError): + object_filtering.execute_rule_on_object(SHAPE_1, d) + + def test_is_logical_expression_valid_invalid_dict_raises_value_error(self): + d = { + "foo": "bar", + "baz": 123 + } + with pytest.raises(ValueError): + object_filtering.is_logical_expression_valid(d) + + class TestMixedTypeFilters(unittest.TestCase): def test_mixed_type_filter(self): shape = Shape(2, 2)