pyspecification 0.1.0__tar.gz
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.
- pyspecification-0.1.0/PKG-INFO +788 -0
- pyspecification-0.1.0/README.md +773 -0
- pyspecification-0.1.0/pyproject.toml +35 -0
- pyspecification-0.1.0/pyproject.toml.orig +32 -0
- pyspecification-0.1.0/src/pyspecification/__init__.py +32 -0
- pyspecification-0.1.0/src/pyspecification/compilers.py +177 -0
- pyspecification-0.1.0/src/pyspecification/constants.py +1 -0
- pyspecification-0.1.0/src/pyspecification/exceptions.py +27 -0
- pyspecification-0.1.0/src/pyspecification/json_schema.py +64 -0
- pyspecification-0.1.0/src/pyspecification/predicate.py +142 -0
- pyspecification-0.1.0/src/pyspecification/processors.py +43 -0
- pyspecification-0.1.0/src/pyspecification/registry.py +430 -0
- pyspecification-0.1.0/src/pyspecification/rules.py +166 -0
- pyspecification-0.1.0/src/pyspecification/schemas.py +135 -0
- pyspecification-0.1.0/src/pyspecification/validators.py +14 -0
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pyspecification
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A lightweight, typed Python library for composing business rules as reusable, executable predicates.
|
|
5
|
+
Author: aaltatan
|
|
6
|
+
Author-email: aaltatan <a.altatan@gmail.com>
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Dist: pydantic>=2.13.4
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Project-URL: Homepage, https://github.com/aaltatan/pyspecification
|
|
13
|
+
Project-URL: Issues, https://github.com/aaltatan/pyspecification/issues
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# pyspecification
|
|
17
|
+
|
|
18
|
+
A lightweight, typed Python library for composing business rules as reusable, executable predicates.
|
|
19
|
+
|
|
20
|
+
This project was inspired by the work and ideas shared by [ArjanCodes](https://github.com/arjancodes), especially the concepts demonstrated in his video: ["The Most Overengineered Python Pattern I've Ever Built"](https://youtu.be/KqfMiuL3cx4?si=WAn01N2I0OO3KOgc).
|
|
21
|
+
|
|
22
|
+
`pyspecification` focuses on a functional style:
|
|
23
|
+
|
|
24
|
+
- rules are first-class callables
|
|
25
|
+
- predicates compose with `&`, `|`, and `~`
|
|
26
|
+
- registry-based registration keeps rules organized
|
|
27
|
+
- structured rule definitions can be compiled from dictionaries or JSON-like payloads
|
|
28
|
+
- generated schemas make rule metadata portable and machine-readable
|
|
29
|
+
|
|
30
|
+
It is especially useful for filtering, validation, authorization checks, and declarative rule engines without introducing a heavy framework.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Why use pyspecification?
|
|
35
|
+
|
|
36
|
+
This package helps you turn complex condition logic into small, readable, testable rule fragments.
|
|
37
|
+
|
|
38
|
+
Instead of writing nested `if` logic like this:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
if user.is_admin or (user.name.lower().startswith("admin") and 18 <= user.age <= 30):
|
|
42
|
+
allow_access = True
|
|
43
|
+
else:
|
|
44
|
+
allow_access = False
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
you can define rules as composable predicates:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
rule = is_admin() | (name__istartswith("admin") & age__between(18, 30))
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
This keeps your logic:
|
|
54
|
+
|
|
55
|
+
- declarative
|
|
56
|
+
- reusable
|
|
57
|
+
- easy to combine
|
|
58
|
+
- friendly to validation and filtering pipelines
|
|
59
|
+
- easy to inspect and serialize
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Features
|
|
64
|
+
|
|
65
|
+
- Object-based predicate rules for dataclasses and domain models
|
|
66
|
+
- Subscriptable rules for dictionaries, lists, and generic lookup-based data
|
|
67
|
+
- `Predicate` objects that support logical composition
|
|
68
|
+
- Registry pattern for rule registration and lookup
|
|
69
|
+
- Custom argument processors for coercion and normalization
|
|
70
|
+
- `PredicateCompiler` for compiling structured rule dictionaries into executable predicates
|
|
71
|
+
- `RuleSchema` validation for declarative rule payloads
|
|
72
|
+
- JSON schema generation for rule arguments and return types
|
|
73
|
+
- Support for both logical and bitwise operator modes
|
|
74
|
+
- Hidden rules and custom naming for internal/private rule registration
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Installation
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pip install pyspecification
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
uv add pyspecification
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Core concepts
|
|
91
|
+
|
|
92
|
+
### 1. Predicate
|
|
93
|
+
|
|
94
|
+
A `Predicate[T, R]` wraps a function `fn: T -> R` and adds composition behavior.
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from pyspecification import Predicate
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def is_adult(user: object) -> bool:
|
|
101
|
+
return user.age >= 18
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def is_admin_predicate(user: object) -> bool:
|
|
105
|
+
return user.is_admin
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
adult_predicate: Predicate[User, bool] = Predicate(is_adult, operator="logical")
|
|
109
|
+
is_admin_predicate: Predicate[User, bool] = Predicate(is_admin_predicate, operator="logical")
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
You can combine predicates using:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
rule = adult_predicate & is_admin_predicate
|
|
116
|
+
rule = adult_predicate | is_admin_predicate
|
|
117
|
+
rule = ~adult_predicate
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`operator` can be either:
|
|
121
|
+
|
|
122
|
+
- `"logical"` for `and` / `or` / `not`
|
|
123
|
+
- `"bitwise"` for `&` / `|` / `~`
|
|
124
|
+
|
|
125
|
+
When combining predicates, both sides must use the same operator mode.
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
### 2. Object rules
|
|
130
|
+
|
|
131
|
+
Use `@object_rule` to create reusable predicates from object-based functions.
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from dataclasses import dataclass
|
|
135
|
+
|
|
136
|
+
from pyspecification import object_rule
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass
|
|
140
|
+
class User:
|
|
141
|
+
name: str
|
|
142
|
+
age: int
|
|
143
|
+
is_admin: bool = False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@object_rule()
|
|
147
|
+
def name__istartswith(user: User, value: str) -> bool:
|
|
148
|
+
return user.name.lower().startswith(value.lower())
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@object_rule()
|
|
152
|
+
def age__between(user: User, min_age: int, max_age: int) -> bool:
|
|
153
|
+
return user.age >= min_age and user.age <= max_age
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@object_rule()
|
|
157
|
+
def is_admin(user: User) -> bool:
|
|
158
|
+
return user.is_admin
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
rule = is_admin() | (name__istartswith("admin") & age__between(18, 30))
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
This yields a predicate that can be evaluated against a model instance:
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
user = User(name="Abdullah", age=25, is_admin=True)
|
|
168
|
+
print(rule(user)) # True
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The rule function itself is a factory that returns a `Predicate`.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
### 3. Subscriptable rules
|
|
176
|
+
|
|
177
|
+
Use `@subscriptable_rule` for dictionary or list-like lookup data.
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
from typing import Any
|
|
181
|
+
|
|
182
|
+
from pyspecification import subscriptable_rule
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@subscriptable_rule()
|
|
186
|
+
def string__ieq(obj: dict[str, Any], key: str, value: str) -> bool:
|
|
187
|
+
return obj[key].lower() == value.lower()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@subscriptable_rule()
|
|
191
|
+
def number__le(obj: dict[str, Any], key: str, value: int) -> bool:
|
|
192
|
+
return obj[key] <= value
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
rule = string__ieq("gender", "male") & number__le("rank", 10)
|
|
196
|
+
|
|
197
|
+
person = {"gender": "Male", "rank": 9}
|
|
198
|
+
print(rule(person)) # True
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
This pattern is ideal for filtering dictionaries and JSON-like records.
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
### 4. SQLAlchemy integration example
|
|
206
|
+
|
|
207
|
+
One of the strongest real-world use cases is turning rule definitions into SQLAlchemy filter expressions for database queries.
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
from typing import Any
|
|
211
|
+
|
|
212
|
+
from pyspecification import ObjectRulesRegistry, Predicate, PredicateCompiler, RuleSchema
|
|
213
|
+
from sqlalchemy import ColumnElement, and_, create_engine, or_
|
|
214
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sessionmaker
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class Base(DeclarativeBase):
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class User(Base):
|
|
222
|
+
__tablename__ = "users"
|
|
223
|
+
|
|
224
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
225
|
+
name: Mapped[str]
|
|
226
|
+
age: Mapped[int]
|
|
227
|
+
is_admin: Mapped[bool] = mapped_column(default=False)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
engine = create_engine("sqlite:///:memory:")
|
|
231
|
+
SessionLocal = sessionmaker(bind=engine)
|
|
232
|
+
|
|
233
|
+
# create database and seed data
|
|
234
|
+
Base.metadata.create_all(engine)
|
|
235
|
+
with SessionLocal() as session:
|
|
236
|
+
session.add_all(
|
|
237
|
+
[
|
|
238
|
+
User(name="Abdullah", age=18, is_admin=True),
|
|
239
|
+
User(name="Bob", age=16, is_admin=True),
|
|
240
|
+
User(name="Charlie", age=20, is_admin=False),
|
|
241
|
+
User(name="David", age=12, is_admin=False),
|
|
242
|
+
User(name="Eve", age=8, is_admin=True),
|
|
243
|
+
]
|
|
244
|
+
)
|
|
245
|
+
session.commit()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
rules = ObjectRulesRegistry[type[User], ColumnElement[bool]](operator="bitwise")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@rules.rule()
|
|
252
|
+
def is_admin(model: type[User]) -> ColumnElement[bool]:
|
|
253
|
+
return model.is_admin == True # noqa: E712
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@rules.rule()
|
|
257
|
+
def name__iendswith(model: type[User], value: str) -> ColumnElement[bool]:
|
|
258
|
+
return model.name.iendswith(value)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@rules.rule()
|
|
262
|
+
def age__ge(model: type[User], value: int) -> ColumnElement[bool]:
|
|
263
|
+
return model.age >= value
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@rules.rule()
|
|
267
|
+
def age__le(model: type[User], value: int) -> ColumnElement[bool]:
|
|
268
|
+
return model.age <= value
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
compiler = PredicateCompiler(
|
|
272
|
+
rules.rules,
|
|
273
|
+
lambda schema: Predicate(
|
|
274
|
+
lambda _: and_(True) if schema["operator"] == "and" else or_(False),
|
|
275
|
+
operator="bitwise",
|
|
276
|
+
),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
filter_rule_data = {
|
|
280
|
+
"operator": "or",
|
|
281
|
+
"expressions": [
|
|
282
|
+
{"-is_admin": []},
|
|
283
|
+
{"age__ge": [18]},
|
|
284
|
+
],
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
predicate = compiler.compile(RuleSchema(**filter_rule_data).model_dump())
|
|
288
|
+
|
|
289
|
+
with SessionLocal() as session:
|
|
290
|
+
users = session.query(User).filter(predicate(User)).all()
|
|
291
|
+
print([user.name for user in users])
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
This pattern is especially useful when you want:
|
|
295
|
+
|
|
296
|
+
- declarative backend filters
|
|
297
|
+
- admin dashboards with rule-driven queries
|
|
298
|
+
- object-level permission evaluation
|
|
299
|
+
- SQLAlchemy-friendly business logic without hard-coded SQL fragments
|
|
300
|
+
|
|
301
|
+
> The project includes a full end-to-end SQLAlchemy example in the test suite under [tests/e2e/test_sqlalchemy_filtering_system.py](tests/e2e/test_sqlalchemy_filtering_system.py).
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
## Custom processors
|
|
306
|
+
|
|
307
|
+
Rules can apply argument processors to coerce values before evaluation.
|
|
308
|
+
|
|
309
|
+
```python
|
|
310
|
+
from datetime import datetime
|
|
311
|
+
|
|
312
|
+
from pyspecification import SubscriptableRulesRegistry
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
rules = SubscriptableRulesRegistry[dict[str, object], str, bool](operator="logical")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@rules.rule(processors=(lambda value: datetime.strptime(value, "%Y-%m-%d"), {}))
|
|
319
|
+
def datetime__gt(obj: dict[str, object], key: str, value: datetime) -> bool:
|
|
320
|
+
return obj[key] > value
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
predicate = rules["datetime__gt"]("birthdate", "2001-06-01")
|
|
324
|
+
print(predicate({"birthdate": datetime(2005, 1, 1)})) # True
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
The processor tuple format is:
|
|
328
|
+
|
|
329
|
+
```python
|
|
330
|
+
(default_processor, named_processors_map)
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
For example:
|
|
334
|
+
|
|
335
|
+
```python
|
|
336
|
+
processors = (
|
|
337
|
+
int,
|
|
338
|
+
{"value": str},
|
|
339
|
+
)
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
This means:
|
|
343
|
+
|
|
344
|
+
- positional args are passed through `int`
|
|
345
|
+
- keyword args with name `"value"` are passed through `str`
|
|
346
|
+
|
|
347
|
+
If conversion fails, the library raises `ProcessArgumentError`.
|
|
348
|
+
|
|
349
|
+
---
|
|
350
|
+
|
|
351
|
+
## Compiling structured rule definitions
|
|
352
|
+
|
|
353
|
+
`PredicateCompiler` turns declarative rule dictionaries into executable predicates.
|
|
354
|
+
|
|
355
|
+
```python
|
|
356
|
+
from dataclasses import dataclass
|
|
357
|
+
|
|
358
|
+
from pyspecification import Predicate, PredicateCompiler, object_rule
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@dataclass
|
|
362
|
+
class User:
|
|
363
|
+
name: str
|
|
364
|
+
age: int
|
|
365
|
+
is_admin: bool = False
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@object_rule()
|
|
369
|
+
def is_admin(user: User) -> bool:
|
|
370
|
+
return user.is_admin
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@object_rule()
|
|
374
|
+
def name__istartswith(user: User, value: str) -> bool:
|
|
375
|
+
return user.name.lower().startswith(value.lower())
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@object_rule()
|
|
379
|
+
def age__between(user: User, min_age: int, max_age: int) -> bool:
|
|
380
|
+
return user.age >= min_age and user.age <= max_age
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
rules = {
|
|
384
|
+
"is_admin": is_admin,
|
|
385
|
+
"name__istartswith": name__istartswith,
|
|
386
|
+
"age__between": age__between,
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
compiler = PredicateCompiler(
|
|
390
|
+
rules,
|
|
391
|
+
lambda schema: Predicate(lambda _: schema["operator"] == "and", operator="logical"),
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
rule_data = {
|
|
395
|
+
"operator": "or",
|
|
396
|
+
"expressions": [
|
|
397
|
+
{"is_admin": []},
|
|
398
|
+
{
|
|
399
|
+
"expressions": [
|
|
400
|
+
{"name__istartswith": "admin"},
|
|
401
|
+
{"age__between": [18, 30]},
|
|
402
|
+
]
|
|
403
|
+
},
|
|
404
|
+
],
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
predicate = compiler.compile(rule_data)
|
|
408
|
+
print(predicate(User("admin", 25, True))) # True
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
### Supported structured syntax
|
|
412
|
+
|
|
413
|
+
Simple predicate forms:
|
|
414
|
+
|
|
415
|
+
```python
|
|
416
|
+
{"is_admin": []}
|
|
417
|
+
{"-is_admin": []}
|
|
418
|
+
{"name__istartswith": "admin"}
|
|
419
|
+
{"age__between": [18, 30]}
|
|
420
|
+
{"name__startswith": {"value": "admin"}}
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
Nested expressions:
|
|
424
|
+
|
|
425
|
+
```python
|
|
426
|
+
{
|
|
427
|
+
"operator": "and",
|
|
428
|
+
"expressions": [
|
|
429
|
+
{"is_admin": []},
|
|
430
|
+
{"name__istartswith": "admin"},
|
|
431
|
+
],
|
|
432
|
+
}
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
And inverted wrappers:
|
|
436
|
+
|
|
437
|
+
```python
|
|
438
|
+
{
|
|
439
|
+
"operator": "or",
|
|
440
|
+
"inverse": True,
|
|
441
|
+
"expressions": [
|
|
442
|
+
{"is_admin": []},
|
|
443
|
+
{"age__between": [18, 30]},
|
|
444
|
+
],
|
|
445
|
+
}
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
The compiler raises `CompilationError` if a rule name is missing or the payload is malformed.
|
|
449
|
+
|
|
450
|
+
---
|
|
451
|
+
|
|
452
|
+
## Rule schema validation
|
|
453
|
+
|
|
454
|
+
`RuleSchema` validates declarative rule payloads.
|
|
455
|
+
|
|
456
|
+
```python
|
|
457
|
+
from pyspecification import RuleSchema
|
|
458
|
+
|
|
459
|
+
rule_data = {
|
|
460
|
+
"operator": "and",
|
|
461
|
+
"expressions": [
|
|
462
|
+
{"name__startswith": "admin"},
|
|
463
|
+
{"age__gt": 18},
|
|
464
|
+
],
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
schema = RuleSchema(**rule_data)
|
|
468
|
+
print(schema.model_dump())
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
This is useful when you want to validate incoming rule definitions before compile-time execution.
|
|
472
|
+
|
|
473
|
+
You can also use `RuleSchema` to represent nested predicate trees as typed, portable data.
|
|
474
|
+
|
|
475
|
+
---
|
|
476
|
+
|
|
477
|
+
## JSON schema generation
|
|
478
|
+
|
|
479
|
+
`get_json_schema` inspects a rule function and returns JSON-schema-like metadata for parameters and return value.
|
|
480
|
+
|
|
481
|
+
```python
|
|
482
|
+
from dataclasses import dataclass
|
|
483
|
+
|
|
484
|
+
from pyspecification import get_json_schema, object_rule
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
@dataclass
|
|
488
|
+
class User:
|
|
489
|
+
name: str
|
|
490
|
+
age: int
|
|
491
|
+
is_admin: bool = True
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
@object_rule()
|
|
495
|
+
def name__istartswith(user: User, value: str) -> bool:
|
|
496
|
+
return user.name.lower().startswith(value.lower())
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
print(get_json_schema(name__istartswith))
|
|
500
|
+
# {
|
|
501
|
+
# "value": {"type": "string"},
|
|
502
|
+
# "return": {"type": "boolean"},
|
|
503
|
+
# }
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
This is useful for:
|
|
507
|
+
|
|
508
|
+
- generating UIs for rule configuration
|
|
509
|
+
- building admin tools and dashboards
|
|
510
|
+
- describing rule inputs to other systems
|
|
511
|
+
- documenting business rules programmatically
|
|
512
|
+
|
|
513
|
+
---
|
|
514
|
+
|
|
515
|
+
## Use cases
|
|
516
|
+
|
|
517
|
+
### 1. Filtering datasets
|
|
518
|
+
|
|
519
|
+
This library is excellent for building dynamic dataset filters during API requests or internal analytics queries.
|
|
520
|
+
|
|
521
|
+
```python
|
|
522
|
+
from dataclasses import dataclass
|
|
523
|
+
|
|
524
|
+
from pyspecification import ObjectRulesRegistry
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
@dataclass
|
|
528
|
+
class User:
|
|
529
|
+
name: str
|
|
530
|
+
age: int
|
|
531
|
+
is_admin: bool
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
registry = ObjectRulesRegistry[User, bool](operator="logical")
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
@registry.rule()
|
|
538
|
+
def is_admin(user: User) -> bool:
|
|
539
|
+
return user.is_admin
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
@registry.rule()
|
|
543
|
+
def age__gte(user: User, value: int) -> bool:
|
|
544
|
+
return user.age >= value
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
users = [
|
|
548
|
+
User("Alice", 27, True),
|
|
549
|
+
User("Bob", 19, False),
|
|
550
|
+
User("Charlie", 31, True),
|
|
551
|
+
]
|
|
552
|
+
|
|
553
|
+
predicate = registry["is_admin"]() & registry["age__gte"](20)
|
|
554
|
+
filtered = [user for user in users if predicate(user)]
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
### 2. SQLAlchemy-backed filtering and query composition
|
|
558
|
+
|
|
559
|
+
This is one of the most useful real-world patterns for the package. You can define a reusable rule set and compile it into SQLAlchemy boolean expressions for database queries.
|
|
560
|
+
|
|
561
|
+
```python
|
|
562
|
+
from pyspecification import ObjectRulesRegistry, Predicate, PredicateCompiler, RuleSchema
|
|
563
|
+
from sqlalchemy import ColumnElement, and_, or_
|
|
564
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
class Base(DeclarativeBase):
|
|
568
|
+
pass
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
class User(Base):
|
|
572
|
+
__tablename__ = "users"
|
|
573
|
+
|
|
574
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
575
|
+
name: Mapped[str]
|
|
576
|
+
age: Mapped[int]
|
|
577
|
+
is_admin: Mapped[bool]
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
rules = ObjectRulesRegistry[type[User], ColumnElement[bool]](operator="bitwise")
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
@rules.rule()
|
|
584
|
+
def is_admin(model: type[User]) -> ColumnElement[bool]:
|
|
585
|
+
return model.is_admin == True # noqa: E712
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
@rules.rule()
|
|
589
|
+
def age__ge(model: type[User], value: int) -> ColumnElement[bool]:
|
|
590
|
+
return model.age >= value
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
compiler = PredicateCompiler(
|
|
594
|
+
rules.rules,
|
|
595
|
+
lambda schema: Predicate(
|
|
596
|
+
lambda _: and_(True) if schema["operator"] == "and" else or_(False),
|
|
597
|
+
operator="bitwise",
|
|
598
|
+
),
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
filter_rule = {
|
|
602
|
+
"operator": "or",
|
|
603
|
+
"expressions": [
|
|
604
|
+
{"is_admin": []},
|
|
605
|
+
{"age__ge": [18]},
|
|
606
|
+
],
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
query_predicate = compiler.compile(RuleSchema(**filter_rule).model_dump())
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
This makes it easy to expose admin filters, user search rules, and role-based queries without manually stitching SQL conditions together.
|
|
613
|
+
|
|
614
|
+
### 3. Authorization and access rules
|
|
615
|
+
|
|
616
|
+
You can model business policies as rules and compose them into policy expressions.
|
|
617
|
+
|
|
618
|
+
```python
|
|
619
|
+
rule = is_admin() | (is_manager() & is_active())
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
This allows readable authorization checks without large condition trees.
|
|
623
|
+
|
|
624
|
+
### 3. Dynamic rule engines
|
|
625
|
+
|
|
626
|
+
The compiler and schema APIs make it easy to store or receive rules as structured data.
|
|
627
|
+
|
|
628
|
+
Examples:
|
|
629
|
+
|
|
630
|
+
- frontend sends a filter model to backend
|
|
631
|
+
- admin system stores JSON rules in a database
|
|
632
|
+
- rules are reloaded at runtime based on configuration
|
|
633
|
+
|
|
634
|
+
### 4. Validation pipelines
|
|
635
|
+
|
|
636
|
+
Rules can be assembled from reusable predicate pieces and evaluated against model instances or dictionary records.
|
|
637
|
+
|
|
638
|
+
This is ideal for:
|
|
639
|
+
|
|
640
|
+
- data validation
|
|
641
|
+
- compliance checks
|
|
642
|
+
- workflow gating
|
|
643
|
+
- feature flags and user segmentation
|
|
644
|
+
|
|
645
|
+
---
|
|
646
|
+
|
|
647
|
+
## Recommended patterns
|
|
648
|
+
|
|
649
|
+
### Prefer named rule functions
|
|
650
|
+
|
|
651
|
+
```python
|
|
652
|
+
@object_rule()
|
|
653
|
+
def age__between(user: User, min_age: int, max_age: int) -> bool:
|
|
654
|
+
return user.age >= min_age and user.age <= max_age
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
This gives you readable names and predictable rule lookup keys.
|
|
658
|
+
|
|
659
|
+
### Keep rules small and pure
|
|
660
|
+
|
|
661
|
+
Rules should do one thing and avoid hidden side effects.
|
|
662
|
+
|
|
663
|
+
### Use registries for larger systems
|
|
664
|
+
|
|
665
|
+
If your project has many rules, registries provide structure and reduce duplication.
|
|
666
|
+
|
|
667
|
+
### Validate schema before compile
|
|
668
|
+
|
|
669
|
+
If you load rules from external sources, validate them via `RuleSchema` before compiling.
|
|
670
|
+
|
|
671
|
+
---
|
|
672
|
+
|
|
673
|
+
## Exceptions
|
|
674
|
+
|
|
675
|
+
The library raises explicit exceptions for rule issues:
|
|
676
|
+
|
|
677
|
+
- `RuleNotRegisteredError`
|
|
678
|
+
- `RuleAlreadyRegisteredError`
|
|
679
|
+
- `RuleKeyDoesNotExistError`
|
|
680
|
+
- `CompilationError`
|
|
681
|
+
- `ProcessArgumentError`
|
|
682
|
+
|
|
683
|
+
These are especially useful when rules are dynamically loaded or assembled from user input.
|
|
684
|
+
|
|
685
|
+
---
|
|
686
|
+
|
|
687
|
+
## Example scripts in this repository
|
|
688
|
+
|
|
689
|
+
This project includes runnable examples under the `scripts/` directory:
|
|
690
|
+
|
|
691
|
+
- `scripts/rules_example.py` — basic object-based rule composition
|
|
692
|
+
- `scripts/reg_example.py` — registry usage and compiled rule predicate patterns
|
|
693
|
+
- `scripts/json_schema_example.py` — JSON schema generation examples
|
|
694
|
+
|
|
695
|
+
The test suite under `tests/` also demonstrates behavior for:
|
|
696
|
+
|
|
697
|
+
- registry registration
|
|
698
|
+
- predicate composition
|
|
699
|
+
- compiler validation
|
|
700
|
+
- schema serialization
|
|
701
|
+
- filtering workflows
|
|
702
|
+
|
|
703
|
+
---
|
|
704
|
+
|
|
705
|
+
## Example complete workflow
|
|
706
|
+
|
|
707
|
+
```python
|
|
708
|
+
from dataclasses import dataclass
|
|
709
|
+
|
|
710
|
+
from pyspecification import ObjectRulesRegistry, PredicateCompiler, RuleSchema
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
@dataclass
|
|
714
|
+
class User:
|
|
715
|
+
name: str
|
|
716
|
+
age: int
|
|
717
|
+
is_admin: bool = False
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
registry = ObjectRulesRegistry[User, bool](operator="logical")
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
@registry.rule()
|
|
724
|
+
def is_admin(user: User) -> bool:
|
|
725
|
+
return user.is_admin
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
@registry.rule()
|
|
729
|
+
def name__istartswith(user: User, value: str) -> bool:
|
|
730
|
+
return user.name.lower().startswith(value.lower())
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
@registry.rule()
|
|
734
|
+
def age__between(user: User, min_age: int, max_age: int) -> bool:
|
|
735
|
+
return user.age >= min_age and user.age <= max_age
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
rule_definition = {
|
|
739
|
+
"operator": "or",
|
|
740
|
+
"expressions": [
|
|
741
|
+
{"is_admin": []},
|
|
742
|
+
{
|
|
743
|
+
"expressions": [
|
|
744
|
+
{"name__istartswith": "admin"},
|
|
745
|
+
{"age__between": [18, 30]},
|
|
746
|
+
]
|
|
747
|
+
},
|
|
748
|
+
],
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
schema = RuleSchema(**rule_definition)
|
|
752
|
+
compiler = PredicateCompiler(registry.rules, lambda spec: Predicate(lambda _: True, operator="logical"))
|
|
753
|
+
predicate = compiler.compile(schema.model_dump())
|
|
754
|
+
|
|
755
|
+
users = [
|
|
756
|
+
User("Abdullah", 18, True),
|
|
757
|
+
User("admin", 20, False),
|
|
758
|
+
User("Charlie", 12, False),
|
|
759
|
+
]
|
|
760
|
+
|
|
761
|
+
print([predicate(user) for user in users])
|
|
762
|
+
```
|
|
763
|
+
|
|
764
|
+
---
|
|
765
|
+
|
|
766
|
+
## Summary
|
|
767
|
+
|
|
768
|
+
`pyspecification` brings together rule registration, predicate composition, schema validation, and runtime compilation in a compact library designed around functional and declarative rule authoring.
|
|
769
|
+
|
|
770
|
+
It is a practical fit for projects that need to:
|
|
771
|
+
|
|
772
|
+
- express business rules clearly
|
|
773
|
+
- compose conditions without nested `if` chains
|
|
774
|
+
- validate dynamic rule payloads
|
|
775
|
+
- support filtering and policy evaluation
|
|
776
|
+
- keep rule logic easy to test and maintain
|
|
777
|
+
|
|
778
|
+
If you want a rule system that feels Pythonic, composable, and lightweight, `pyspecification` is built for that workflow.
|
|
779
|
+
|
|
780
|
+
---
|
|
781
|
+
|
|
782
|
+
## License
|
|
783
|
+
|
|
784
|
+
This project is licensed under the GNU General Public License v3.0 or later.
|
|
785
|
+
|
|
786
|
+
See the full text in [LICENSE](LICENSE).
|
|
787
|
+
|
|
788
|
+
The project is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU GPL v3 for more details.
|