pyspecification 2.0.2__tar.gz → 3.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-2.0.2 → pyspecification-3.0.0}/PKG-INFO +134 -24
- {pyspecification-2.0.2 → pyspecification-3.0.0}/README.md +133 -23
- {pyspecification-2.0.2 → pyspecification-3.0.0}/pyproject.toml +1 -1
- {pyspecification-2.0.2 → pyspecification-3.0.0}/pyproject.toml.orig +1 -1
- {pyspecification-2.0.2 → pyspecification-3.0.0}/src/pyspecification/__init__.py +5 -1
- {pyspecification-2.0.2 → pyspecification-3.0.0}/src/pyspecification/exceptions.py +12 -4
- pyspecification-3.0.0/src/pyspecification/json_schema.py +263 -0
- pyspecification-3.0.0/src/pyspecification/processors.py +155 -0
- {pyspecification-2.0.2 → pyspecification-3.0.0}/src/pyspecification/registry.py +71 -16
- {pyspecification-2.0.2 → pyspecification-3.0.0}/src/pyspecification/rules.py +12 -1
- pyspecification-2.0.2/src/pyspecification/json_schema.py +0 -116
- pyspecification-2.0.2/src/pyspecification/processors.py +0 -43
- {pyspecification-2.0.2 → pyspecification-3.0.0}/src/pyspecification/compilers.py +0 -0
- {pyspecification-2.0.2 → pyspecification-3.0.0}/src/pyspecification/predicate.py +0 -0
- {pyspecification-2.0.2 → pyspecification-3.0.0}/src/pyspecification/validators.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: pyspecification
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.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>
|
|
@@ -65,9 +65,9 @@ This keeps your logic:
|
|
|
65
65
|
- Subscriptable rules for dictionaries, lists, and generic lookup-based data
|
|
66
66
|
- `Predicate` objects that support logical composition
|
|
67
67
|
- Registry pattern for rule registration and lookup
|
|
68
|
-
- Custom argument processors for coercion and normalization
|
|
68
|
+
- Custom argument processors for coercion and normalization, validated when a rule is registered
|
|
69
69
|
- `PredicateCompiler` for compiling structured rule dictionaries into executable predicates
|
|
70
|
-
- JSON schema generation for
|
|
70
|
+
- JSON schema generation for single rules and for whole compilable expressions
|
|
71
71
|
- Support for both logical and bitwise operator modes
|
|
72
72
|
- Hidden rules and custom naming for internal/private rule registration
|
|
73
73
|
|
|
@@ -490,7 +490,10 @@ This pattern is especially useful when you want:
|
|
|
490
490
|
|
|
491
491
|
## Custom processors
|
|
492
492
|
|
|
493
|
-
Rules can apply argument processors to coerce values before evaluation.
|
|
493
|
+
Rules can apply argument processors to coerce values before evaluation. This is
|
|
494
|
+
most useful when rules come from JSON, where every value is a string, number,
|
|
495
|
+
boolean, list, dict or null, and your rule functions expect richer types such as
|
|
496
|
+
dates or decimals.
|
|
494
497
|
|
|
495
498
|
```python
|
|
496
499
|
from datetime import datetime
|
|
@@ -501,7 +504,7 @@ from pyspecification import SubscriptableRulesRegistry
|
|
|
501
504
|
rules = SubscriptableRulesRegistry[dict[str, object], str, bool](operator="logical")
|
|
502
505
|
|
|
503
506
|
|
|
504
|
-
@rules.rule(processors=
|
|
507
|
+
@rules.rule(processors={"value": lambda value: datetime.strptime(value, "%Y-%m-%d")})
|
|
505
508
|
def datetime__gt(obj: dict[str, object], key: str, value: datetime) -> bool:
|
|
506
509
|
return obj[key] > value
|
|
507
510
|
|
|
@@ -510,27 +513,45 @@ predicate = rules["datetime__gt"]("birthdate", "2001-06-01")
|
|
|
510
513
|
print(predicate({"birthdate": datetime(2005, 1, 1)})) # True
|
|
511
514
|
```
|
|
512
515
|
|
|
513
|
-
The
|
|
516
|
+
The `processors` option accepts:
|
|
514
517
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
+
- `None` (the default): arguments are passed through untouched.
|
|
519
|
+
- a callable: applied to every argument, e.g. `processors=int`.
|
|
520
|
+
- a mapping of parameter name to callable: applied to those arguments only,
|
|
521
|
+
e.g. `processors={"min_age": int}`. The key `...` (Ellipsis) means "every
|
|
522
|
+
argument not named here", e.g. `processors={"min_age": int, ...: str.strip}`.
|
|
523
|
+
|
|
524
|
+
Processors are matched by parameter name, so they apply whether a value was
|
|
525
|
+
passed positionally or by keyword. For `*args` the processor runs on each item,
|
|
526
|
+
and for `**kwargs` each extra keyword name is looked up individually. Default
|
|
527
|
+
values you did not pass are left as they are.
|
|
528
|
+
|
|
529
|
+
The object under test and, for subscriptable rules, the `key` are not
|
|
530
|
+
arguments: they are never processed and cannot be named in the mapping.
|
|
518
531
|
|
|
519
|
-
|
|
532
|
+
Processors are validated when the rule is registered, so a typo fails
|
|
533
|
+
immediately instead of silently never running:
|
|
520
534
|
|
|
521
535
|
```python
|
|
522
|
-
processors
|
|
523
|
-
|
|
524
|
-
{"value": str},
|
|
525
|
-
)
|
|
536
|
+
@rules.rule(processors={"valu": str}) # InvalidProcessorsError: unknown parameters ['valu']
|
|
537
|
+
def string__eq(obj: dict[str, object], key: str, value: str) -> bool: ...
|
|
526
538
|
```
|
|
527
539
|
|
|
528
|
-
|
|
540
|
+
`InvalidProcessorsError` is raised when the option is not a callable or a
|
|
541
|
+
mapping, when a mapping value is not callable, or when a key names no parameter
|
|
542
|
+
of the rule (rules that accept `**kwargs` allow any key).
|
|
543
|
+
|
|
544
|
+
If a processor raises, the library raises `ProcessArgumentError` naming the
|
|
545
|
+
parameter and value, with the original exception chained:
|
|
529
546
|
|
|
530
|
-
|
|
531
|
-
-
|
|
547
|
+
```text
|
|
548
|
+
Argument 'value' with value '06-01-2001' failed to process, time data '06-01-2001' does not match format '%Y-%m-%d'
|
|
549
|
+
```
|
|
532
550
|
|
|
533
|
-
|
|
551
|
+
> **Upgrading from 2.x:** processors used to be a `(default_processor,
|
|
552
|
+
> named_processors_map)` tuple. Pass a callable instead of the tuple for the
|
|
553
|
+
> old "process everything" behavior, or a mapping for named parameters. The
|
|
554
|
+
> old tuple form is now rejected with `InvalidProcessorsError`.
|
|
534
555
|
|
|
535
556
|
---
|
|
536
557
|
|
|
@@ -693,7 +714,17 @@ not match a predicate or wrapper shape, it raises `TypeError`.
|
|
|
693
714
|
|
|
694
715
|
## JSON schema generation
|
|
695
716
|
|
|
696
|
-
|
|
717
|
+
The generated schemas describe the exact shape accepted by `PredicateCompiler`,
|
|
718
|
+
so they can be handed to a form builder, a validator, or an LLM that writes
|
|
719
|
+
rules as JSON.
|
|
720
|
+
|
|
721
|
+
### One rule
|
|
722
|
+
|
|
723
|
+
`get_rule_json_schema(name, rule)` returns the schema of one predicate
|
|
724
|
+
dictionary. Positional parameters are described under `args` (via
|
|
725
|
+
`prefixItems`), keyword-capable parameters under `kwargs`, and the rule's
|
|
726
|
+
docstring becomes the description. The object under test is not part of the
|
|
727
|
+
schema; for subscriptable rules the `key` is the first parameter.
|
|
697
728
|
|
|
698
729
|
```python
|
|
699
730
|
from dataclasses import dataclass
|
|
@@ -709,19 +740,94 @@ class User:
|
|
|
709
740
|
|
|
710
741
|
|
|
711
742
|
@object_rule()
|
|
712
|
-
def
|
|
713
|
-
|
|
743
|
+
def age__between(user: User, min_age: int, max_age: int = 120) -> bool:
|
|
744
|
+
"""Whether the age is within a range."""
|
|
745
|
+
return min_age <= user.age <= max_age
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
print(get_rule_json_schema("age__between", age__between))
|
|
749
|
+
# {
|
|
750
|
+
# "type": "object",
|
|
751
|
+
# "properties": {
|
|
752
|
+
# "name": {"const": "age__between"},
|
|
753
|
+
# "args": {
|
|
754
|
+
# "type": "array",
|
|
755
|
+
# "prefixItems": [{"type": "integer"}, {"type": "integer", "default": 120}],
|
|
756
|
+
# "items": False,
|
|
757
|
+
# },
|
|
758
|
+
# "kwargs": {
|
|
759
|
+
# "type": "object",
|
|
760
|
+
# "properties": {
|
|
761
|
+
# "min_age": {"type": "integer"},
|
|
762
|
+
# "max_age": {"type": "integer", "default": 120},
|
|
763
|
+
# },
|
|
764
|
+
# "required": [],
|
|
765
|
+
# "additionalProperties": False,
|
|
766
|
+
# },
|
|
767
|
+
# "inverse": {"type": "boolean"},
|
|
768
|
+
# },
|
|
769
|
+
# "required": ["name", "args", "kwargs", "inverse"],
|
|
770
|
+
# "additionalProperties": False,
|
|
771
|
+
# "description": "Whether the age is within a range.",
|
|
772
|
+
# }
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
### A whole expression
|
|
714
776
|
|
|
777
|
+
`get_expression_json_schema(rules)` returns the schema of everything
|
|
778
|
+
`PredicateCompiler.compile` accepts: a predicate dictionary for each rule, or a
|
|
779
|
+
nested `{"operator": "all" | "any", "expressions": [...]}` wrapper. Pass
|
|
780
|
+
`registry.rules` (hidden rules are left out) or any name-to-rule mapping you
|
|
781
|
+
give to the compiler.
|
|
715
782
|
|
|
716
|
-
|
|
717
|
-
|
|
783
|
+
```python
|
|
784
|
+
from pyspecification import ObjectRulesRegistry, get_expression_json_schema
|
|
785
|
+
|
|
786
|
+
registry = ObjectRulesRegistry[User, bool](operator="logical")
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
@registry.rule()
|
|
790
|
+
def is_admin(user: User) -> bool:
|
|
791
|
+
return user.is_admin
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
schema = get_expression_json_schema(registry.rules)
|
|
795
|
+
print(schema["$schema"]) # https://json-schema.org/draft/2020-12/schema
|
|
718
796
|
```
|
|
719
797
|
|
|
798
|
+
### Supported types
|
|
799
|
+
|
|
800
|
+
`get_json_schema(annotation)` maps Python annotations to JSON Schema. Unknown or
|
|
801
|
+
unannotated types map to `{}`, which accepts any value.
|
|
802
|
+
|
|
803
|
+
| Python annotation | JSON Schema |
|
|
804
|
+
| --- | --- |
|
|
805
|
+
| `str`, `int`, `float`, `bool`, `None` | `string`, `integer`, `number`, `boolean`, `null` |
|
|
806
|
+
| `Decimal` | `number` |
|
|
807
|
+
| `datetime`, `date`, `time`, `UUID` | `string` with `date-time`, `date`, `time`, `uuid` format |
|
|
808
|
+
| `list[T]`, `set[T]`, `frozenset[T]`, `tuple[T, ...]` | `array` of `T` |
|
|
809
|
+
| `dict[K, V]` | `object` with `V` as `additionalProperties` |
|
|
810
|
+
| `Literal[...]`, `Enum` subclasses | `enum` of the values (plus `string` type when all are strings) |
|
|
811
|
+
| `A \| B`, `Optional[A]` | `anyOf` |
|
|
812
|
+
| `TypedDict` | `object` with `properties` and `required` (`Required`/`NotRequired` honored) |
|
|
813
|
+
| `Annotated[T, ...]`, `type` aliases | the schema of `T` / the aliased type |
|
|
814
|
+
|
|
815
|
+
Fixed-size heterogeneous tuples such as `tuple[int, str]` are described by their
|
|
816
|
+
first member only. String annotations are evaluated when possible, and fall back
|
|
817
|
+
to `{}` when they cannot be resolved. Defaults are included when they are plain
|
|
818
|
+
JSON values.
|
|
819
|
+
|
|
820
|
+
> **Upgrading from 2.x:** `get_rule_json_schema(rule)` used to return a flat
|
|
821
|
+
> `{parameter: schema, "return": schema}` mapping. It now takes the rule name
|
|
822
|
+
> too and returns the predicate dictionary schema shown above. `get_json_schema`
|
|
823
|
+
> is now exported from the package root.
|
|
824
|
+
|
|
720
825
|
This is useful for:
|
|
721
826
|
|
|
722
827
|
- generating UIs for rule configuration
|
|
723
828
|
- building admin tools and dashboards
|
|
724
|
-
-
|
|
829
|
+
- validating rule payloads before compiling them
|
|
830
|
+
- describing rule inputs to other systems and LLMs
|
|
725
831
|
- documenting business rules programmatically
|
|
726
832
|
|
|
727
833
|
---
|
|
@@ -904,6 +1010,7 @@ The library raises explicit exceptions for rule issues:
|
|
|
904
1010
|
- `UnexpectedKeywordArgumentError`
|
|
905
1011
|
- `TooManyArgumentsError`
|
|
906
1012
|
- `ProcessArgumentError`
|
|
1013
|
+
- `InvalidProcessorsError`
|
|
907
1014
|
|
|
908
1015
|
`ArgumentError` is the base class for failures involving arguments passed to a
|
|
909
1016
|
rule. Its specialized exceptions describe the problem:
|
|
@@ -917,6 +1024,9 @@ rule. Its specialized exceptions describe the problem:
|
|
|
917
1024
|
- `ProcessArgumentError` means an argument processor could not convert or
|
|
918
1025
|
otherwise process a value.
|
|
919
1026
|
|
|
1027
|
+
`InvalidProcessorsError` (a `TypeError`) is raised at registration when the
|
|
1028
|
+
`processors` option is malformed or names a parameter the rule does not have.
|
|
1029
|
+
|
|
920
1030
|
`RuleDoesNotExistError` includes the missing name and the available rule names,
|
|
921
1031
|
which is useful when rules are dynamically loaded. Malformed normalized
|
|
922
1032
|
compiler payloads raise `TypeError`; its message identifies the JSON-like path
|
|
@@ -51,9 +51,9 @@ This keeps your logic:
|
|
|
51
51
|
- Subscriptable rules for dictionaries, lists, and generic lookup-based data
|
|
52
52
|
- `Predicate` objects that support logical composition
|
|
53
53
|
- Registry pattern for rule registration and lookup
|
|
54
|
-
- Custom argument processors for coercion and normalization
|
|
54
|
+
- Custom argument processors for coercion and normalization, validated when a rule is registered
|
|
55
55
|
- `PredicateCompiler` for compiling structured rule dictionaries into executable predicates
|
|
56
|
-
- JSON schema generation for
|
|
56
|
+
- JSON schema generation for single rules and for whole compilable expressions
|
|
57
57
|
- Support for both logical and bitwise operator modes
|
|
58
58
|
- Hidden rules and custom naming for internal/private rule registration
|
|
59
59
|
|
|
@@ -476,7 +476,10 @@ This pattern is especially useful when you want:
|
|
|
476
476
|
|
|
477
477
|
## Custom processors
|
|
478
478
|
|
|
479
|
-
Rules can apply argument processors to coerce values before evaluation.
|
|
479
|
+
Rules can apply argument processors to coerce values before evaluation. This is
|
|
480
|
+
most useful when rules come from JSON, where every value is a string, number,
|
|
481
|
+
boolean, list, dict or null, and your rule functions expect richer types such as
|
|
482
|
+
dates or decimals.
|
|
480
483
|
|
|
481
484
|
```python
|
|
482
485
|
from datetime import datetime
|
|
@@ -487,7 +490,7 @@ from pyspecification import SubscriptableRulesRegistry
|
|
|
487
490
|
rules = SubscriptableRulesRegistry[dict[str, object], str, bool](operator="logical")
|
|
488
491
|
|
|
489
492
|
|
|
490
|
-
@rules.rule(processors=
|
|
493
|
+
@rules.rule(processors={"value": lambda value: datetime.strptime(value, "%Y-%m-%d")})
|
|
491
494
|
def datetime__gt(obj: dict[str, object], key: str, value: datetime) -> bool:
|
|
492
495
|
return obj[key] > value
|
|
493
496
|
|
|
@@ -496,27 +499,45 @@ predicate = rules["datetime__gt"]("birthdate", "2001-06-01")
|
|
|
496
499
|
print(predicate({"birthdate": datetime(2005, 1, 1)})) # True
|
|
497
500
|
```
|
|
498
501
|
|
|
499
|
-
The
|
|
502
|
+
The `processors` option accepts:
|
|
500
503
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
+
- `None` (the default): arguments are passed through untouched.
|
|
505
|
+
- a callable: applied to every argument, e.g. `processors=int`.
|
|
506
|
+
- a mapping of parameter name to callable: applied to those arguments only,
|
|
507
|
+
e.g. `processors={"min_age": int}`. The key `...` (Ellipsis) means "every
|
|
508
|
+
argument not named here", e.g. `processors={"min_age": int, ...: str.strip}`.
|
|
509
|
+
|
|
510
|
+
Processors are matched by parameter name, so they apply whether a value was
|
|
511
|
+
passed positionally or by keyword. For `*args` the processor runs on each item,
|
|
512
|
+
and for `**kwargs` each extra keyword name is looked up individually. Default
|
|
513
|
+
values you did not pass are left as they are.
|
|
514
|
+
|
|
515
|
+
The object under test and, for subscriptable rules, the `key` are not
|
|
516
|
+
arguments: they are never processed and cannot be named in the mapping.
|
|
504
517
|
|
|
505
|
-
|
|
518
|
+
Processors are validated when the rule is registered, so a typo fails
|
|
519
|
+
immediately instead of silently never running:
|
|
506
520
|
|
|
507
521
|
```python
|
|
508
|
-
processors
|
|
509
|
-
|
|
510
|
-
{"value": str},
|
|
511
|
-
)
|
|
522
|
+
@rules.rule(processors={"valu": str}) # InvalidProcessorsError: unknown parameters ['valu']
|
|
523
|
+
def string__eq(obj: dict[str, object], key: str, value: str) -> bool: ...
|
|
512
524
|
```
|
|
513
525
|
|
|
514
|
-
|
|
526
|
+
`InvalidProcessorsError` is raised when the option is not a callable or a
|
|
527
|
+
mapping, when a mapping value is not callable, or when a key names no parameter
|
|
528
|
+
of the rule (rules that accept `**kwargs` allow any key).
|
|
529
|
+
|
|
530
|
+
If a processor raises, the library raises `ProcessArgumentError` naming the
|
|
531
|
+
parameter and value, with the original exception chained:
|
|
515
532
|
|
|
516
|
-
|
|
517
|
-
-
|
|
533
|
+
```text
|
|
534
|
+
Argument 'value' with value '06-01-2001' failed to process, time data '06-01-2001' does not match format '%Y-%m-%d'
|
|
535
|
+
```
|
|
518
536
|
|
|
519
|
-
|
|
537
|
+
> **Upgrading from 2.x:** processors used to be a `(default_processor,
|
|
538
|
+
> named_processors_map)` tuple. Pass a callable instead of the tuple for the
|
|
539
|
+
> old "process everything" behavior, or a mapping for named parameters. The
|
|
540
|
+
> old tuple form is now rejected with `InvalidProcessorsError`.
|
|
520
541
|
|
|
521
542
|
---
|
|
522
543
|
|
|
@@ -679,7 +700,17 @@ not match a predicate or wrapper shape, it raises `TypeError`.
|
|
|
679
700
|
|
|
680
701
|
## JSON schema generation
|
|
681
702
|
|
|
682
|
-
|
|
703
|
+
The generated schemas describe the exact shape accepted by `PredicateCompiler`,
|
|
704
|
+
so they can be handed to a form builder, a validator, or an LLM that writes
|
|
705
|
+
rules as JSON.
|
|
706
|
+
|
|
707
|
+
### One rule
|
|
708
|
+
|
|
709
|
+
`get_rule_json_schema(name, rule)` returns the schema of one predicate
|
|
710
|
+
dictionary. Positional parameters are described under `args` (via
|
|
711
|
+
`prefixItems`), keyword-capable parameters under `kwargs`, and the rule's
|
|
712
|
+
docstring becomes the description. The object under test is not part of the
|
|
713
|
+
schema; for subscriptable rules the `key` is the first parameter.
|
|
683
714
|
|
|
684
715
|
```python
|
|
685
716
|
from dataclasses import dataclass
|
|
@@ -695,19 +726,94 @@ class User:
|
|
|
695
726
|
|
|
696
727
|
|
|
697
728
|
@object_rule()
|
|
698
|
-
def
|
|
699
|
-
|
|
729
|
+
def age__between(user: User, min_age: int, max_age: int = 120) -> bool:
|
|
730
|
+
"""Whether the age is within a range."""
|
|
731
|
+
return min_age <= user.age <= max_age
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
print(get_rule_json_schema("age__between", age__between))
|
|
735
|
+
# {
|
|
736
|
+
# "type": "object",
|
|
737
|
+
# "properties": {
|
|
738
|
+
# "name": {"const": "age__between"},
|
|
739
|
+
# "args": {
|
|
740
|
+
# "type": "array",
|
|
741
|
+
# "prefixItems": [{"type": "integer"}, {"type": "integer", "default": 120}],
|
|
742
|
+
# "items": False,
|
|
743
|
+
# },
|
|
744
|
+
# "kwargs": {
|
|
745
|
+
# "type": "object",
|
|
746
|
+
# "properties": {
|
|
747
|
+
# "min_age": {"type": "integer"},
|
|
748
|
+
# "max_age": {"type": "integer", "default": 120},
|
|
749
|
+
# },
|
|
750
|
+
# "required": [],
|
|
751
|
+
# "additionalProperties": False,
|
|
752
|
+
# },
|
|
753
|
+
# "inverse": {"type": "boolean"},
|
|
754
|
+
# },
|
|
755
|
+
# "required": ["name", "args", "kwargs", "inverse"],
|
|
756
|
+
# "additionalProperties": False,
|
|
757
|
+
# "description": "Whether the age is within a range.",
|
|
758
|
+
# }
|
|
759
|
+
```
|
|
760
|
+
|
|
761
|
+
### A whole expression
|
|
700
762
|
|
|
763
|
+
`get_expression_json_schema(rules)` returns the schema of everything
|
|
764
|
+
`PredicateCompiler.compile` accepts: a predicate dictionary for each rule, or a
|
|
765
|
+
nested `{"operator": "all" | "any", "expressions": [...]}` wrapper. Pass
|
|
766
|
+
`registry.rules` (hidden rules are left out) or any name-to-rule mapping you
|
|
767
|
+
give to the compiler.
|
|
701
768
|
|
|
702
|
-
|
|
703
|
-
|
|
769
|
+
```python
|
|
770
|
+
from pyspecification import ObjectRulesRegistry, get_expression_json_schema
|
|
771
|
+
|
|
772
|
+
registry = ObjectRulesRegistry[User, bool](operator="logical")
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
@registry.rule()
|
|
776
|
+
def is_admin(user: User) -> bool:
|
|
777
|
+
return user.is_admin
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
schema = get_expression_json_schema(registry.rules)
|
|
781
|
+
print(schema["$schema"]) # https://json-schema.org/draft/2020-12/schema
|
|
704
782
|
```
|
|
705
783
|
|
|
784
|
+
### Supported types
|
|
785
|
+
|
|
786
|
+
`get_json_schema(annotation)` maps Python annotations to JSON Schema. Unknown or
|
|
787
|
+
unannotated types map to `{}`, which accepts any value.
|
|
788
|
+
|
|
789
|
+
| Python annotation | JSON Schema |
|
|
790
|
+
| --- | --- |
|
|
791
|
+
| `str`, `int`, `float`, `bool`, `None` | `string`, `integer`, `number`, `boolean`, `null` |
|
|
792
|
+
| `Decimal` | `number` |
|
|
793
|
+
| `datetime`, `date`, `time`, `UUID` | `string` with `date-time`, `date`, `time`, `uuid` format |
|
|
794
|
+
| `list[T]`, `set[T]`, `frozenset[T]`, `tuple[T, ...]` | `array` of `T` |
|
|
795
|
+
| `dict[K, V]` | `object` with `V` as `additionalProperties` |
|
|
796
|
+
| `Literal[...]`, `Enum` subclasses | `enum` of the values (plus `string` type when all are strings) |
|
|
797
|
+
| `A \| B`, `Optional[A]` | `anyOf` |
|
|
798
|
+
| `TypedDict` | `object` with `properties` and `required` (`Required`/`NotRequired` honored) |
|
|
799
|
+
| `Annotated[T, ...]`, `type` aliases | the schema of `T` / the aliased type |
|
|
800
|
+
|
|
801
|
+
Fixed-size heterogeneous tuples such as `tuple[int, str]` are described by their
|
|
802
|
+
first member only. String annotations are evaluated when possible, and fall back
|
|
803
|
+
to `{}` when they cannot be resolved. Defaults are included when they are plain
|
|
804
|
+
JSON values.
|
|
805
|
+
|
|
806
|
+
> **Upgrading from 2.x:** `get_rule_json_schema(rule)` used to return a flat
|
|
807
|
+
> `{parameter: schema, "return": schema}` mapping. It now takes the rule name
|
|
808
|
+
> too and returns the predicate dictionary schema shown above. `get_json_schema`
|
|
809
|
+
> is now exported from the package root.
|
|
810
|
+
|
|
706
811
|
This is useful for:
|
|
707
812
|
|
|
708
813
|
- generating UIs for rule configuration
|
|
709
814
|
- building admin tools and dashboards
|
|
710
|
-
-
|
|
815
|
+
- validating rule payloads before compiling them
|
|
816
|
+
- describing rule inputs to other systems and LLMs
|
|
711
817
|
- documenting business rules programmatically
|
|
712
818
|
|
|
713
819
|
---
|
|
@@ -890,6 +996,7 @@ The library raises explicit exceptions for rule issues:
|
|
|
890
996
|
- `UnexpectedKeywordArgumentError`
|
|
891
997
|
- `TooManyArgumentsError`
|
|
892
998
|
- `ProcessArgumentError`
|
|
999
|
+
- `InvalidProcessorsError`
|
|
893
1000
|
|
|
894
1001
|
`ArgumentError` is the base class for failures involving arguments passed to a
|
|
895
1002
|
rule. Its specialized exceptions describe the problem:
|
|
@@ -903,6 +1010,9 @@ rule. Its specialized exceptions describe the problem:
|
|
|
903
1010
|
- `ProcessArgumentError` means an argument processor could not convert or
|
|
904
1011
|
otherwise process a value.
|
|
905
1012
|
|
|
1013
|
+
`InvalidProcessorsError` (a `TypeError`) is raised at registration when the
|
|
1014
|
+
`processors` option is malformed or names a parameter the rule does not have.
|
|
1015
|
+
|
|
906
1016
|
`RuleDoesNotExistError` includes the missing name and the available rule names,
|
|
907
1017
|
which is useful when rules are dynamically loaded. Malformed normalized
|
|
908
1018
|
compiler payloads raise `TypeError`; its message identifies the JSON-like path
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pyspecification"
|
|
3
|
-
version = "
|
|
3
|
+
version = "3.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,6 +7,7 @@ This project was inspired by the work and ideas shared by [ArjanCodes](https://g
|
|
|
7
7
|
from .compilers import ExpressionWrapperDict, PredicateCompiler, PredicateDict
|
|
8
8
|
from .exceptions import (
|
|
9
9
|
ArgumentError,
|
|
10
|
+
InvalidProcessorsError,
|
|
10
11
|
MissingArgumentError,
|
|
11
12
|
PositionalOnlyArgumentError,
|
|
12
13
|
ProcessArgumentError,
|
|
@@ -16,7 +17,7 @@ from .exceptions import (
|
|
|
16
17
|
TooManyArgumentsError,
|
|
17
18
|
UnexpectedKeywordArgumentError,
|
|
18
19
|
)
|
|
19
|
-
from .json_schema import get_rule_json_schema
|
|
20
|
+
from .json_schema import get_expression_json_schema, get_json_schema, get_rule_json_schema
|
|
20
21
|
from .predicate import OperatorType, Predicate
|
|
21
22
|
from .registry import ObjectRulesRegistry, SubscriptableRulesRegistry
|
|
22
23
|
from .rules import object_rule, subscriptable_rule
|
|
@@ -24,6 +25,7 @@ from .rules import object_rule, subscriptable_rule
|
|
|
24
25
|
__all__ = [
|
|
25
26
|
"ArgumentError",
|
|
26
27
|
"ExpressionWrapperDict",
|
|
28
|
+
"InvalidProcessorsError",
|
|
27
29
|
"MissingArgumentError",
|
|
28
30
|
"ObjectRulesRegistry",
|
|
29
31
|
"OperatorType",
|
|
@@ -38,6 +40,8 @@ __all__ = [
|
|
|
38
40
|
"SubscriptableRulesRegistry",
|
|
39
41
|
"TooManyArgumentsError",
|
|
40
42
|
"UnexpectedKeywordArgumentError",
|
|
43
|
+
"get_expression_json_schema",
|
|
44
|
+
"get_json_schema",
|
|
41
45
|
"get_rule_json_schema",
|
|
42
46
|
"object_rule",
|
|
43
47
|
"subscriptable_rule",
|
|
@@ -2,7 +2,11 @@ import re
|
|
|
2
2
|
from collections.abc import Iterable
|
|
3
3
|
|
|
4
4
|
|
|
5
|
-
class
|
|
5
|
+
class PySpecificationError(Exception):
|
|
6
|
+
"""Base class for all PySpecification exceptions."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RuleDoesNotExistError(PySpecificationError):
|
|
6
10
|
"""Exception raised when a rule does not exist."""
|
|
7
11
|
|
|
8
12
|
def __init__(self, name: str, available_rules: Iterable[str] | None = None) -> None:
|
|
@@ -14,21 +18,21 @@ class RuleDoesNotExistError(Exception):
|
|
|
14
18
|
super().__init__(msg)
|
|
15
19
|
|
|
16
20
|
|
|
17
|
-
class RuleAlreadyRegisteredError(
|
|
21
|
+
class RuleAlreadyRegisteredError(PySpecificationError):
|
|
18
22
|
"""Exception raised when a rule is already registered in registry class."""
|
|
19
23
|
|
|
20
24
|
def __init__(self, name: str) -> None:
|
|
21
25
|
super().__init__(f"Rule '{name}' is already registered")
|
|
22
26
|
|
|
23
27
|
|
|
24
|
-
class RuleKeyDoesNotExistError(
|
|
28
|
+
class RuleKeyDoesNotExistError(PySpecificationError):
|
|
25
29
|
"""Exception raised when a key does not exist in the object of rule."""
|
|
26
30
|
|
|
27
31
|
def __init__(self, key: str, rule_name: str) -> None:
|
|
28
32
|
super().__init__(f"Key '{key}' does not exist in the object of rule '{rule_name}'")
|
|
29
33
|
|
|
30
34
|
|
|
31
|
-
class ArgumentError(TypeError):
|
|
35
|
+
class ArgumentError(PySpecificationError, TypeError):
|
|
32
36
|
pass
|
|
33
37
|
|
|
34
38
|
|
|
@@ -72,3 +76,7 @@ def is_positional_only_argument_exception(e: TypeError) -> bool:
|
|
|
72
76
|
|
|
73
77
|
class ProcessArgumentError(ArgumentError):
|
|
74
78
|
"""Exception raised when a process argument fails in registry class."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class InvalidProcessorsError(PySpecificationError, TypeError):
|
|
82
|
+
"""Exception raised when the `processors` option of a rule is malformed."""
|