pyspecification 1.0.0__tar.gz → 2.0.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-1.0.0 → pyspecification-2.0.0}/PKG-INFO +43 -162
- {pyspecification-1.0.0 → pyspecification-2.0.0}/README.md +42 -160
- {pyspecification-1.0.0 → pyspecification-2.0.0}/pyproject.toml +2 -2
- {pyspecification-1.0.0 → pyspecification-2.0.0}/pyproject.toml.orig +2 -2
- {pyspecification-1.0.0 → pyspecification-2.0.0}/src/pyspecification/__init__.py +2 -4
- {pyspecification-1.0.0 → pyspecification-2.0.0}/src/pyspecification/compilers.py +7 -15
- pyspecification-2.0.0/src/pyspecification/json_schema.py +116 -0
- {pyspecification-1.0.0 → pyspecification-2.0.0}/src/pyspecification/predicate.py +5 -1
- {pyspecification-1.0.0 → pyspecification-2.0.0}/src/pyspecification/registry.py +4 -20
- {pyspecification-1.0.0 → pyspecification-2.0.0}/src/pyspecification/rules.py +3 -14
- pyspecification-1.0.0/src/pyspecification/constants.py +0 -1
- pyspecification-1.0.0/src/pyspecification/json_schema.py +0 -64
- pyspecification-1.0.0/src/pyspecification/schemas.py +0 -39
- {pyspecification-1.0.0 → pyspecification-2.0.0}/src/pyspecification/exceptions.py +0 -0
- {pyspecification-1.0.0 → pyspecification-2.0.0}/src/pyspecification/processors.py +0 -0
- {pyspecification-1.0.0 → pyspecification-2.0.0}/src/pyspecification/validators.py +0 -0
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: pyspecification
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.0
|
|
4
4
|
Summary: A lightweight, typed Python library for composing business rules as reusable, executable predicates.
|
|
5
5
|
Author: aaltatan
|
|
6
6
|
Author-email: aaltatan <a.altatan@gmail.com>
|
|
7
7
|
Classifier: Programming Language :: Python :: 3
|
|
8
8
|
Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
|
|
9
9
|
Classifier: Operating System :: OS Independent
|
|
10
|
-
Requires-Dist: pydantic==2.5.3
|
|
11
10
|
Requires-Python: >=3.12
|
|
12
11
|
Project-URL: Homepage, https://github.com/aaltatan/pyspecification
|
|
13
12
|
Project-URL: Issues, https://github.com/aaltatan/pyspecification/issues
|
|
@@ -25,7 +24,7 @@ This project was inspired by the work and ideas shared by [ArjanCodes](https://g
|
|
|
25
24
|
- predicates compose with `&`, `|`, and `~`
|
|
26
25
|
- registry-based registration keeps rules organized
|
|
27
26
|
- structured rule definitions can be compiled from dictionaries or JSON-like payloads
|
|
28
|
-
-
|
|
27
|
+
- structured rule definitions make rule metadata portable and machine-readable
|
|
29
28
|
|
|
30
29
|
It is especially useful for filtering, validation, authorization checks, and declarative rule engines without introducing a heavy framework.
|
|
31
30
|
|
|
@@ -68,7 +67,6 @@ This keeps your logic:
|
|
|
68
67
|
- Registry pattern for rule registration and lookup
|
|
69
68
|
- Custom argument processors for coercion and normalization
|
|
70
69
|
- `PredicateCompiler` for compiling structured rule dictionaries into executable predicates
|
|
71
|
-
- `RuleSchema` validation for declarative rule payloads
|
|
72
70
|
- JSON schema generation for rule arguments and return types
|
|
73
71
|
- Support for both logical and bitwise operator modes
|
|
74
72
|
- Hidden rules and custom naming for internal/private rule registration
|
|
@@ -251,7 +249,7 @@ One of the strongest real-world use cases is turning rule definitions into SQLAl
|
|
|
251
249
|
```python
|
|
252
250
|
from typing import Any
|
|
253
251
|
|
|
254
|
-
from pyspecification import ObjectRulesRegistry, Predicate, PredicateCompiler
|
|
252
|
+
from pyspecification import ObjectRulesRegistry, Predicate, PredicateCompiler
|
|
255
253
|
from sqlalchemy import ColumnElement, and_, create_engine, or_
|
|
256
254
|
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sessionmaker
|
|
257
255
|
|
|
@@ -336,11 +334,13 @@ filter_rule_data = {
|
|
|
336
334
|
],
|
|
337
335
|
}
|
|
338
336
|
|
|
339
|
-
predicate = compiler.compile(
|
|
337
|
+
predicate = compiler.compile(filter_rule_data)
|
|
338
|
+
# is_admin() | age__ge(18)
|
|
339
|
+
# User.is_admin | User.age >= 18
|
|
340
340
|
|
|
341
341
|
with SessionLocal() as session:
|
|
342
342
|
users = session.query(User).filter(predicate(User)).all()
|
|
343
|
-
print([user.name for user in users])
|
|
343
|
+
print([user.name for user in users]) # ['Abdullah', 'Bob', 'Charlie', 'Eve']
|
|
344
344
|
```
|
|
345
345
|
|
|
346
346
|
This pattern is especially useful when you want:
|
|
@@ -407,7 +407,7 @@ If conversion fails, the library raises `ProcessArgumentError`.
|
|
|
407
407
|
```python
|
|
408
408
|
from dataclasses import dataclass
|
|
409
409
|
|
|
410
|
-
from pyspecification import Predicate, PredicateCompiler,
|
|
410
|
+
from pyspecification import Predicate, PredicateCompiler, object_rule
|
|
411
411
|
|
|
412
412
|
|
|
413
413
|
@dataclass
|
|
@@ -462,7 +462,7 @@ rule_data = {
|
|
|
462
462
|
"inverse": False,
|
|
463
463
|
},
|
|
464
464
|
{
|
|
465
|
-
"name": "
|
|
465
|
+
"name": "age__between",
|
|
466
466
|
"args": [18, 30],
|
|
467
467
|
"kwargs": {},
|
|
468
468
|
"inverse": False,
|
|
@@ -472,67 +472,16 @@ rule_data = {
|
|
|
472
472
|
],
|
|
473
473
|
}
|
|
474
474
|
|
|
475
|
-
predicate = compiler.compile(
|
|
476
|
-
|
|
477
|
-
```
|
|
478
|
-
|
|
479
|
-
### Predicate schema
|
|
480
|
-
|
|
481
|
-
`PredicateCompiler.compile()` consumes the normalized dictionary produced by
|
|
482
|
-
`RuleSchema(...).model_dump()`. A predicate schema always has four keys:
|
|
483
|
-
|
|
484
|
-
```json
|
|
485
|
-
{
|
|
486
|
-
"name": "<rule_name>",
|
|
487
|
-
"args": [],
|
|
488
|
-
"kwargs": {},
|
|
489
|
-
"inverse": false
|
|
490
|
-
}
|
|
491
|
-
```
|
|
492
|
-
|
|
493
|
-
The `name` must be present in the dictionary of rules passed to the compiler.
|
|
494
|
-
Use `args` for positional arguments and `kwargs` for keyword arguments. These
|
|
495
|
-
examples assume the rules from the previous section:
|
|
496
|
-
|
|
497
|
-
```json
|
|
498
|
-
{
|
|
499
|
-
"name": "is_admin",
|
|
500
|
-
"args": [],
|
|
501
|
-
"kwargs": {},
|
|
502
|
-
"inverse": false
|
|
503
|
-
}
|
|
504
|
-
```
|
|
505
|
-
|
|
506
|
-
```json
|
|
507
|
-
{
|
|
508
|
-
"name": "name__istartswith",
|
|
509
|
-
"args": ["admin"],
|
|
510
|
-
"kwargs": {},
|
|
511
|
-
"inverse": false
|
|
512
|
-
}
|
|
513
|
-
```
|
|
514
|
-
|
|
515
|
-
```json
|
|
516
|
-
{
|
|
517
|
-
"name": "age__between",
|
|
518
|
-
"args": [],
|
|
519
|
-
"kwargs": {"min_age": 18, "max_age": 30},
|
|
520
|
-
"inverse": false
|
|
521
|
-
}
|
|
522
|
-
```
|
|
523
|
-
|
|
524
|
-
Set `inverse` to `true` to negate one predicate:
|
|
475
|
+
predicate = compiler.compile(rule_data)
|
|
476
|
+
# is_admin() | (name__istartswith("admin") & age__between(18, 30))
|
|
525
477
|
|
|
526
|
-
|
|
527
|
-
{
|
|
528
|
-
"name": "is_admin",
|
|
529
|
-
"args": [],
|
|
530
|
-
"kwargs": {},
|
|
531
|
-
"inverse": true
|
|
532
|
-
}
|
|
478
|
+
print(predicate(User("admin", 25, True))) # True
|
|
533
479
|
```
|
|
534
480
|
|
|
535
|
-
|
|
481
|
+
`PredicateCompiler.compile()` consumes a normalized dictionary. The `name`
|
|
482
|
+
must be present in the dictionary of rules passed to the compiler. Use `args`
|
|
483
|
+
for positional arguments, `kwargs` for keyword arguments, and `inverse` to
|
|
484
|
+
negate one predicate:
|
|
536
485
|
|
|
537
486
|
```python
|
|
538
487
|
import json
|
|
@@ -540,12 +489,12 @@ import json
|
|
|
540
489
|
rule_json = '{"name": "age__between", "args": [18, 30], "kwargs": {}, "inverse": false}'
|
|
541
490
|
rule_data = json.loads(rule_json)
|
|
542
491
|
predicate = compiler.compile(rule_data)
|
|
543
|
-
```
|
|
544
492
|
|
|
545
|
-
|
|
493
|
+
print(repr(predicate)) # Predicate(age__between)
|
|
494
|
+
```
|
|
546
495
|
|
|
547
|
-
Use a wrapper to combine predicates. A wrapper has an `operator
|
|
548
|
-
`expressions` list
|
|
496
|
+
Use a wrapper to combine predicates. A wrapper has an `operator` and an
|
|
497
|
+
`expressions` list:
|
|
549
498
|
|
|
550
499
|
```json
|
|
551
500
|
{
|
|
@@ -563,9 +512,8 @@ Use a wrapper to combine predicates. A wrapper has an `operator`, an
|
|
|
563
512
|
"kwargs": {},
|
|
564
513
|
"inverse": false
|
|
565
514
|
}
|
|
566
|
-
]
|
|
567
|
-
|
|
568
|
-
}
|
|
515
|
+
]
|
|
516
|
+
} // is_admin() & age__between(18, 30)
|
|
569
517
|
```
|
|
570
518
|
|
|
571
519
|
`operator` must be either `"all"` or `"any"`. Expressions can be nested to
|
|
@@ -596,88 +544,27 @@ represent more complex logic:
|
|
|
596
544
|
"kwargs": {"min_age": 18, "max_age": 30},
|
|
597
545
|
"inverse": false
|
|
598
546
|
}
|
|
599
|
-
]
|
|
600
|
-
"inverse": false
|
|
601
|
-
}
|
|
602
|
-
],
|
|
603
|
-
"inverse": false
|
|
604
|
-
}
|
|
605
|
-
```
|
|
606
|
-
|
|
607
|
-
You can also invert a complete wrapper:
|
|
608
|
-
|
|
609
|
-
```json
|
|
610
|
-
{
|
|
611
|
-
"operator": "any",
|
|
612
|
-
"expressions": [
|
|
613
|
-
{
|
|
614
|
-
"name": "is_admin",
|
|
615
|
-
"args": [],
|
|
616
|
-
"kwargs": {},
|
|
617
|
-
"inverse": false
|
|
618
|
-
},
|
|
619
|
-
{
|
|
620
|
-
"name": "age__between",
|
|
621
|
-
"args": [18, 30],
|
|
622
|
-
"kwargs": {},
|
|
623
|
-
"inverse": false
|
|
547
|
+
]
|
|
624
548
|
}
|
|
625
|
-
]
|
|
626
|
-
|
|
627
|
-
}
|
|
549
|
+
]
|
|
550
|
+
} // is_admin() | (name__istartswith("admin") & age__between(min_age=18, max_age=30))
|
|
628
551
|
```
|
|
629
552
|
|
|
630
553
|
The compiler raises `RuleDoesNotExistError` when a predicate name is not in the
|
|
631
554
|
compiler's rule mapping. If the dictionary passed directly to `compile()` does
|
|
632
|
-
not match a predicate or wrapper
|
|
633
|
-
includes the location of the invalid expression, the expected schemas, and the
|
|
634
|
-
received value. Validate external payloads with `RuleSchema` before compilation.
|
|
555
|
+
not match a predicate or wrapper shape, it raises `TypeError`.
|
|
635
556
|
|
|
636
557
|
---
|
|
637
|
-
|
|
638
|
-
## Rule schema validation
|
|
639
|
-
|
|
640
|
-
`RuleSchema` validates declarative rule payloads.
|
|
641
|
-
|
|
642
|
-
```python
|
|
643
|
-
from pyspecification import RuleSchema
|
|
644
|
-
|
|
645
|
-
rule_data = {
|
|
646
|
-
"operator": "all",
|
|
647
|
-
"expressions": [
|
|
648
|
-
{
|
|
649
|
-
"name": "name__startswith",
|
|
650
|
-
"args": ["admin"],
|
|
651
|
-
"kwargs": {},
|
|
652
|
-
"inverse": False,
|
|
653
|
-
},
|
|
654
|
-
{
|
|
655
|
-
"name": "age__gt",
|
|
656
|
-
"args": [18],
|
|
657
|
-
"kwargs": {},
|
|
658
|
-
"inverse": False,
|
|
659
|
-
}
|
|
660
|
-
],
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
schema = RuleSchema(**rule_data)
|
|
664
|
-
print(schema.model_dump())
|
|
665
|
-
```
|
|
666
|
-
|
|
667
|
-
This is useful when you want to validate incoming rule definitions before compile-time execution.
|
|
668
|
-
|
|
669
|
-
You can also use `RuleSchema` to represent nested predicate trees as typed, portable data.
|
|
670
|
-
|
|
671
558
|
---
|
|
672
559
|
|
|
673
560
|
## JSON schema generation
|
|
674
561
|
|
|
675
|
-
`
|
|
562
|
+
`get_rule_json_schema` inspects a rule function and returns JSON-schema-like metadata for parameters and return value.
|
|
676
563
|
|
|
677
564
|
```python
|
|
678
565
|
from dataclasses import dataclass
|
|
679
566
|
|
|
680
|
-
from pyspecification import
|
|
567
|
+
from pyspecification import get_rule_json_schema, object_rule
|
|
681
568
|
|
|
682
569
|
|
|
683
570
|
@dataclass
|
|
@@ -692,11 +579,8 @@ def name__istartswith(user: User, value: str) -> bool:
|
|
|
692
579
|
return user.name.lower().startswith(value.lower())
|
|
693
580
|
|
|
694
581
|
|
|
695
|
-
print(
|
|
696
|
-
# {
|
|
697
|
-
# "value": {"type": "string"},
|
|
698
|
-
# "return": {"type": "boolean"},
|
|
699
|
-
# }
|
|
582
|
+
print(get_rule_json_schema(name__istartswith))
|
|
583
|
+
# {'value': {'type': 'string'}, 'return': {'type': 'boolean'}}
|
|
700
584
|
```
|
|
701
585
|
|
|
702
586
|
This is useful for:
|
|
@@ -755,7 +639,7 @@ filtered = [user for user in users if predicate(user)]
|
|
|
755
639
|
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.
|
|
756
640
|
|
|
757
641
|
```python
|
|
758
|
-
from pyspecification import ObjectRulesRegistry, Predicate, PredicateCompiler
|
|
642
|
+
from pyspecification import ObjectRulesRegistry, Predicate, PredicateCompiler
|
|
759
643
|
from sqlalchemy import ColumnElement, and_, or_
|
|
760
644
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
761
645
|
|
|
@@ -812,7 +696,9 @@ filter_rule = {
|
|
|
812
696
|
],
|
|
813
697
|
}
|
|
814
698
|
|
|
815
|
-
query_predicate = compiler.compile(
|
|
699
|
+
query_predicate = compiler.compile(filter_rule)
|
|
700
|
+
# is_admin() | age__ge(18)
|
|
701
|
+
# User.is_admin | User.age >= 18
|
|
816
702
|
```
|
|
817
703
|
|
|
818
704
|
This makes it easy to expose admin filters, user search rules, and role-based queries without manually stitching SQL conditions together.
|
|
@@ -829,7 +715,7 @@ This allows readable authorization checks without large condition trees.
|
|
|
829
715
|
|
|
830
716
|
### 3. Dynamic rule engines
|
|
831
717
|
|
|
832
|
-
The compiler
|
|
718
|
+
The compiler makes it easy to store or receive rules as structured data.
|
|
833
719
|
|
|
834
720
|
Examples:
|
|
835
721
|
|
|
@@ -870,10 +756,6 @@ Rules should do one thing and avoid hidden side effects.
|
|
|
870
756
|
|
|
871
757
|
If your project has many rules, registries provide structure and reduce duplication.
|
|
872
758
|
|
|
873
|
-
### Validate schema before compile
|
|
874
|
-
|
|
875
|
-
If you load rules from external sources, validate them via `RuleSchema` before compiling.
|
|
876
|
-
|
|
877
759
|
---
|
|
878
760
|
|
|
879
761
|
## Exceptions
|
|
@@ -904,7 +786,7 @@ rule. Its specialized exceptions describe the problem:
|
|
|
904
786
|
`RuleDoesNotExistError` includes the missing name and the available rule names,
|
|
905
787
|
which is useful when rules are dynamically loaded. Malformed normalized
|
|
906
788
|
compiler payloads raise `TypeError`; its message identifies the JSON-like path
|
|
907
|
-
of the invalid expression and shows the expected
|
|
789
|
+
of the invalid expression and shows the expected shapes.
|
|
908
790
|
|
|
909
791
|
---
|
|
910
792
|
|
|
@@ -914,14 +796,13 @@ This project includes runnable examples under the `scripts/` directory:
|
|
|
914
796
|
|
|
915
797
|
- `scripts/rules_example.py` — basic object-based rule composition
|
|
916
798
|
- `scripts/reg_example.py` — registry usage and compiled rule predicate patterns
|
|
917
|
-
- `scripts/json_schema_example.py` — JSON schema generation examples
|
|
918
799
|
|
|
919
800
|
The test suite under `tests/` also demonstrates behavior for:
|
|
920
801
|
|
|
921
802
|
- registry registration
|
|
922
803
|
- predicate composition
|
|
923
804
|
- compiler validation
|
|
924
|
-
-
|
|
805
|
+
- structured rule compilation
|
|
925
806
|
- filtering workflows
|
|
926
807
|
|
|
927
808
|
---
|
|
@@ -931,7 +812,7 @@ The test suite under `tests/` also demonstrates behavior for:
|
|
|
931
812
|
```python
|
|
932
813
|
from dataclasses import dataclass
|
|
933
814
|
|
|
934
|
-
from pyspecification import ObjectRulesRegistry,
|
|
815
|
+
from pyspecification import ObjectRulesRegistry, Predicate, PredicateCompiler
|
|
935
816
|
|
|
936
817
|
|
|
937
818
|
@dataclass
|
|
@@ -988,9 +869,9 @@ rule_definition = {
|
|
|
988
869
|
],
|
|
989
870
|
}
|
|
990
871
|
|
|
991
|
-
schema = RuleSchema(**rule_definition)
|
|
992
872
|
compiler = PredicateCompiler(registry.rules, lambda spec: Predicate(lambda _: True, operator="logical"))
|
|
993
|
-
predicate = compiler.compile(
|
|
873
|
+
predicate = compiler.compile(rule_definition)
|
|
874
|
+
# is_admin() | (name__istartswith("admin") & age__between(18, 30))
|
|
994
875
|
|
|
995
876
|
users = [
|
|
996
877
|
User("Abdullah", 18, True),
|
|
@@ -998,20 +879,20 @@ users = [
|
|
|
998
879
|
User("Charlie", 12, False),
|
|
999
880
|
]
|
|
1000
881
|
|
|
1001
|
-
print([predicate(user) for user in users])
|
|
882
|
+
print([predicate(user) for user in users]) # [True, True, False]
|
|
1002
883
|
```
|
|
1003
884
|
|
|
1004
885
|
---
|
|
1005
886
|
|
|
1006
887
|
## Summary
|
|
1007
888
|
|
|
1008
|
-
`pyspecification` brings together rule registration, predicate composition,
|
|
889
|
+
`pyspecification` brings together rule registration, predicate composition, and runtime compilation in a compact library designed around functional and declarative rule authoring.
|
|
1009
890
|
|
|
1010
891
|
It is a practical fit for projects that need to:
|
|
1011
892
|
|
|
1012
893
|
- express business rules clearly
|
|
1013
894
|
- compose conditions without nested `if` chains
|
|
1014
|
-
-
|
|
895
|
+
- compile dynamic rule payloads
|
|
1015
896
|
- support filtering and policy evaluation
|
|
1016
897
|
- keep rule logic easy to test and maintain
|
|
1017
898
|
|
|
@@ -10,7 +10,7 @@ This project was inspired by the work and ideas shared by [ArjanCodes](https://g
|
|
|
10
10
|
- predicates compose with `&`, `|`, and `~`
|
|
11
11
|
- registry-based registration keeps rules organized
|
|
12
12
|
- structured rule definitions can be compiled from dictionaries or JSON-like payloads
|
|
13
|
-
-
|
|
13
|
+
- structured rule definitions make rule metadata portable and machine-readable
|
|
14
14
|
|
|
15
15
|
It is especially useful for filtering, validation, authorization checks, and declarative rule engines without introducing a heavy framework.
|
|
16
16
|
|
|
@@ -53,7 +53,6 @@ This keeps your logic:
|
|
|
53
53
|
- Registry pattern for rule registration and lookup
|
|
54
54
|
- Custom argument processors for coercion and normalization
|
|
55
55
|
- `PredicateCompiler` for compiling structured rule dictionaries into executable predicates
|
|
56
|
-
- `RuleSchema` validation for declarative rule payloads
|
|
57
56
|
- JSON schema generation for rule arguments and return types
|
|
58
57
|
- Support for both logical and bitwise operator modes
|
|
59
58
|
- Hidden rules and custom naming for internal/private rule registration
|
|
@@ -236,7 +235,7 @@ One of the strongest real-world use cases is turning rule definitions into SQLAl
|
|
|
236
235
|
```python
|
|
237
236
|
from typing import Any
|
|
238
237
|
|
|
239
|
-
from pyspecification import ObjectRulesRegistry, Predicate, PredicateCompiler
|
|
238
|
+
from pyspecification import ObjectRulesRegistry, Predicate, PredicateCompiler
|
|
240
239
|
from sqlalchemy import ColumnElement, and_, create_engine, or_
|
|
241
240
|
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sessionmaker
|
|
242
241
|
|
|
@@ -321,11 +320,13 @@ filter_rule_data = {
|
|
|
321
320
|
],
|
|
322
321
|
}
|
|
323
322
|
|
|
324
|
-
predicate = compiler.compile(
|
|
323
|
+
predicate = compiler.compile(filter_rule_data)
|
|
324
|
+
# is_admin() | age__ge(18)
|
|
325
|
+
# User.is_admin | User.age >= 18
|
|
325
326
|
|
|
326
327
|
with SessionLocal() as session:
|
|
327
328
|
users = session.query(User).filter(predicate(User)).all()
|
|
328
|
-
print([user.name for user in users])
|
|
329
|
+
print([user.name for user in users]) # ['Abdullah', 'Bob', 'Charlie', 'Eve']
|
|
329
330
|
```
|
|
330
331
|
|
|
331
332
|
This pattern is especially useful when you want:
|
|
@@ -392,7 +393,7 @@ If conversion fails, the library raises `ProcessArgumentError`.
|
|
|
392
393
|
```python
|
|
393
394
|
from dataclasses import dataclass
|
|
394
395
|
|
|
395
|
-
from pyspecification import Predicate, PredicateCompiler,
|
|
396
|
+
from pyspecification import Predicate, PredicateCompiler, object_rule
|
|
396
397
|
|
|
397
398
|
|
|
398
399
|
@dataclass
|
|
@@ -447,7 +448,7 @@ rule_data = {
|
|
|
447
448
|
"inverse": False,
|
|
448
449
|
},
|
|
449
450
|
{
|
|
450
|
-
"name": "
|
|
451
|
+
"name": "age__between",
|
|
451
452
|
"args": [18, 30],
|
|
452
453
|
"kwargs": {},
|
|
453
454
|
"inverse": False,
|
|
@@ -457,67 +458,16 @@ rule_data = {
|
|
|
457
458
|
],
|
|
458
459
|
}
|
|
459
460
|
|
|
460
|
-
predicate = compiler.compile(
|
|
461
|
-
|
|
462
|
-
```
|
|
463
|
-
|
|
464
|
-
### Predicate schema
|
|
465
|
-
|
|
466
|
-
`PredicateCompiler.compile()` consumes the normalized dictionary produced by
|
|
467
|
-
`RuleSchema(...).model_dump()`. A predicate schema always has four keys:
|
|
468
|
-
|
|
469
|
-
```json
|
|
470
|
-
{
|
|
471
|
-
"name": "<rule_name>",
|
|
472
|
-
"args": [],
|
|
473
|
-
"kwargs": {},
|
|
474
|
-
"inverse": false
|
|
475
|
-
}
|
|
476
|
-
```
|
|
477
|
-
|
|
478
|
-
The `name` must be present in the dictionary of rules passed to the compiler.
|
|
479
|
-
Use `args` for positional arguments and `kwargs` for keyword arguments. These
|
|
480
|
-
examples assume the rules from the previous section:
|
|
481
|
-
|
|
482
|
-
```json
|
|
483
|
-
{
|
|
484
|
-
"name": "is_admin",
|
|
485
|
-
"args": [],
|
|
486
|
-
"kwargs": {},
|
|
487
|
-
"inverse": false
|
|
488
|
-
}
|
|
489
|
-
```
|
|
490
|
-
|
|
491
|
-
```json
|
|
492
|
-
{
|
|
493
|
-
"name": "name__istartswith",
|
|
494
|
-
"args": ["admin"],
|
|
495
|
-
"kwargs": {},
|
|
496
|
-
"inverse": false
|
|
497
|
-
}
|
|
498
|
-
```
|
|
499
|
-
|
|
500
|
-
```json
|
|
501
|
-
{
|
|
502
|
-
"name": "age__between",
|
|
503
|
-
"args": [],
|
|
504
|
-
"kwargs": {"min_age": 18, "max_age": 30},
|
|
505
|
-
"inverse": false
|
|
506
|
-
}
|
|
507
|
-
```
|
|
508
|
-
|
|
509
|
-
Set `inverse` to `true` to negate one predicate:
|
|
461
|
+
predicate = compiler.compile(rule_data)
|
|
462
|
+
# is_admin() | (name__istartswith("admin") & age__between(18, 30))
|
|
510
463
|
|
|
511
|
-
|
|
512
|
-
{
|
|
513
|
-
"name": "is_admin",
|
|
514
|
-
"args": [],
|
|
515
|
-
"kwargs": {},
|
|
516
|
-
"inverse": true
|
|
517
|
-
}
|
|
464
|
+
print(predicate(User("admin", 25, True))) # True
|
|
518
465
|
```
|
|
519
466
|
|
|
520
|
-
|
|
467
|
+
`PredicateCompiler.compile()` consumes a normalized dictionary. The `name`
|
|
468
|
+
must be present in the dictionary of rules passed to the compiler. Use `args`
|
|
469
|
+
for positional arguments, `kwargs` for keyword arguments, and `inverse` to
|
|
470
|
+
negate one predicate:
|
|
521
471
|
|
|
522
472
|
```python
|
|
523
473
|
import json
|
|
@@ -525,12 +475,12 @@ import json
|
|
|
525
475
|
rule_json = '{"name": "age__between", "args": [18, 30], "kwargs": {}, "inverse": false}'
|
|
526
476
|
rule_data = json.loads(rule_json)
|
|
527
477
|
predicate = compiler.compile(rule_data)
|
|
528
|
-
```
|
|
529
478
|
|
|
530
|
-
|
|
479
|
+
print(repr(predicate)) # Predicate(age__between)
|
|
480
|
+
```
|
|
531
481
|
|
|
532
|
-
Use a wrapper to combine predicates. A wrapper has an `operator
|
|
533
|
-
`expressions` list
|
|
482
|
+
Use a wrapper to combine predicates. A wrapper has an `operator` and an
|
|
483
|
+
`expressions` list:
|
|
534
484
|
|
|
535
485
|
```json
|
|
536
486
|
{
|
|
@@ -548,9 +498,8 @@ Use a wrapper to combine predicates. A wrapper has an `operator`, an
|
|
|
548
498
|
"kwargs": {},
|
|
549
499
|
"inverse": false
|
|
550
500
|
}
|
|
551
|
-
]
|
|
552
|
-
|
|
553
|
-
}
|
|
501
|
+
]
|
|
502
|
+
} // is_admin() & age__between(18, 30)
|
|
554
503
|
```
|
|
555
504
|
|
|
556
505
|
`operator` must be either `"all"` or `"any"`. Expressions can be nested to
|
|
@@ -581,88 +530,27 @@ represent more complex logic:
|
|
|
581
530
|
"kwargs": {"min_age": 18, "max_age": 30},
|
|
582
531
|
"inverse": false
|
|
583
532
|
}
|
|
584
|
-
]
|
|
585
|
-
"inverse": false
|
|
586
|
-
}
|
|
587
|
-
],
|
|
588
|
-
"inverse": false
|
|
589
|
-
}
|
|
590
|
-
```
|
|
591
|
-
|
|
592
|
-
You can also invert a complete wrapper:
|
|
593
|
-
|
|
594
|
-
```json
|
|
595
|
-
{
|
|
596
|
-
"operator": "any",
|
|
597
|
-
"expressions": [
|
|
598
|
-
{
|
|
599
|
-
"name": "is_admin",
|
|
600
|
-
"args": [],
|
|
601
|
-
"kwargs": {},
|
|
602
|
-
"inverse": false
|
|
603
|
-
},
|
|
604
|
-
{
|
|
605
|
-
"name": "age__between",
|
|
606
|
-
"args": [18, 30],
|
|
607
|
-
"kwargs": {},
|
|
608
|
-
"inverse": false
|
|
533
|
+
]
|
|
609
534
|
}
|
|
610
|
-
]
|
|
611
|
-
|
|
612
|
-
}
|
|
535
|
+
]
|
|
536
|
+
} // is_admin() | (name__istartswith("admin") & age__between(min_age=18, max_age=30))
|
|
613
537
|
```
|
|
614
538
|
|
|
615
539
|
The compiler raises `RuleDoesNotExistError` when a predicate name is not in the
|
|
616
540
|
compiler's rule mapping. If the dictionary passed directly to `compile()` does
|
|
617
|
-
not match a predicate or wrapper
|
|
618
|
-
includes the location of the invalid expression, the expected schemas, and the
|
|
619
|
-
received value. Validate external payloads with `RuleSchema` before compilation.
|
|
541
|
+
not match a predicate or wrapper shape, it raises `TypeError`.
|
|
620
542
|
|
|
621
543
|
---
|
|
622
|
-
|
|
623
|
-
## Rule schema validation
|
|
624
|
-
|
|
625
|
-
`RuleSchema` validates declarative rule payloads.
|
|
626
|
-
|
|
627
|
-
```python
|
|
628
|
-
from pyspecification import RuleSchema
|
|
629
|
-
|
|
630
|
-
rule_data = {
|
|
631
|
-
"operator": "all",
|
|
632
|
-
"expressions": [
|
|
633
|
-
{
|
|
634
|
-
"name": "name__startswith",
|
|
635
|
-
"args": ["admin"],
|
|
636
|
-
"kwargs": {},
|
|
637
|
-
"inverse": False,
|
|
638
|
-
},
|
|
639
|
-
{
|
|
640
|
-
"name": "age__gt",
|
|
641
|
-
"args": [18],
|
|
642
|
-
"kwargs": {},
|
|
643
|
-
"inverse": False,
|
|
644
|
-
}
|
|
645
|
-
],
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
schema = RuleSchema(**rule_data)
|
|
649
|
-
print(schema.model_dump())
|
|
650
|
-
```
|
|
651
|
-
|
|
652
|
-
This is useful when you want to validate incoming rule definitions before compile-time execution.
|
|
653
|
-
|
|
654
|
-
You can also use `RuleSchema` to represent nested predicate trees as typed, portable data.
|
|
655
|
-
|
|
656
544
|
---
|
|
657
545
|
|
|
658
546
|
## JSON schema generation
|
|
659
547
|
|
|
660
|
-
`
|
|
548
|
+
`get_rule_json_schema` inspects a rule function and returns JSON-schema-like metadata for parameters and return value.
|
|
661
549
|
|
|
662
550
|
```python
|
|
663
551
|
from dataclasses import dataclass
|
|
664
552
|
|
|
665
|
-
from pyspecification import
|
|
553
|
+
from pyspecification import get_rule_json_schema, object_rule
|
|
666
554
|
|
|
667
555
|
|
|
668
556
|
@dataclass
|
|
@@ -677,11 +565,8 @@ def name__istartswith(user: User, value: str) -> bool:
|
|
|
677
565
|
return user.name.lower().startswith(value.lower())
|
|
678
566
|
|
|
679
567
|
|
|
680
|
-
print(
|
|
681
|
-
# {
|
|
682
|
-
# "value": {"type": "string"},
|
|
683
|
-
# "return": {"type": "boolean"},
|
|
684
|
-
# }
|
|
568
|
+
print(get_rule_json_schema(name__istartswith))
|
|
569
|
+
# {'value': {'type': 'string'}, 'return': {'type': 'boolean'}}
|
|
685
570
|
```
|
|
686
571
|
|
|
687
572
|
This is useful for:
|
|
@@ -740,7 +625,7 @@ filtered = [user for user in users if predicate(user)]
|
|
|
740
625
|
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.
|
|
741
626
|
|
|
742
627
|
```python
|
|
743
|
-
from pyspecification import ObjectRulesRegistry, Predicate, PredicateCompiler
|
|
628
|
+
from pyspecification import ObjectRulesRegistry, Predicate, PredicateCompiler
|
|
744
629
|
from sqlalchemy import ColumnElement, and_, or_
|
|
745
630
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
746
631
|
|
|
@@ -797,7 +682,9 @@ filter_rule = {
|
|
|
797
682
|
],
|
|
798
683
|
}
|
|
799
684
|
|
|
800
|
-
query_predicate = compiler.compile(
|
|
685
|
+
query_predicate = compiler.compile(filter_rule)
|
|
686
|
+
# is_admin() | age__ge(18)
|
|
687
|
+
# User.is_admin | User.age >= 18
|
|
801
688
|
```
|
|
802
689
|
|
|
803
690
|
This makes it easy to expose admin filters, user search rules, and role-based queries without manually stitching SQL conditions together.
|
|
@@ -814,7 +701,7 @@ This allows readable authorization checks without large condition trees.
|
|
|
814
701
|
|
|
815
702
|
### 3. Dynamic rule engines
|
|
816
703
|
|
|
817
|
-
The compiler
|
|
704
|
+
The compiler makes it easy to store or receive rules as structured data.
|
|
818
705
|
|
|
819
706
|
Examples:
|
|
820
707
|
|
|
@@ -855,10 +742,6 @@ Rules should do one thing and avoid hidden side effects.
|
|
|
855
742
|
|
|
856
743
|
If your project has many rules, registries provide structure and reduce duplication.
|
|
857
744
|
|
|
858
|
-
### Validate schema before compile
|
|
859
|
-
|
|
860
|
-
If you load rules from external sources, validate them via `RuleSchema` before compiling.
|
|
861
|
-
|
|
862
745
|
---
|
|
863
746
|
|
|
864
747
|
## Exceptions
|
|
@@ -889,7 +772,7 @@ rule. Its specialized exceptions describe the problem:
|
|
|
889
772
|
`RuleDoesNotExistError` includes the missing name and the available rule names,
|
|
890
773
|
which is useful when rules are dynamically loaded. Malformed normalized
|
|
891
774
|
compiler payloads raise `TypeError`; its message identifies the JSON-like path
|
|
892
|
-
of the invalid expression and shows the expected
|
|
775
|
+
of the invalid expression and shows the expected shapes.
|
|
893
776
|
|
|
894
777
|
---
|
|
895
778
|
|
|
@@ -899,14 +782,13 @@ This project includes runnable examples under the `scripts/` directory:
|
|
|
899
782
|
|
|
900
783
|
- `scripts/rules_example.py` — basic object-based rule composition
|
|
901
784
|
- `scripts/reg_example.py` — registry usage and compiled rule predicate patterns
|
|
902
|
-
- `scripts/json_schema_example.py` — JSON schema generation examples
|
|
903
785
|
|
|
904
786
|
The test suite under `tests/` also demonstrates behavior for:
|
|
905
787
|
|
|
906
788
|
- registry registration
|
|
907
789
|
- predicate composition
|
|
908
790
|
- compiler validation
|
|
909
|
-
-
|
|
791
|
+
- structured rule compilation
|
|
910
792
|
- filtering workflows
|
|
911
793
|
|
|
912
794
|
---
|
|
@@ -916,7 +798,7 @@ The test suite under `tests/` also demonstrates behavior for:
|
|
|
916
798
|
```python
|
|
917
799
|
from dataclasses import dataclass
|
|
918
800
|
|
|
919
|
-
from pyspecification import ObjectRulesRegistry,
|
|
801
|
+
from pyspecification import ObjectRulesRegistry, Predicate, PredicateCompiler
|
|
920
802
|
|
|
921
803
|
|
|
922
804
|
@dataclass
|
|
@@ -973,9 +855,9 @@ rule_definition = {
|
|
|
973
855
|
],
|
|
974
856
|
}
|
|
975
857
|
|
|
976
|
-
schema = RuleSchema(**rule_definition)
|
|
977
858
|
compiler = PredicateCompiler(registry.rules, lambda spec: Predicate(lambda _: True, operator="logical"))
|
|
978
|
-
predicate = compiler.compile(
|
|
859
|
+
predicate = compiler.compile(rule_definition)
|
|
860
|
+
# is_admin() | (name__istartswith("admin") & age__between(18, 30))
|
|
979
861
|
|
|
980
862
|
users = [
|
|
981
863
|
User("Abdullah", 18, True),
|
|
@@ -983,20 +865,20 @@ users = [
|
|
|
983
865
|
User("Charlie", 12, False),
|
|
984
866
|
]
|
|
985
867
|
|
|
986
|
-
print([predicate(user) for user in users])
|
|
868
|
+
print([predicate(user) for user in users]) # [True, True, False]
|
|
987
869
|
```
|
|
988
870
|
|
|
989
871
|
---
|
|
990
872
|
|
|
991
873
|
## Summary
|
|
992
874
|
|
|
993
|
-
`pyspecification` brings together rule registration, predicate composition,
|
|
875
|
+
`pyspecification` brings together rule registration, predicate composition, and runtime compilation in a compact library designed around functional and declarative rule authoring.
|
|
994
876
|
|
|
995
877
|
It is a practical fit for projects that need to:
|
|
996
878
|
|
|
997
879
|
- express business rules clearly
|
|
998
880
|
- compose conditions without nested `if` chains
|
|
999
|
-
-
|
|
881
|
+
- compile dynamic rule payloads
|
|
1000
882
|
- support filtering and policy evaluation
|
|
1001
883
|
- keep rule logic easy to test and maintain
|
|
1002
884
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pyspecification"
|
|
3
|
-
version = "
|
|
3
|
+
version = "2.0.0"
|
|
4
4
|
description = "A lightweight, typed Python library for composing business rules as reusable, executable predicates."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
7
|
-
dependencies = [
|
|
7
|
+
dependencies = []
|
|
8
8
|
classifiers = [
|
|
9
9
|
"Programming Language :: Python :: 3",
|
|
10
10
|
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pyspecification"
|
|
3
|
-
version = "
|
|
3
|
+
version = "2.0.0"
|
|
4
4
|
description = "A lightweight, typed Python library for composing business rules as reusable, executable predicates."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [{ name = "aaltatan", email = "a.altatan@gmail.com" }]
|
|
7
7
|
requires-python = ">=3.12"
|
|
8
|
-
dependencies = [
|
|
8
|
+
dependencies = []
|
|
9
9
|
classifiers = [
|
|
10
10
|
"Programming Language :: Python :: 3",
|
|
11
11
|
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
|
@@ -16,11 +16,10 @@ from .exceptions import (
|
|
|
16
16
|
TooManyArgumentsError,
|
|
17
17
|
UnexpectedKeywordArgumentError,
|
|
18
18
|
)
|
|
19
|
-
from .json_schema import
|
|
19
|
+
from .json_schema import get_rule_json_schema
|
|
20
20
|
from .predicate import OperatorType, Predicate
|
|
21
21
|
from .registry import ObjectRulesRegistry, SubscriptableRulesRegistry
|
|
22
22
|
from .rules import object_rule, subscriptable_rule
|
|
23
|
-
from .schemas import RuleSchema
|
|
24
23
|
|
|
25
24
|
__all__ = [
|
|
26
25
|
"ArgumentError",
|
|
@@ -36,11 +35,10 @@ __all__ = [
|
|
|
36
35
|
"RuleAlreadyRegisteredError",
|
|
37
36
|
"RuleDoesNotExistError",
|
|
38
37
|
"RuleKeyDoesNotExistError",
|
|
39
|
-
"RuleSchema",
|
|
40
38
|
"SubscriptableRulesRegistry",
|
|
41
39
|
"TooManyArgumentsError",
|
|
42
40
|
"UnexpectedKeywordArgumentError",
|
|
43
|
-
"
|
|
41
|
+
"get_rule_json_schema",
|
|
44
42
|
"object_rule",
|
|
45
43
|
"subscriptable_rule",
|
|
46
44
|
]
|
|
@@ -3,16 +3,15 @@ from collections.abc import Callable
|
|
|
3
3
|
from pprint import pformat
|
|
4
4
|
from typing import Any, Literal, TypedDict, TypeGuard
|
|
5
5
|
|
|
6
|
-
from pydantic import TypeAdapter
|
|
7
|
-
|
|
8
6
|
from .exceptions import MissingArgumentError, RuleDoesNotExistError, is_missing_argument_exception
|
|
7
|
+
from .json_schema import get_json_schema
|
|
9
8
|
from .predicate import Predicate, ReturnType
|
|
10
9
|
|
|
11
10
|
type ExpressionDict = ExpressionWrapperDict | PredicateDict
|
|
12
11
|
|
|
13
12
|
|
|
14
13
|
PREDICATE_DICT_KEYS = {"name", "args", "kwargs", "inverse"}
|
|
15
|
-
EXPRESSION_WRAPPER_DICT_KEYS = {"operator", "expressions"
|
|
14
|
+
EXPRESSION_WRAPPER_DICT_KEYS = {"operator", "expressions"}
|
|
16
15
|
|
|
17
16
|
|
|
18
17
|
class PredicateDict(TypedDict):
|
|
@@ -29,7 +28,6 @@ class ExpressionWrapperDict(TypedDict):
|
|
|
29
28
|
|
|
30
29
|
operator: Literal["all", "any"]
|
|
31
30
|
expressions: list["ExpressionWrapperDict | PredicateDict"]
|
|
32
|
-
inverse: bool
|
|
33
31
|
|
|
34
32
|
|
|
35
33
|
def is_predicate_dict(d: Any) -> TypeGuard[PredicateDict]:
|
|
@@ -40,9 +38,7 @@ def is_predicate_dict(d: Any) -> TypeGuard[PredicateDict]:
|
|
|
40
38
|
)
|
|
41
39
|
|
|
42
40
|
|
|
43
|
-
def is_expression_wrapper_dict(
|
|
44
|
-
d: Any,
|
|
45
|
-
) -> TypeGuard[ExpressionWrapperDict]:
|
|
41
|
+
def is_expression_wrapper_dict(d: Any) -> TypeGuard[ExpressionWrapperDict]:
|
|
46
42
|
return (
|
|
47
43
|
isinstance(d, dict)
|
|
48
44
|
and all(key in d for key in EXPRESSION_WRAPPER_DICT_KEYS)
|
|
@@ -89,7 +85,6 @@ class PredicateCompiler[T, R: ReturnType]:
|
|
|
89
85
|
|
|
90
86
|
rule_data = {
|
|
91
87
|
"operator": "any",
|
|
92
|
-
"inverse": False,
|
|
93
88
|
"expressions": [
|
|
94
89
|
{
|
|
95
90
|
"name": "is_admin",
|
|
@@ -99,7 +94,6 @@ class PredicateCompiler[T, R: ReturnType]:
|
|
|
99
94
|
},
|
|
100
95
|
{
|
|
101
96
|
"operator": "all",
|
|
102
|
-
"inverse": False,
|
|
103
97
|
"expressions": [
|
|
104
98
|
{
|
|
105
99
|
"name": "name__istartswith",
|
|
@@ -120,7 +114,8 @@ class PredicateCompiler[T, R: ReturnType]:
|
|
|
120
114
|
|
|
121
115
|
rule = compiler.compile(rule_data)
|
|
122
116
|
|
|
123
|
-
|
|
117
|
+
print(rule(User("admin", 25, True))) # True
|
|
118
|
+
assert rule(User("admin", 25, True))
|
|
124
119
|
|
|
125
120
|
|
|
126
121
|
if __name__ == "__main__":
|
|
@@ -177,9 +172,6 @@ class PredicateCompiler[T, R: ReturnType]:
|
|
|
177
172
|
else:
|
|
178
173
|
predicate |= compiled_predicate
|
|
179
174
|
|
|
180
|
-
if wrapper["inverse"]:
|
|
181
|
-
predicate = ~predicate
|
|
182
|
-
|
|
183
175
|
return predicate
|
|
184
176
|
|
|
185
177
|
|
|
@@ -190,10 +182,10 @@ def _invalid_expression_message(expression: Any, path: str) -> str:
|
|
|
190
182
|
"Expected an expression matching one of these schemas:",
|
|
191
183
|
"",
|
|
192
184
|
"Predicate:",
|
|
193
|
-
json.dumps(
|
|
185
|
+
json.dumps(get_json_schema(PredicateDict), indent=2),
|
|
194
186
|
"",
|
|
195
187
|
"Expression wrapper:",
|
|
196
|
-
json.dumps(
|
|
188
|
+
json.dumps(get_json_schema(ExpressionWrapperDict), indent=2),
|
|
197
189
|
"",
|
|
198
190
|
"Received:",
|
|
199
191
|
pformat(expression, sort_dicts=False, width=88),
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from datetime import date, datetime, time
|
|
3
|
+
from inspect import get_annotations, signature
|
|
4
|
+
from types import UnionType
|
|
5
|
+
from typing import Any, Literal, Union, get_args, get_origin, is_typeddict
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_json_schema(annotation: Any) -> dict[str, Any]:
|
|
10
|
+
if is_typeddict(annotation):
|
|
11
|
+
annotations = get_annotations(annotation)
|
|
12
|
+
return {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"properties": {name: get_json_schema(typ) for name, typ in annotations.items()},
|
|
15
|
+
"required": [name for name in annotations if name in annotation.__required_keys__],
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
origin = get_origin(annotation)
|
|
19
|
+
|
|
20
|
+
if origin is Literal:
|
|
21
|
+
values = list(get_args(annotation))
|
|
22
|
+
schema: dict[str, Any] = {"enum": values}
|
|
23
|
+
if values and all(isinstance(value, str) for value in values):
|
|
24
|
+
schema["type"] = "string"
|
|
25
|
+
return schema
|
|
26
|
+
|
|
27
|
+
if origin in (Union, UnionType):
|
|
28
|
+
return {"anyOf": [get_json_schema(arg) for arg in get_args(annotation)]}
|
|
29
|
+
|
|
30
|
+
if origin is not None or annotation in (list, set, frozenset, tuple, dict):
|
|
31
|
+
origin = annotation if origin is None else origin
|
|
32
|
+
if origin in (list, set, frozenset, tuple):
|
|
33
|
+
args = get_args(annotation)
|
|
34
|
+
item_schema = get_json_schema(args[0]) if args else {}
|
|
35
|
+
return {"type": "array", "items": item_schema}
|
|
36
|
+
if origin is dict:
|
|
37
|
+
args = get_args(annotation)
|
|
38
|
+
value_schema = get_json_schema(args[1]) if len(args) > 1 else {}
|
|
39
|
+
return {"type": "object", "additionalProperties": value_schema}
|
|
40
|
+
|
|
41
|
+
schemas = {
|
|
42
|
+
str: {"type": "string"},
|
|
43
|
+
int: {"type": "integer"},
|
|
44
|
+
float: {"type": "number"},
|
|
45
|
+
bool: {"type": "boolean"},
|
|
46
|
+
None: {"type": "null"},
|
|
47
|
+
type(None): {"type": "null"},
|
|
48
|
+
datetime: {"type": "string", "format": "date-time"},
|
|
49
|
+
date: {"type": "string", "format": "date"},
|
|
50
|
+
time: {"type": "string", "format": "time"},
|
|
51
|
+
UUID: {"type": "string", "format": "uuid"},
|
|
52
|
+
Any: {},
|
|
53
|
+
}
|
|
54
|
+
return schemas.get(annotation, {})
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_rule_json_schema(rule: Callable[..., Any]) -> dict[str, Any]:
|
|
58
|
+
"""Get the JSON schema for a rule.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
rule (Callable): The rule to get the JSON schema for.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
dict[str, Any]: The JSON schema for the rule.
|
|
65
|
+
|
|
66
|
+
Example:
|
|
67
|
+
```python
|
|
68
|
+
from pyspecification import get_rule_json_schema, object_rule
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@object_rule()
|
|
72
|
+
def is_admin(user: User) -> bool:
|
|
73
|
+
return user.is_admin
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@object_rule()
|
|
77
|
+
def name__istartswith(user: User, value: str) -> bool:
|
|
78
|
+
return user.name.lower().startswith(value.lower())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@object_rule()
|
|
82
|
+
def age__between(user: User, min_age: int, max_age: int) -> bool:
|
|
83
|
+
return user.age >= min_age and user.age <= max_age
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main() -> None:
|
|
87
|
+
schema = get_rule_json_schema(is_admin)
|
|
88
|
+
print(schema) # {'return': {'type': 'boolean'}}
|
|
89
|
+
|
|
90
|
+
schema = get_rule_json_schema(name__istartswith)
|
|
91
|
+
print(schema) # {'value': {'type': 'string'}, 'return': {'type': 'boolean'}}
|
|
92
|
+
|
|
93
|
+
schema = get_rule_json_schema(age__between)
|
|
94
|
+
print(
|
|
95
|
+
schema
|
|
96
|
+
) # {'min_age': {'type': 'integer'}, 'max_age': {'type': 'integer'}, 'return': {'type': 'boolean'}}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
main()
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
""" # noqa: E501
|
|
104
|
+
annotations = get_annotations(rule)
|
|
105
|
+
parameters = list(signature(rule).parameters)
|
|
106
|
+
schema: dict[str, Any] = {}
|
|
107
|
+
|
|
108
|
+
for index, name in enumerate(parameters):
|
|
109
|
+
if index == 0:
|
|
110
|
+
continue
|
|
111
|
+
schema[name] = get_json_schema(annotations.get(name, Any))
|
|
112
|
+
|
|
113
|
+
if "return" in annotations:
|
|
114
|
+
schema["return"] = get_json_schema(annotations["return"])
|
|
115
|
+
|
|
116
|
+
return schema
|
|
@@ -59,7 +59,11 @@ class Predicate[T, R: ReturnType]:
|
|
|
59
59
|
assert rule(User(name="Abdullah", age=16, is_admin=True))
|
|
60
60
|
assert rule(User(name="admin", age=20, is_admin=False))
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
print(rule(User(name="Abdullah", age=18, is_admin=True)))
|
|
63
|
+
# True
|
|
64
|
+
|
|
65
|
+
print(repr(rule))
|
|
66
|
+
# Predicate((is_admin OR (name__istartswith AND age__between)))
|
|
63
67
|
|
|
64
68
|
|
|
65
69
|
if __name__ == "__main__":
|
|
@@ -3,7 +3,6 @@ from collections.abc import Callable
|
|
|
3
3
|
from functools import wraps
|
|
4
4
|
from typing import Any, Concatenate
|
|
5
5
|
|
|
6
|
-
from .constants import RESERVED_WORDS
|
|
7
6
|
from .exceptions import RuleAlreadyRegisteredError, RuleDoesNotExistError
|
|
8
7
|
from .predicate import OperatorType, Predicate, ReturnType
|
|
9
8
|
from .processors import DEFAULT_PROCESSORS, ProcessFn, process_arguments
|
|
@@ -70,12 +69,7 @@ class ObjectRulesRegistry[T, R: ReturnType]:
|
|
|
70
69
|
]
|
|
71
70
|
|
|
72
71
|
print([e for e in EMPLOYEES if rule(e)])
|
|
73
|
-
|
|
74
|
-
# [
|
|
75
|
-
# User(name="Alice", age=18, is_admin=True),
|
|
76
|
-
# User(name="Admin", age=18, is_admin=False),
|
|
77
|
-
# User(name="Eve", age=8, is_admin=True),
|
|
78
|
-
# ]
|
|
72
|
+
# [User(name='Alice', age=18, is_admin=True), User(name='Admin', age=18, is_admin=False), User(name='Eve', age=8, is_admin=True)]
|
|
79
73
|
|
|
80
74
|
|
|
81
75
|
if __name__ == "__main__":
|
|
@@ -230,8 +224,8 @@ class SubscriptableRulesRegistry[T, K, R: ReturnType]:
|
|
|
230
224
|
|
|
231
225
|
|
|
232
226
|
def main() -> None:
|
|
233
|
-
# Manual composition: name == "
|
|
234
|
-
rule = reg["string__ieq"]("name", "
|
|
227
|
+
# Manual composition: name == "alice" OR age >= 18
|
|
228
|
+
rule = reg["string__ieq"]("name", "alice") | reg["int__ge"]("age", 18)
|
|
235
229
|
|
|
236
230
|
EMPLOYEES = [
|
|
237
231
|
{"name": "Alice", "age": 5},
|
|
@@ -243,13 +237,7 @@ class SubscriptableRulesRegistry[T, K, R: ReturnType]:
|
|
|
243
237
|
]
|
|
244
238
|
|
|
245
239
|
print([e for e in EMPLOYEES if rule(e)])
|
|
246
|
-
|
|
247
|
-
# [
|
|
248
|
-
# {"name": "Alice", "age": 5},
|
|
249
|
-
# {"name": "Admin", "age": 6},
|
|
250
|
-
# {"name": "David", "age": 25},
|
|
251
|
-
# {"name": "Eve", "age": 30},
|
|
252
|
-
# ]
|
|
240
|
+
# [{'name': 'Alice', 'age': 5}, {'name': 'David', 'age': 25}, {'name': 'Eve', 'age': 30}]
|
|
253
241
|
|
|
254
242
|
|
|
255
243
|
if __name__ == "__main__":
|
|
@@ -414,10 +402,6 @@ def _process_rule_name(fn: Callable[..., Any], name: str | None = None) -> str:
|
|
|
414
402
|
msg = "You must provide a name for the rule when using a lambda function"
|
|
415
403
|
raise ValueError(msg)
|
|
416
404
|
|
|
417
|
-
if rule_name.lower() in RESERVED_WORDS:
|
|
418
|
-
msg = f"Rule name '{rule_name}' is reserved"
|
|
419
|
-
raise ValueError(msg)
|
|
420
|
-
|
|
421
405
|
validate_python_vars_fn_naming_convention(rule_name)
|
|
422
406
|
|
|
423
407
|
return rule_name
|
|
@@ -69,12 +69,7 @@ def object_rule[T, R: ReturnType, **P](
|
|
|
69
69
|
]
|
|
70
70
|
|
|
71
71
|
print([e for e in EMPLOYEES if rule(e)])
|
|
72
|
-
|
|
73
|
-
# [
|
|
74
|
-
# User(name="Alice", age=18, is_admin=True),
|
|
75
|
-
# User(name="Admin", age=18, is_admin=False),
|
|
76
|
-
# User(name="Eve", age=8, is_admin=True),
|
|
77
|
-
# ]
|
|
72
|
+
# [User(name='Alice', age=18, is_admin=True), User(name='Admin', age=18, is_admin=False), User(name='Eve', age=8, is_admin=True)]
|
|
78
73
|
|
|
79
74
|
|
|
80
75
|
if __name__ == "__main__":
|
|
@@ -137,7 +132,7 @@ def subscriptable_rule[T, K, R: ReturnType, **P](
|
|
|
137
132
|
|
|
138
133
|
|
|
139
134
|
def main() -> None:
|
|
140
|
-
rule = string__ieq("name", "
|
|
135
|
+
rule = string__ieq("name", "alice") | int__ge("age", 18)
|
|
141
136
|
|
|
142
137
|
EMPLOYEES = [
|
|
143
138
|
{"name": "Alice", "age": 5},
|
|
@@ -149,13 +144,7 @@ def subscriptable_rule[T, K, R: ReturnType, **P](
|
|
|
149
144
|
]
|
|
150
145
|
|
|
151
146
|
print([e for e in EMPLOYEES if rule(e)])
|
|
152
|
-
|
|
153
|
-
# [
|
|
154
|
-
# {"name": "Alice", "age": 5},
|
|
155
|
-
# {"name": "Admin", "age": 6},
|
|
156
|
-
# {"name": "David", "age": 25},
|
|
157
|
-
# {"name": "Eve", "age": 30},
|
|
158
|
-
# ]
|
|
147
|
+
# [{'name': 'Alice', 'age': 5}, {'name': 'David', 'age': 25}, {'name': 'Eve', 'age': 30}]
|
|
159
148
|
|
|
160
149
|
|
|
161
150
|
if __name__ == "__main__":
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
RESERVED_WORDS = ("name", "expressions", "operator", "inverse", "args", "kwargs")
|
|
@@ -1,64 +0,0 @@
|
|
|
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[str, Any]: 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
|
-
}
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
from typing import Annotated, Any, Literal
|
|
2
|
-
|
|
3
|
-
from pydantic import AfterValidator, BaseModel, Field, RootModel, field_validator
|
|
4
|
-
|
|
5
|
-
from .validators import validate_python_vars_fn_naming_convention
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class PredicateSchema(BaseModel):
|
|
9
|
-
"""A schema for a predicate."""
|
|
10
|
-
|
|
11
|
-
name: Annotated[str, AfterValidator(validate_python_vars_fn_naming_convention)]
|
|
12
|
-
inverse: bool
|
|
13
|
-
args: list[Any]
|
|
14
|
-
kwargs: dict[str, Any]
|
|
15
|
-
|
|
16
|
-
@field_validator("kwargs", mode="after")
|
|
17
|
-
@classmethod
|
|
18
|
-
def _validate_kwargs(cls, v: dict[str, Any]) -> dict[str, Any]:
|
|
19
|
-
for key in v:
|
|
20
|
-
validate_python_vars_fn_naming_convention(key)
|
|
21
|
-
|
|
22
|
-
return v
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
class ExpressionsWrapperSchema(BaseModel):
|
|
26
|
-
"""A schema for an expression."""
|
|
27
|
-
|
|
28
|
-
operator: Literal["all", "any"]
|
|
29
|
-
inverse: bool
|
|
30
|
-
expressions: list["ExpressionType"] = Field(min_length=1)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
ExpressionType = Annotated[
|
|
34
|
-
ExpressionsWrapperSchema | PredicateSchema, Field(union_mode="left_to_right")
|
|
35
|
-
]
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
class RuleSchema(RootModel[ExpressionType]):
|
|
39
|
-
"""A schema for a rule."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|