pyspecification 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ from .compilers import ExpressionWrapperDict, PredicateCompiler, PredicateDict
2
+ from .exceptions import (
3
+ CompilationError,
4
+ ProcessArgumentError,
5
+ RuleAlreadyRegisteredError,
6
+ RuleKeyDoesNotExistError,
7
+ RuleNotRegisteredError,
8
+ )
9
+ from .json_schema import get_json_schema
10
+ from .predicate import OperatorType, Predicate
11
+ from .registry import ObjectRulesRegistry, SubscriptableRulesRegistry
12
+ from .rules import object_rule, subscriptable_rule
13
+ from .schemas import RuleSchema
14
+
15
+ __all__ = [
16
+ "CompilationError",
17
+ "ExpressionWrapperDict",
18
+ "ObjectRulesRegistry",
19
+ "OperatorType",
20
+ "Predicate",
21
+ "PredicateCompiler",
22
+ "PredicateDict",
23
+ "ProcessArgumentError",
24
+ "RuleAlreadyRegisteredError",
25
+ "RuleKeyDoesNotExistError",
26
+ "RuleNotRegisteredError",
27
+ "RuleSchema",
28
+ "SubscriptableRulesRegistry",
29
+ "get_json_schema",
30
+ "object_rule",
31
+ "subscriptable_rule",
32
+ ]
@@ -0,0 +1,177 @@
1
+ from collections.abc import Callable
2
+ from typing import Any, Literal, TypedDict, TypeGuard
3
+
4
+ from .exceptions import CompilationError
5
+ from .predicate import Predicate, ReturnType
6
+
7
+ type ExpressionDict = ExpressionWrapperDict | PredicateDict
8
+
9
+
10
+ PREDICATE_DICT_KEYS = {"name", "args", "kwargs", "inverse"}
11
+ EXPRESSION_WRAPPER_DICT_KEYS = {"operator", "expressions", "inverse"}
12
+
13
+
14
+ class PredicateDict(TypedDict):
15
+ """A dictionary representation of a predicate."""
16
+
17
+ name: str
18
+ args: list[Any]
19
+ kwargs: dict[str, Any]
20
+ inverse: bool
21
+
22
+
23
+ class ExpressionWrapperDict(TypedDict):
24
+ """A dictionary representation of an expression."""
25
+
26
+ operator: Literal["and", "or"]
27
+ expressions: list["ExpressionWrapperDict | PredicateDict"]
28
+ inverse: bool
29
+
30
+
31
+ def is_predicate_dict(d: ExpressionDict | dict[str, Any]) -> TypeGuard[PredicateDict]:
32
+ return all(key in d for key in PREDICATE_DICT_KEYS) and len(d) == len(PREDICATE_DICT_KEYS)
33
+
34
+
35
+ def is_expression_wrapper_dict(
36
+ d: ExpressionDict | dict[str, Any],
37
+ ) -> TypeGuard[ExpressionWrapperDict]:
38
+ return all(key in d for key in EXPRESSION_WRAPPER_DICT_KEYS) and len(d) == len(
39
+ EXPRESSION_WRAPPER_DICT_KEYS
40
+ )
41
+
42
+
43
+ class PredicateCompiler[T, R: ReturnType]:
44
+ """A compiler for predicates.
45
+
46
+ Args:
47
+ rules (dict[str, Callable[..., Predicate[Any, Any]]]): The rules to use for compiling.
48
+ initial_predicate_factory (Callable[[ExpressionWrapperDict], Predicate[Any, Any]]): The factory to use for creating initial predicates.
49
+
50
+ Example:
51
+ ```python
52
+ from pyspecification import Predicate, PredicateCompiler, object_rule
53
+
54
+
55
+ @object_rule
56
+ def is_admin(user: User) -> bool:
57
+ return user.is_admin
58
+
59
+
60
+ @object_rule
61
+ def name__istartswith(user: User, value: str) -> bool:
62
+ return user.name.lower().startswith(value.lower())
63
+
64
+
65
+ @object_rule
66
+ def age__between(user: User, min_age: int, max_age: int) -> bool:
67
+ return user.age >= min_age and user.age <= max_age
68
+
69
+
70
+ def main() -> None:
71
+ rules = {
72
+ "is_admin": is_admin,
73
+ "name__istartswith": name__istartswith,
74
+ "age__between": age__between,
75
+ }
76
+
77
+ compiler = PredicateCompiler(
78
+ rules,
79
+ lambda schema: Predicate(lambda _: schema["operator"] == "and"),
80
+ )
81
+
82
+ rule_data = {
83
+ "operator": "or",
84
+ "inverse": False,
85
+ "expressions": [
86
+ {
87
+ "name": "is_admin",
88
+ "inverse": False,
89
+ "args": [],
90
+ "kwargs": {},
91
+ },
92
+ {
93
+ "operator": "and",
94
+ "inverse": False,
95
+ "expressions": [
96
+ {
97
+ "name": "name__istartswith",
98
+ "inverse": False,
99
+ "args": ["admin"],
100
+ "kwargs": {},
101
+ },
102
+ {
103
+ "name": "age__between",
104
+ "inverse": False,
105
+ "args": [18, 30],
106
+ "kwargs": {},
107
+ },
108
+ ],
109
+ },
110
+ ],
111
+ }
112
+
113
+ predicate = compiler.compile(rule_data)
114
+
115
+ assert all(predicate(user) for user in users)
116
+
117
+
118
+ if __name__ == "__main__":
119
+ main()
120
+ ```
121
+
122
+ """ # noqa: E501
123
+
124
+ def __init__(
125
+ self,
126
+ rules: dict[str, Callable[..., Predicate[T, R]]],
127
+ initial_predicate_factory: Callable[[ExpressionWrapperDict], Predicate[T, R]],
128
+ ) -> None:
129
+ self._rules = rules
130
+ self._initial_predicate_factory = initial_predicate_factory
131
+
132
+ def compile(self, expression: ExpressionDict) -> Predicate[T, R]:
133
+ """Compiles an expression into a predicate."""
134
+ if is_expression_wrapper_dict(expression):
135
+ return self._compile_wrapper(expression)
136
+
137
+ if is_predicate_dict(expression):
138
+ return self._compile_single(expression)
139
+
140
+ msg = (
141
+ f"Invalid expression type: {expression}\n"
142
+ f"Expected one of\n"
143
+ "{'expressions': list[ExpressionDict], 'inverse': bool, 'operator': Literal['and', 'or']}\n" # noqa: E501
144
+ "or \n{'name': str, 'inverse': bool, 'args': list, 'kwargs': dict}"
145
+ )
146
+ raise CompilationError(msg)
147
+
148
+ def _compile_single(self, single: PredicateDict) -> Predicate[T, R]:
149
+ if single["name"] not in self._rules:
150
+ msg = (
151
+ f"Rule '{single['name']}' is not found\n"
152
+ f"Available rules: {', '.join(self._rules.keys())}"
153
+ )
154
+ raise CompilationError(msg)
155
+
156
+ predicate = self._rules[single["name"]](*single["args"], **single["kwargs"])
157
+
158
+ if single["inverse"]:
159
+ predicate = ~predicate
160
+
161
+ return predicate
162
+
163
+ def _compile_wrapper(self, wrapper: ExpressionWrapperDict) -> Predicate[T, R]:
164
+ predicate = self._initial_predicate_factory(wrapper)
165
+
166
+ for expression in wrapper["expressions"]:
167
+ compiled_predicate = self.compile(expression)
168
+
169
+ if wrapper["operator"] == "and":
170
+ predicate &= compiled_predicate
171
+ else:
172
+ predicate |= compiled_predicate
173
+
174
+ if wrapper["inverse"]:
175
+ predicate = ~predicate
176
+
177
+ return predicate
@@ -0,0 +1 @@
1
+ RESERVED_WORDS = ("name", "expressions", "operator", "inverse", "args", "kwargs")
@@ -0,0 +1,27 @@
1
+ class RuleNotRegisteredError(Exception):
2
+ """Exception raised when a rule is not registered in registry class."""
3
+
4
+ def __init__(self, name: str) -> None:
5
+ super().__init__(f"Rule '{name}' is not registered")
6
+
7
+
8
+ class RuleAlreadyRegisteredError(Exception):
9
+ """Exception raised when a rule is already registered in registry class."""
10
+
11
+ def __init__(self, name: str) -> None:
12
+ super().__init__(f"Rule '{name}' is already registered")
13
+
14
+
15
+ class RuleKeyDoesNotExistError(Exception):
16
+ """Exception raised when a key does not exist in the object of rule."""
17
+
18
+ def __init__(self, key: str, rule_name: str) -> None:
19
+ super().__init__(f"Key '{key}' does not exist in the object of rule '{rule_name}'")
20
+
21
+
22
+ class CompilationError(Exception):
23
+ """Exception raised when a rule compilation fails."""
24
+
25
+
26
+ class ProcessArgumentError(Exception):
27
+ """Exception raised when a process argument fails in registry class."""
@@ -0,0 +1,64 @@
1
+ from collections.abc import Callable
2
+ from inspect import get_annotations
3
+ from typing import Any
4
+
5
+ from pydantic import TypeAdapter
6
+
7
+
8
+ def get_json_schema(rule: Callable[..., Any]) -> dict[str, Any]:
9
+ """Get the JSON schema for a rule.
10
+
11
+ Args:
12
+ rule (Callable): The rule to get the JSON schema for.
13
+
14
+ Returns:
15
+ dict: The JSON schema for the rule.
16
+
17
+ Example:
18
+ ```python
19
+ from pyspecification import get_json_schema, object_rule
20
+
21
+
22
+ @object_rule
23
+ def is_admin(user: User) -> bool:
24
+ return user.is_admin
25
+
26
+
27
+ @object_rule
28
+ def name__istartswith(user: User, value: str) -> bool:
29
+ return user.name.lower().startswith(value.lower())
30
+
31
+
32
+ @object_rule
33
+ def age__between(user: User, min_age: int, max_age: int) -> bool:
34
+ return user.age >= min_age and user.age <= max_age
35
+
36
+
37
+ def main() -> None:
38
+ schema = get_json_schema(is_admin)
39
+ print(schema)
40
+ # {"return": {"type": "boolean"}}
41
+
42
+ schema = get_json_schema(name__istartswith)
43
+ print(schema)
44
+ # {"value": {"type": "string"}, "return": {"type": "boolean"}}
45
+
46
+ schema = get_json_schema(age__between)
47
+ print(schema)
48
+ # {
49
+ # "min_age": {"type": "integer"},
50
+ # "max_age": {"type": "integer"},
51
+ # "return": {"type": "boolean"},
52
+ # }
53
+
54
+
55
+ if __name__ == "__main__":
56
+ main()
57
+ ```
58
+
59
+ """
60
+ return {
61
+ arg: TypeAdapter(typ).json_schema()
62
+ for idx, (arg, typ) in enumerate(get_annotations(rule).items())
63
+ if idx > 0
64
+ }
@@ -0,0 +1,142 @@
1
+ # ruff: noqa: PGH003, SLF001
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any, Literal, Protocol
5
+
6
+ type OperatorType = Literal["bitwise", "logical"]
7
+
8
+
9
+ class ReturnType(Protocol):
10
+ def __and__(self, value: Any, /) -> Any: ...
11
+ def __or__(self, value: Any, /) -> Any: ...
12
+ def __invert__(self) -> Any: ...
13
+
14
+
15
+ class Predicate[T, R: ReturnType]:
16
+ """A predicate is a function that takes an object of type T and returns a value of type R.
17
+
18
+ Args:
19
+ fn (Callable[[T], R]): The function to wrap.
20
+ operator (Literal["bitwise", "logical"]): The operator to use for combining predicates.
21
+ description (str, optional): A description of the predicate. Defaults to None.
22
+
23
+ Example:
24
+ ```python
25
+ from pyspecification import Predicate
26
+
27
+
28
+ def is_admin(user: User) -> bool:
29
+ return user.is_admin
30
+
31
+
32
+ def name__istartswith(user: User, value: str) -> bool:
33
+ return user.name.lower().startswith(value.lower())
34
+
35
+
36
+ def age__between(user: User, min_age: int, max_age: int) -> bool:
37
+ return user.age >= min_age and user.age <= max_age
38
+
39
+
40
+ is_admin: Predicate[User, bool] = Predicate(
41
+ is_admin,
42
+ operator="logical",
43
+ )
44
+ name__istartswith: Predicate[User, bool] = Predicate(
45
+ name__istartswith,
46
+ operator="logical",
47
+ )
48
+ age__between: Predicate[User, bool] = Predicate(
49
+ age__between,
50
+ operator="logical",
51
+ )
52
+
53
+
54
+ rule = is_admin | (name__istartswith("admin") & age__between(18, 30))
55
+
56
+
57
+ def main() -> None:
58
+ assert rule(User(name="Abdullah", age=18, is_admin=True))
59
+ assert rule(User(name="Abdullah", age=16, is_admin=True))
60
+ assert rule(User(name="admin", age=20, is_admin=False))
61
+
62
+
63
+ if __name__ == "__main__":
64
+ main()
65
+ ```
66
+
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ fn: Callable[[T], R],
72
+ /,
73
+ *,
74
+ operator: OperatorType,
75
+ description: str | None = None,
76
+ ) -> None:
77
+ self._fn = fn
78
+ self._description = description
79
+ self._operator = operator
80
+
81
+ def __call__(self, obj: T) -> R:
82
+ return self._fn(obj)
83
+
84
+ def __and__(self, other: "Predicate[T, R]") -> "Predicate[T, R]":
85
+ self._insure_matching_operators(other)
86
+
87
+ if self._operator == "bitwise":
88
+ return Predicate(
89
+ lambda obj: self(obj) & other(obj),
90
+ description=f"({self} & {other})",
91
+ operator="bitwise",
92
+ )
93
+
94
+ return Predicate(
95
+ lambda obj: self(obj) and other(obj),
96
+ description=f"({self} AND {other})",
97
+ operator="logical",
98
+ )
99
+
100
+ def __or__(self, other: "Predicate[T, R]") -> "Predicate[T, R]":
101
+ self._insure_matching_operators(other)
102
+
103
+ if self._operator == "bitwise":
104
+ return Predicate(
105
+ lambda obj: self(obj) | other(obj),
106
+ description=f"({self} | {other})",
107
+ operator="bitwise",
108
+ )
109
+
110
+ return Predicate(
111
+ lambda obj: self(obj) or other(obj),
112
+ description=f"({self} OR {other})",
113
+ operator="logical",
114
+ )
115
+
116
+ def __invert__(self) -> "Predicate[T, R]":
117
+ if self._operator == "bitwise":
118
+ return Predicate(
119
+ lambda obj: ~self(obj),
120
+ description=f"~{self}",
121
+ operator="bitwise",
122
+ )
123
+
124
+ return Predicate(
125
+ lambda obj: not self(obj), # type: ignore
126
+ description=f"NOT {self}",
127
+ operator="logical",
128
+ )
129
+
130
+ def __str__(self) -> str:
131
+ return self._description or self._fn.__name__ or "Predicate"
132
+
133
+ def __repr__(self) -> str:
134
+ return f"Predicate({self})"
135
+
136
+ def _insure_matching_operators(self, other: "Predicate[T, R]") -> None:
137
+ if self._operator != other._operator:
138
+ msg = (
139
+ "Cannot combine predicates with different operators:"
140
+ f" {self._operator} != {other._operator}"
141
+ )
142
+ raise ValueError(msg)
@@ -0,0 +1,43 @@
1
+ from collections.abc import Callable
2
+ from typing import Any
3
+
4
+ from .exceptions import ProcessArgumentError
5
+
6
+ type ProcessFn = Callable[[Any], Any]
7
+
8
+
9
+ DEFAULT_PROCESSORS: tuple[ProcessFn, dict[str, ProcessFn]] = (lambda value: value, {})
10
+
11
+
12
+ def process_arguments(
13
+ processors: tuple[ProcessFn, dict[str, ProcessFn]],
14
+ *args: Any,
15
+ **kwargs: Any,
16
+ ) -> tuple[tuple[Any, ...], dict[str, Any]]:
17
+ default_fn, processors_map = processors
18
+
19
+ processed_args = []
20
+ current_arg = None
21
+
22
+ try:
23
+ for arg in args:
24
+ current_arg = arg
25
+ processed_args.append(default_fn(arg))
26
+ except Exception as e:
27
+ msg = f"Argument '{current_arg}' failed to process, {e}"
28
+ raise ProcessArgumentError(msg) from e
29
+
30
+ processed_kwargs = {}
31
+ current_key, current_value = None, None
32
+
33
+ try:
34
+ for key, value in kwargs.items():
35
+ current_key, current_value = key, value
36
+ processed_kwargs[key] = processors_map.get(key, default_fn)(value)
37
+ except Exception as e:
38
+ msg = (
39
+ f"Keyword argument '{current_key}' with value '{current_value}' failed to process, {e}"
40
+ )
41
+ raise ProcessArgumentError(msg) from e
42
+
43
+ return tuple(processed_args), processed_kwargs