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.
@@ -1,13 +1,12 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pyspecification
3
- Version: 1.0.0
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
- - generated schemas make rule metadata portable and machine-readable
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, RuleSchema
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(RuleSchema(**filter_rule_data).model_dump())
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, RuleSchema, object_rule
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": "rule_not_exists",
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(RuleSchema(**rule_data).model_dump())
476
- print(predicate(User("admin", 25, True))) # True
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
- ```json
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
- The normalized schema is convenient when rule definitions arrive as JSON:
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
- ### Expression wrapper schema
493
+ print(repr(predicate)) # Predicate(age__between)
494
+ ```
546
495
 
547
- Use a wrapper to combine predicates. A wrapper has an `operator`, an
548
- `expressions` list, and an `inverse` flag:
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
- "inverse": false
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
- "inverse": true
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 schema, it raises `TypeError`. The error
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
- `get_json_schema` inspects a rule function and returns JSON-schema-like metadata for parameters and return value.
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 get_json_schema, object_rule
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(get_json_schema(name__istartswith))
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, RuleSchema
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(RuleSchema(**filter_rule).model_dump())
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 and schema APIs make it easy to store or receive rules as structured data.
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 schemas.
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
- - schema serialization
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, PredicateCompiler, RuleSchema
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(schema.model_dump())
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, schema validation, and runtime compilation in a compact library designed around functional and declarative rule authoring.
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
- - validate dynamic rule payloads
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
- - generated schemas make rule metadata portable and machine-readable
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, RuleSchema
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(RuleSchema(**filter_rule_data).model_dump())
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, RuleSchema, object_rule
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": "rule_not_exists",
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(RuleSchema(**rule_data).model_dump())
461
- print(predicate(User("admin", 25, True))) # True
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
- ```json
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
- The normalized schema is convenient when rule definitions arrive as JSON:
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
- ### Expression wrapper schema
479
+ print(repr(predicate)) # Predicate(age__between)
480
+ ```
531
481
 
532
- Use a wrapper to combine predicates. A wrapper has an `operator`, an
533
- `expressions` list, and an `inverse` flag:
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
- "inverse": false
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
- "inverse": true
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 schema, it raises `TypeError`. The error
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
- `get_json_schema` inspects a rule function and returns JSON-schema-like metadata for parameters and return value.
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 get_json_schema, object_rule
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(get_json_schema(name__istartswith))
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, RuleSchema
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(RuleSchema(**filter_rule).model_dump())
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 and schema APIs make it easy to store or receive rules as structured data.
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 schemas.
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
- - schema serialization
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, PredicateCompiler, RuleSchema
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(schema.model_dump())
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, schema validation, and runtime compilation in a compact library designed around functional and declarative rule authoring.
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
- - validate dynamic rule payloads
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 = "1.0.0"
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 = ["pydantic==2.5.3"]
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 = "1.0.0"
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 = ["pydantic==2.5.3"]
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 get_json_schema
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
- "get_json_schema",
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", "inverse"}
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
- assert all(rule(user) for user in users)
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(TypeAdapter(PredicateDict).json_schema(), indent=2),
185
+ json.dumps(get_json_schema(PredicateDict), indent=2),
194
186
  "",
195
187
  "Expression wrapper:",
196
- json.dumps(TypeAdapter(ExpressionWrapperDict).json_schema(), indent=2),
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
- assert repr(rule) == "Predicate(is_admin OR (name__istartswith AND age__between))"
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 == "abdullah" AND age >= 18
234
- rule = reg["string__ieq"]("name", "a") | reg["int__ge"]("age", 18)
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", "a") | int__ge("age", 18)
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."""