pyspecification 2.0.2__tar.gz → 4.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-4.0.0}/PKG-INFO +200 -36
- {pyspecification-2.0.2 → pyspecification-4.0.0}/README.md +198 -35
- {pyspecification-2.0.2 → pyspecification-4.0.0}/pyproject.toml +2 -2
- {pyspecification-2.0.2 → pyspecification-4.0.0}/pyproject.toml.orig +4 -2
- {pyspecification-2.0.2 → pyspecification-4.0.0}/src/pyspecification/__init__.py +14 -3
- {pyspecification-2.0.2 → pyspecification-4.0.0}/src/pyspecification/compilers.py +17 -10
- pyspecification-4.0.0/src/pyspecification/exceptions.py +95 -0
- pyspecification-4.0.0/src/pyspecification/json_schema.py +268 -0
- {pyspecification-2.0.2 → pyspecification-4.0.0}/src/pyspecification/registry.py +23 -51
- pyspecification-4.0.0/src/pyspecification/rules.py +281 -0
- pyspecification-2.0.2/src/pyspecification/exceptions.py +0 -74
- 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/src/pyspecification/rules.py +0 -205
- {pyspecification-2.0.2 → pyspecification-4.0.0}/src/pyspecification/predicate.py +0 -0
- {pyspecification-2.0.2 → pyspecification-4.0.0}/src/pyspecification/validators.py +0 -0
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: pyspecification
|
|
3
|
-
Version:
|
|
3
|
+
Version: 4.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: pyargprocessors>=0.1.0
|
|
10
11
|
Requires-Python: >=3.12
|
|
11
12
|
Project-URL: Homepage, https://github.com/aaltatan/pyspecification
|
|
12
13
|
Project-URL: Issues, https://github.com/aaltatan/pyspecification/issues
|
|
@@ -65,9 +66,10 @@ This keeps your logic:
|
|
|
65
66
|
- Subscriptable rules for dictionaries, lists, and generic lookup-based data
|
|
66
67
|
- `Predicate` objects that support logical composition
|
|
67
68
|
- Registry pattern for rule registration and lookup
|
|
68
|
-
-
|
|
69
|
+
- `Process` markers (from [pyargprocessors](https://github.com/aaltatan/pyargprocessors)) that coerce and normalize raw arguments, such as strings from JSON or forms, into the objects a rule wants
|
|
70
|
+
- Argument mistakes and failing processors reported while a rule is built, before anything is evaluated
|
|
69
71
|
- `PredicateCompiler` for compiling structured rule dictionaries into executable predicates
|
|
70
|
-
- JSON schema generation for
|
|
72
|
+
- JSON schema generation for single rules and for whole compilable expressions
|
|
71
73
|
- Support for both logical and bitwise operator modes
|
|
72
74
|
- Hidden rules and custom naming for internal/private rule registration
|
|
73
75
|
|
|
@@ -488,21 +490,30 @@ This pattern is especially useful when you want:
|
|
|
488
490
|
|
|
489
491
|
---
|
|
490
492
|
|
|
491
|
-
##
|
|
493
|
+
## Processing arguments with `Process`
|
|
492
494
|
|
|
493
|
-
Rules
|
|
495
|
+
Rules often come from JSON or a web form, where every value is a string, number,
|
|
496
|
+
boolean, list, dict or null, while your rule functions want dates, decimals,
|
|
497
|
+
enums, clean text or loaded data. Mark a parameter with `Process(fn)` inside
|
|
498
|
+
`Annotated`, and `fn` is applied to the value when the rule is built. The marker
|
|
499
|
+
comes from [pyargprocessors](https://github.com/aaltatan/pyargprocessors), a
|
|
500
|
+
dependency of this package, and is re-exported here:
|
|
494
501
|
|
|
495
502
|
```python
|
|
496
503
|
from datetime import datetime
|
|
504
|
+
from typing import Annotated
|
|
497
505
|
|
|
498
|
-
from pyspecification import SubscriptableRulesRegistry
|
|
499
|
-
|
|
506
|
+
from pyspecification import Process, SubscriptableRulesRegistry
|
|
500
507
|
|
|
501
508
|
rules = SubscriptableRulesRegistry[dict[str, object], str, bool](operator="logical")
|
|
502
509
|
|
|
503
510
|
|
|
504
|
-
|
|
505
|
-
|
|
511
|
+
def parse_date(text: str) -> datetime:
|
|
512
|
+
return datetime.strptime(text, "%Y-%m-%d")
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
@rules.rule()
|
|
516
|
+
def datetime__gt(obj: dict[str, object], key: str, value: Annotated[datetime, Process(parse_date)]) -> bool:
|
|
506
517
|
return obj[key] > value
|
|
507
518
|
|
|
508
519
|
|
|
@@ -510,27 +521,72 @@ predicate = rules["datetime__gt"]("birthdate", "2001-06-01")
|
|
|
510
521
|
print(predicate({"birthdate": datetime(2005, 1, 1)})) # True
|
|
511
522
|
```
|
|
512
523
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
524
|
+
`Process` works on `object_rule` and `subscriptable_rule` directly as well as
|
|
525
|
+
through the registries. A few things worth knowing:
|
|
526
|
+
|
|
527
|
+
- Name a processed type once with a `type` alias and reuse it in many rules, and
|
|
528
|
+
chain several markers (they run left to right):
|
|
529
|
+
|
|
530
|
+
```python
|
|
531
|
+
type Text = Annotated[str, Process(str.strip), Process(str.lower)]
|
|
532
|
+
type Money = Annotated[Decimal, Process(Decimal)]
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
- A marker applies whether the value was passed positionally or by keyword. On
|
|
536
|
+
`*args` it runs on each item, and on `**kwargs` on each value.
|
|
537
|
+
- Processing happens once, when the rule is built, so a bad value fails before the
|
|
538
|
+
predicate ever runs. Default values you did not pass are never processed.
|
|
539
|
+
- The processor may take a different input than the rule: `Annotated[frozenset[str],
|
|
540
|
+
Process(load_names)]` lets the JSON send a file path while the rule receives the
|
|
541
|
+
loaded names. The JSON schema describes what the JSON must send (the processor's
|
|
542
|
+
first parameter annotation), see below.
|
|
543
|
+
- The object under test and, for subscriptable rules, the key are not
|
|
544
|
+
arguments: a marker on them raises `InvalidProcessorError`.
|
|
545
|
+
- `pyargprocessors` also provides `each` and `optional` to build a processor out
|
|
546
|
+
of another one (lists, nulls) and `process_value` to apply a processed type to
|
|
547
|
+
a single value; see its documentation.
|
|
548
|
+
|
|
549
|
+
If a processor raises, the library raises `ProcessArgumentError` naming the
|
|
550
|
+
argument and value, with the original exception chained. It also carries
|
|
551
|
+
`argument`, `value` and `cause` attributes:
|
|
552
|
+
|
|
553
|
+
```text
|
|
554
|
+
Argument 'value' with value '06-01-2001' failed to process, time data '06-01-2001' does not match format '%Y-%m-%d'
|
|
517
555
|
```
|
|
518
556
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
557
|
+
`InvalidProcessorError` (a `TypeError`) is raised as soon as the rule is defined
|
|
558
|
+
when a `Process` is not callable, is placed on the object under test or the key,
|
|
559
|
+
or is written as a default value instead of inside `Annotated`.
|
|
560
|
+
|
|
561
|
+
### Argument errors
|
|
562
|
+
|
|
563
|
+
Arguments are bound to the rule signature as soon as the rule is called, so
|
|
564
|
+
mistakes surface while a predicate is being built (including by
|
|
565
|
+
`PredicateCompiler.compile`), not when it is evaluated. They are reported in the
|
|
566
|
+
order Python reports them for a call: keywords that cannot be passed by name,
|
|
567
|
+
surplus or repeated arguments, then missing ones.
|
|
568
|
+
|
|
569
|
+
| Mistake | Exception |
|
|
570
|
+
| --- | --- |
|
|
571
|
+
| a required argument is missing | `MissingArgumentError` |
|
|
572
|
+
| too many positional arguments | `TooManyArgumentsError` |
|
|
573
|
+
| a keyword the rule does not declare | `UnexpectedKeywordArgumentError` |
|
|
574
|
+
| a value given both positionally and by keyword | `MultipleValuesArgumentError` |
|
|
575
|
+
| a positional-only parameter passed by keyword | `PositionalOnlyArgumentError` |
|
|
576
|
+
|
|
577
|
+
A `TypeError` raised from inside your rule function is yours and is never
|
|
578
|
+
reclassified. A function that does not accept the object under test (and, for
|
|
579
|
+
subscriptable rules, the key) positionally raises `InvalidRuleError`.
|
|
580
|
+
|
|
581
|
+
> **Upgrading from 3.x:** the `processors=` option of `rule()` and
|
|
582
|
+
> `register_rule()` is gone. Replace `processors=int` by annotating the
|
|
583
|
+
> parameters (`value: Annotated[int, Process(int)]`), and a mapping such as
|
|
584
|
+
> `processors={"age": int}` by marking just `age`. `ProcessArgumentError` and
|
|
585
|
+
> `InvalidProcessorError` now come from pyargprocessors (re-exported by this
|
|
586
|
+
> package) and derive from its `PyargprocessorsError` instead of
|
|
587
|
+
> `PySpecificationError`. Argument errors are raised when a rule is built instead of when its predicate is
|
|
588
|
+
> called, and a keyword-passed positional-only parameter (such as `key=` for a
|
|
589
|
+
> rule declared `key, value, /`) is now a `PositionalOnlyArgumentError`.
|
|
534
590
|
|
|
535
591
|
---
|
|
536
592
|
|
|
@@ -693,7 +749,17 @@ not match a predicate or wrapper shape, it raises `TypeError`.
|
|
|
693
749
|
|
|
694
750
|
## JSON schema generation
|
|
695
751
|
|
|
696
|
-
|
|
752
|
+
The generated schemas describe the exact shape accepted by `PredicateCompiler`,
|
|
753
|
+
so they can be handed to a form builder, a validator, or an LLM that writes
|
|
754
|
+
rules as JSON.
|
|
755
|
+
|
|
756
|
+
### One rule
|
|
757
|
+
|
|
758
|
+
`get_rule_json_schema(name, rule)` returns the schema of one predicate
|
|
759
|
+
dictionary. Positional parameters are described under `args` (via
|
|
760
|
+
`prefixItems`), keyword-capable parameters under `kwargs`, and the rule's
|
|
761
|
+
docstring becomes the description. The object under test is not part of the
|
|
762
|
+
schema; for subscriptable rules the `key` is the first parameter.
|
|
697
763
|
|
|
698
764
|
```python
|
|
699
765
|
from dataclasses import dataclass
|
|
@@ -709,19 +775,99 @@ class User:
|
|
|
709
775
|
|
|
710
776
|
|
|
711
777
|
@object_rule()
|
|
712
|
-
def
|
|
713
|
-
|
|
778
|
+
def age__between(user: User, min_age: int, max_age: int = 120) -> bool:
|
|
779
|
+
"""Whether the age is within a range."""
|
|
780
|
+
return min_age <= user.age <= max_age
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
print(get_rule_json_schema("age__between", age__between))
|
|
784
|
+
# {
|
|
785
|
+
# "type": "object",
|
|
786
|
+
# "properties": {
|
|
787
|
+
# "name": {"const": "age__between"},
|
|
788
|
+
# "args": {
|
|
789
|
+
# "type": "array",
|
|
790
|
+
# "prefixItems": [{"type": "integer"}, {"type": "integer", "default": 120}],
|
|
791
|
+
# "items": False,
|
|
792
|
+
# },
|
|
793
|
+
# "kwargs": {
|
|
794
|
+
# "type": "object",
|
|
795
|
+
# "properties": {
|
|
796
|
+
# "min_age": {"type": "integer"},
|
|
797
|
+
# "max_age": {"type": "integer", "default": 120},
|
|
798
|
+
# },
|
|
799
|
+
# "required": [],
|
|
800
|
+
# "additionalProperties": False,
|
|
801
|
+
# },
|
|
802
|
+
# "inverse": {"type": "boolean"},
|
|
803
|
+
# },
|
|
804
|
+
# "required": ["name", "args", "kwargs", "inverse"],
|
|
805
|
+
# "additionalProperties": False,
|
|
806
|
+
# "description": "Whether the age is within a range.",
|
|
807
|
+
# }
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
### A whole expression
|
|
714
811
|
|
|
812
|
+
`get_expression_json_schema(rules)` returns the schema of everything
|
|
813
|
+
`PredicateCompiler.compile` accepts: a predicate dictionary for each rule, or a
|
|
814
|
+
nested `{"operator": "all" | "any", "expressions": [...]}` wrapper. Pass
|
|
815
|
+
`registry.rules` (hidden rules are left out) or any name-to-rule mapping you
|
|
816
|
+
give to the compiler.
|
|
817
|
+
|
|
818
|
+
```python
|
|
819
|
+
from pyspecification import ObjectRulesRegistry, get_expression_json_schema
|
|
820
|
+
|
|
821
|
+
registry = ObjectRulesRegistry[User, bool](operator="logical")
|
|
715
822
|
|
|
716
|
-
|
|
717
|
-
|
|
823
|
+
|
|
824
|
+
@registry.rule()
|
|
825
|
+
def is_admin(user: User) -> bool:
|
|
826
|
+
return user.is_admin
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
schema = get_expression_json_schema(registry.rules)
|
|
830
|
+
print(schema["$schema"]) # https://json-schema.org/draft/2020-12/schema
|
|
718
831
|
```
|
|
719
832
|
|
|
833
|
+
### Supported types
|
|
834
|
+
|
|
835
|
+
`get_json_schema(annotation)` maps Python annotations to JSON Schema. Unknown or
|
|
836
|
+
unannotated types map to `{}`, which accepts any value.
|
|
837
|
+
|
|
838
|
+
| Python annotation | JSON Schema |
|
|
839
|
+
| --- | --- |
|
|
840
|
+
| `str`, `int`, `float`, `bool`, `None` | `string`, `integer`, `number`, `boolean`, `null` |
|
|
841
|
+
| `Decimal` | `number` |
|
|
842
|
+
| `datetime`, `date`, `time`, `UUID` | `string` with `date-time`, `date`, `time`, `uuid` format |
|
|
843
|
+
| `list[T]`, `set[T]`, `frozenset[T]`, `tuple[T, ...]` | `array` of `T` |
|
|
844
|
+
| `dict[K, V]` | `object` with `V` as `additionalProperties` |
|
|
845
|
+
| `Literal[...]`, `Enum` subclasses | `enum` of the values (plus `string` type when all are strings) |
|
|
846
|
+
| `A \| B`, `Optional[A]` | `anyOf` |
|
|
847
|
+
| `TypedDict` | `object` with `properties` and `required` (`Required`/`NotRequired` honored) |
|
|
848
|
+
| `Annotated[T, ...]`, `type` aliases | the schema of `T` / the aliased type |
|
|
849
|
+
|
|
850
|
+
Fixed-size heterogeneous tuples such as `tuple[int, str]` are described by their
|
|
851
|
+
first member only. String annotations are evaluated when possible, and fall back
|
|
852
|
+
to `{}` when they cannot be resolved. Defaults are included when they are plain
|
|
853
|
+
JSON values.
|
|
854
|
+
|
|
855
|
+
A parameter marked with `Process` is described by what its processor accepts, since
|
|
856
|
+
that is what the JSON has to send: with `Annotated[frozenset[str], Process(load_names)]`
|
|
857
|
+
and `def load_names(path: str)`, the schema says `string`. A processor without a
|
|
858
|
+
parameter annotation (such as `int` or `str.strip`) falls back to the annotated type.
|
|
859
|
+
|
|
860
|
+
> **Upgrading from 2.x:** `get_rule_json_schema(rule)` used to return a flat
|
|
861
|
+
> `{parameter: schema, "return": schema}` mapping. It now takes the rule name
|
|
862
|
+
> too and returns the predicate dictionary schema shown above. `get_json_schema`
|
|
863
|
+
> is now exported from the package root.
|
|
864
|
+
|
|
720
865
|
This is useful for:
|
|
721
866
|
|
|
722
867
|
- generating UIs for rule configuration
|
|
723
868
|
- building admin tools and dashboards
|
|
724
|
-
-
|
|
869
|
+
- validating rule payloads before compiling them
|
|
870
|
+
- describing rule inputs to other systems and LLMs
|
|
725
871
|
- documenting business rules programmatically
|
|
726
872
|
|
|
727
873
|
---
|
|
@@ -903,7 +1049,11 @@ The library raises explicit exceptions for rule issues:
|
|
|
903
1049
|
- `MissingArgumentError`
|
|
904
1050
|
- `UnexpectedKeywordArgumentError`
|
|
905
1051
|
- `TooManyArgumentsError`
|
|
1052
|
+
- `MultipleValuesArgumentError`
|
|
1053
|
+
- `PositionalOnlyArgumentError`
|
|
1054
|
+
- `InvalidRuleError`
|
|
906
1055
|
- `ProcessArgumentError`
|
|
1056
|
+
- `InvalidProcessorError`
|
|
907
1057
|
|
|
908
1058
|
`ArgumentError` is the base class for failures involving arguments passed to a
|
|
909
1059
|
rule. Its specialized exceptions describe the problem:
|
|
@@ -914,8 +1064,22 @@ rule. Its specialized exceptions describe the problem:
|
|
|
914
1064
|
rule's signature.
|
|
915
1065
|
- `TooManyArgumentsError` means more positional arguments were provided than
|
|
916
1066
|
the rule accepts.
|
|
917
|
-
- `
|
|
918
|
-
|
|
1067
|
+
- `MultipleValuesArgumentError` means a value was given both positionally and
|
|
1068
|
+
by keyword.
|
|
1069
|
+
- `PositionalOnlyArgumentError` means a positional-only parameter was passed by
|
|
1070
|
+
keyword.
|
|
1071
|
+
- `ProcessArgumentError` means a `Process` function could not convert a value.
|
|
1072
|
+
|
|
1073
|
+
`InvalidProcessorError` and `InvalidRuleError` (both `TypeError`s) are raised when
|
|
1074
|
+
a rule is defined: a misplaced or non-callable `Process`, or a function that does
|
|
1075
|
+
not accept the object under test (and key) positionally.
|
|
1076
|
+
|
|
1077
|
+
Every exception of this package derives from `PySpecificationError`, except
|
|
1078
|
+
`ProcessArgumentError` and `InvalidProcessorError`: they come from
|
|
1079
|
+
[pyargprocessors](https://github.com/aaltatan/pyargprocessors), derive from its
|
|
1080
|
+
`PyargprocessorsError`, and are re-exported here. Exceptions raised while
|
|
1081
|
+
compiling, including those two, carry a note with the JSON path of the offending
|
|
1082
|
+
expression, e.g. `at $.expressions[1]`.
|
|
919
1083
|
|
|
920
1084
|
`RuleDoesNotExistError` includes the missing name and the available rule names,
|
|
921
1085
|
which is useful when rules are dynamically loaded. Malformed normalized
|
|
@@ -51,9 +51,10 @@ 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
|
-
-
|
|
54
|
+
- `Process` markers (from [pyargprocessors](https://github.com/aaltatan/pyargprocessors)) that coerce and normalize raw arguments, such as strings from JSON or forms, into the objects a rule wants
|
|
55
|
+
- Argument mistakes and failing processors reported while a rule is built, before anything is evaluated
|
|
55
56
|
- `PredicateCompiler` for compiling structured rule dictionaries into executable predicates
|
|
56
|
-
- JSON schema generation for
|
|
57
|
+
- JSON schema generation for single rules and for whole compilable expressions
|
|
57
58
|
- Support for both logical and bitwise operator modes
|
|
58
59
|
- Hidden rules and custom naming for internal/private rule registration
|
|
59
60
|
|
|
@@ -474,21 +475,30 @@ This pattern is especially useful when you want:
|
|
|
474
475
|
|
|
475
476
|
---
|
|
476
477
|
|
|
477
|
-
##
|
|
478
|
+
## Processing arguments with `Process`
|
|
478
479
|
|
|
479
|
-
Rules
|
|
480
|
+
Rules often come from JSON or a web form, where every value is a string, number,
|
|
481
|
+
boolean, list, dict or null, while your rule functions want dates, decimals,
|
|
482
|
+
enums, clean text or loaded data. Mark a parameter with `Process(fn)` inside
|
|
483
|
+
`Annotated`, and `fn` is applied to the value when the rule is built. The marker
|
|
484
|
+
comes from [pyargprocessors](https://github.com/aaltatan/pyargprocessors), a
|
|
485
|
+
dependency of this package, and is re-exported here:
|
|
480
486
|
|
|
481
487
|
```python
|
|
482
488
|
from datetime import datetime
|
|
489
|
+
from typing import Annotated
|
|
483
490
|
|
|
484
|
-
from pyspecification import SubscriptableRulesRegistry
|
|
485
|
-
|
|
491
|
+
from pyspecification import Process, SubscriptableRulesRegistry
|
|
486
492
|
|
|
487
493
|
rules = SubscriptableRulesRegistry[dict[str, object], str, bool](operator="logical")
|
|
488
494
|
|
|
489
495
|
|
|
490
|
-
|
|
491
|
-
|
|
496
|
+
def parse_date(text: str) -> datetime:
|
|
497
|
+
return datetime.strptime(text, "%Y-%m-%d")
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
@rules.rule()
|
|
501
|
+
def datetime__gt(obj: dict[str, object], key: str, value: Annotated[datetime, Process(parse_date)]) -> bool:
|
|
492
502
|
return obj[key] > value
|
|
493
503
|
|
|
494
504
|
|
|
@@ -496,27 +506,72 @@ predicate = rules["datetime__gt"]("birthdate", "2001-06-01")
|
|
|
496
506
|
print(predicate({"birthdate": datetime(2005, 1, 1)})) # True
|
|
497
507
|
```
|
|
498
508
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
509
|
+
`Process` works on `object_rule` and `subscriptable_rule` directly as well as
|
|
510
|
+
through the registries. A few things worth knowing:
|
|
511
|
+
|
|
512
|
+
- Name a processed type once with a `type` alias and reuse it in many rules, and
|
|
513
|
+
chain several markers (they run left to right):
|
|
514
|
+
|
|
515
|
+
```python
|
|
516
|
+
type Text = Annotated[str, Process(str.strip), Process(str.lower)]
|
|
517
|
+
type Money = Annotated[Decimal, Process(Decimal)]
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
- A marker applies whether the value was passed positionally or by keyword. On
|
|
521
|
+
`*args` it runs on each item, and on `**kwargs` on each value.
|
|
522
|
+
- Processing happens once, when the rule is built, so a bad value fails before the
|
|
523
|
+
predicate ever runs. Default values you did not pass are never processed.
|
|
524
|
+
- The processor may take a different input than the rule: `Annotated[frozenset[str],
|
|
525
|
+
Process(load_names)]` lets the JSON send a file path while the rule receives the
|
|
526
|
+
loaded names. The JSON schema describes what the JSON must send (the processor's
|
|
527
|
+
first parameter annotation), see below.
|
|
528
|
+
- The object under test and, for subscriptable rules, the key are not
|
|
529
|
+
arguments: a marker on them raises `InvalidProcessorError`.
|
|
530
|
+
- `pyargprocessors` also provides `each` and `optional` to build a processor out
|
|
531
|
+
of another one (lists, nulls) and `process_value` to apply a processed type to
|
|
532
|
+
a single value; see its documentation.
|
|
533
|
+
|
|
534
|
+
If a processor raises, the library raises `ProcessArgumentError` naming the
|
|
535
|
+
argument and value, with the original exception chained. It also carries
|
|
536
|
+
`argument`, `value` and `cause` attributes:
|
|
537
|
+
|
|
538
|
+
```text
|
|
539
|
+
Argument 'value' with value '06-01-2001' failed to process, time data '06-01-2001' does not match format '%Y-%m-%d'
|
|
503
540
|
```
|
|
504
541
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
542
|
+
`InvalidProcessorError` (a `TypeError`) is raised as soon as the rule is defined
|
|
543
|
+
when a `Process` is not callable, is placed on the object under test or the key,
|
|
544
|
+
or is written as a default value instead of inside `Annotated`.
|
|
545
|
+
|
|
546
|
+
### Argument errors
|
|
547
|
+
|
|
548
|
+
Arguments are bound to the rule signature as soon as the rule is called, so
|
|
549
|
+
mistakes surface while a predicate is being built (including by
|
|
550
|
+
`PredicateCompiler.compile`), not when it is evaluated. They are reported in the
|
|
551
|
+
order Python reports them for a call: keywords that cannot be passed by name,
|
|
552
|
+
surplus or repeated arguments, then missing ones.
|
|
553
|
+
|
|
554
|
+
| Mistake | Exception |
|
|
555
|
+
| --- | --- |
|
|
556
|
+
| a required argument is missing | `MissingArgumentError` |
|
|
557
|
+
| too many positional arguments | `TooManyArgumentsError` |
|
|
558
|
+
| a keyword the rule does not declare | `UnexpectedKeywordArgumentError` |
|
|
559
|
+
| a value given both positionally and by keyword | `MultipleValuesArgumentError` |
|
|
560
|
+
| a positional-only parameter passed by keyword | `PositionalOnlyArgumentError` |
|
|
561
|
+
|
|
562
|
+
A `TypeError` raised from inside your rule function is yours and is never
|
|
563
|
+
reclassified. A function that does not accept the object under test (and, for
|
|
564
|
+
subscriptable rules, the key) positionally raises `InvalidRuleError`.
|
|
565
|
+
|
|
566
|
+
> **Upgrading from 3.x:** the `processors=` option of `rule()` and
|
|
567
|
+
> `register_rule()` is gone. Replace `processors=int` by annotating the
|
|
568
|
+
> parameters (`value: Annotated[int, Process(int)]`), and a mapping such as
|
|
569
|
+
> `processors={"age": int}` by marking just `age`. `ProcessArgumentError` and
|
|
570
|
+
> `InvalidProcessorError` now come from pyargprocessors (re-exported by this
|
|
571
|
+
> package) and derive from its `PyargprocessorsError` instead of
|
|
572
|
+
> `PySpecificationError`. Argument errors are raised when a rule is built instead of when its predicate is
|
|
573
|
+
> called, and a keyword-passed positional-only parameter (such as `key=` for a
|
|
574
|
+
> rule declared `key, value, /`) is now a `PositionalOnlyArgumentError`.
|
|
520
575
|
|
|
521
576
|
---
|
|
522
577
|
|
|
@@ -679,7 +734,17 @@ not match a predicate or wrapper shape, it raises `TypeError`.
|
|
|
679
734
|
|
|
680
735
|
## JSON schema generation
|
|
681
736
|
|
|
682
|
-
|
|
737
|
+
The generated schemas describe the exact shape accepted by `PredicateCompiler`,
|
|
738
|
+
so they can be handed to a form builder, a validator, or an LLM that writes
|
|
739
|
+
rules as JSON.
|
|
740
|
+
|
|
741
|
+
### One rule
|
|
742
|
+
|
|
743
|
+
`get_rule_json_schema(name, rule)` returns the schema of one predicate
|
|
744
|
+
dictionary. Positional parameters are described under `args` (via
|
|
745
|
+
`prefixItems`), keyword-capable parameters under `kwargs`, and the rule's
|
|
746
|
+
docstring becomes the description. The object under test is not part of the
|
|
747
|
+
schema; for subscriptable rules the `key` is the first parameter.
|
|
683
748
|
|
|
684
749
|
```python
|
|
685
750
|
from dataclasses import dataclass
|
|
@@ -695,19 +760,99 @@ class User:
|
|
|
695
760
|
|
|
696
761
|
|
|
697
762
|
@object_rule()
|
|
698
|
-
def
|
|
699
|
-
|
|
763
|
+
def age__between(user: User, min_age: int, max_age: int = 120) -> bool:
|
|
764
|
+
"""Whether the age is within a range."""
|
|
765
|
+
return min_age <= user.age <= max_age
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
print(get_rule_json_schema("age__between", age__between))
|
|
769
|
+
# {
|
|
770
|
+
# "type": "object",
|
|
771
|
+
# "properties": {
|
|
772
|
+
# "name": {"const": "age__between"},
|
|
773
|
+
# "args": {
|
|
774
|
+
# "type": "array",
|
|
775
|
+
# "prefixItems": [{"type": "integer"}, {"type": "integer", "default": 120}],
|
|
776
|
+
# "items": False,
|
|
777
|
+
# },
|
|
778
|
+
# "kwargs": {
|
|
779
|
+
# "type": "object",
|
|
780
|
+
# "properties": {
|
|
781
|
+
# "min_age": {"type": "integer"},
|
|
782
|
+
# "max_age": {"type": "integer", "default": 120},
|
|
783
|
+
# },
|
|
784
|
+
# "required": [],
|
|
785
|
+
# "additionalProperties": False,
|
|
786
|
+
# },
|
|
787
|
+
# "inverse": {"type": "boolean"},
|
|
788
|
+
# },
|
|
789
|
+
# "required": ["name", "args", "kwargs", "inverse"],
|
|
790
|
+
# "additionalProperties": False,
|
|
791
|
+
# "description": "Whether the age is within a range.",
|
|
792
|
+
# }
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
### A whole expression
|
|
700
796
|
|
|
797
|
+
`get_expression_json_schema(rules)` returns the schema of everything
|
|
798
|
+
`PredicateCompiler.compile` accepts: a predicate dictionary for each rule, or a
|
|
799
|
+
nested `{"operator": "all" | "any", "expressions": [...]}` wrapper. Pass
|
|
800
|
+
`registry.rules` (hidden rules are left out) or any name-to-rule mapping you
|
|
801
|
+
give to the compiler.
|
|
802
|
+
|
|
803
|
+
```python
|
|
804
|
+
from pyspecification import ObjectRulesRegistry, get_expression_json_schema
|
|
805
|
+
|
|
806
|
+
registry = ObjectRulesRegistry[User, bool](operator="logical")
|
|
701
807
|
|
|
702
|
-
|
|
703
|
-
|
|
808
|
+
|
|
809
|
+
@registry.rule()
|
|
810
|
+
def is_admin(user: User) -> bool:
|
|
811
|
+
return user.is_admin
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
schema = get_expression_json_schema(registry.rules)
|
|
815
|
+
print(schema["$schema"]) # https://json-schema.org/draft/2020-12/schema
|
|
704
816
|
```
|
|
705
817
|
|
|
818
|
+
### Supported types
|
|
819
|
+
|
|
820
|
+
`get_json_schema(annotation)` maps Python annotations to JSON Schema. Unknown or
|
|
821
|
+
unannotated types map to `{}`, which accepts any value.
|
|
822
|
+
|
|
823
|
+
| Python annotation | JSON Schema |
|
|
824
|
+
| --- | --- |
|
|
825
|
+
| `str`, `int`, `float`, `bool`, `None` | `string`, `integer`, `number`, `boolean`, `null` |
|
|
826
|
+
| `Decimal` | `number` |
|
|
827
|
+
| `datetime`, `date`, `time`, `UUID` | `string` with `date-time`, `date`, `time`, `uuid` format |
|
|
828
|
+
| `list[T]`, `set[T]`, `frozenset[T]`, `tuple[T, ...]` | `array` of `T` |
|
|
829
|
+
| `dict[K, V]` | `object` with `V` as `additionalProperties` |
|
|
830
|
+
| `Literal[...]`, `Enum` subclasses | `enum` of the values (plus `string` type when all are strings) |
|
|
831
|
+
| `A \| B`, `Optional[A]` | `anyOf` |
|
|
832
|
+
| `TypedDict` | `object` with `properties` and `required` (`Required`/`NotRequired` honored) |
|
|
833
|
+
| `Annotated[T, ...]`, `type` aliases | the schema of `T` / the aliased type |
|
|
834
|
+
|
|
835
|
+
Fixed-size heterogeneous tuples such as `tuple[int, str]` are described by their
|
|
836
|
+
first member only. String annotations are evaluated when possible, and fall back
|
|
837
|
+
to `{}` when they cannot be resolved. Defaults are included when they are plain
|
|
838
|
+
JSON values.
|
|
839
|
+
|
|
840
|
+
A parameter marked with `Process` is described by what its processor accepts, since
|
|
841
|
+
that is what the JSON has to send: with `Annotated[frozenset[str], Process(load_names)]`
|
|
842
|
+
and `def load_names(path: str)`, the schema says `string`. A processor without a
|
|
843
|
+
parameter annotation (such as `int` or `str.strip`) falls back to the annotated type.
|
|
844
|
+
|
|
845
|
+
> **Upgrading from 2.x:** `get_rule_json_schema(rule)` used to return a flat
|
|
846
|
+
> `{parameter: schema, "return": schema}` mapping. It now takes the rule name
|
|
847
|
+
> too and returns the predicate dictionary schema shown above. `get_json_schema`
|
|
848
|
+
> is now exported from the package root.
|
|
849
|
+
|
|
706
850
|
This is useful for:
|
|
707
851
|
|
|
708
852
|
- generating UIs for rule configuration
|
|
709
853
|
- building admin tools and dashboards
|
|
710
|
-
-
|
|
854
|
+
- validating rule payloads before compiling them
|
|
855
|
+
- describing rule inputs to other systems and LLMs
|
|
711
856
|
- documenting business rules programmatically
|
|
712
857
|
|
|
713
858
|
---
|
|
@@ -889,7 +1034,11 @@ The library raises explicit exceptions for rule issues:
|
|
|
889
1034
|
- `MissingArgumentError`
|
|
890
1035
|
- `UnexpectedKeywordArgumentError`
|
|
891
1036
|
- `TooManyArgumentsError`
|
|
1037
|
+
- `MultipleValuesArgumentError`
|
|
1038
|
+
- `PositionalOnlyArgumentError`
|
|
1039
|
+
- `InvalidRuleError`
|
|
892
1040
|
- `ProcessArgumentError`
|
|
1041
|
+
- `InvalidProcessorError`
|
|
893
1042
|
|
|
894
1043
|
`ArgumentError` is the base class for failures involving arguments passed to a
|
|
895
1044
|
rule. Its specialized exceptions describe the problem:
|
|
@@ -900,8 +1049,22 @@ rule. Its specialized exceptions describe the problem:
|
|
|
900
1049
|
rule's signature.
|
|
901
1050
|
- `TooManyArgumentsError` means more positional arguments were provided than
|
|
902
1051
|
the rule accepts.
|
|
903
|
-
- `
|
|
904
|
-
|
|
1052
|
+
- `MultipleValuesArgumentError` means a value was given both positionally and
|
|
1053
|
+
by keyword.
|
|
1054
|
+
- `PositionalOnlyArgumentError` means a positional-only parameter was passed by
|
|
1055
|
+
keyword.
|
|
1056
|
+
- `ProcessArgumentError` means a `Process` function could not convert a value.
|
|
1057
|
+
|
|
1058
|
+
`InvalidProcessorError` and `InvalidRuleError` (both `TypeError`s) are raised when
|
|
1059
|
+
a rule is defined: a misplaced or non-callable `Process`, or a function that does
|
|
1060
|
+
not accept the object under test (and key) positionally.
|
|
1061
|
+
|
|
1062
|
+
Every exception of this package derives from `PySpecificationError`, except
|
|
1063
|
+
`ProcessArgumentError` and `InvalidProcessorError`: they come from
|
|
1064
|
+
[pyargprocessors](https://github.com/aaltatan/pyargprocessors), derive from its
|
|
1065
|
+
`PyargprocessorsError`, and are re-exported here. Exceptions raised while
|
|
1066
|
+
compiling, including those two, carry a note with the JSON path of the offending
|
|
1067
|
+
expression, e.g. `at $.expressions[1]`.
|
|
905
1068
|
|
|
906
1069
|
`RuleDoesNotExistError` includes the missing name and the available rule names,
|
|
907
1070
|
which is useful when rules are dynamically loaded. Malformed normalized
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pyspecification"
|
|
3
|
-
version = "
|
|
3
|
+
version = "4.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 = ["pyargprocessors>=0.1.0"]
|
|
8
8
|
classifiers = [
|
|
9
9
|
"Programming Language :: Python :: 3",
|
|
10
10
|
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pyspecification"
|
|
3
|
-
version = "
|
|
3
|
+
version = "4.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
|
+
"pyargprocessors>=0.1.0",
|
|
10
|
+
]
|
|
9
11
|
classifiers = [
|
|
10
12
|
"Programming Language :: Python :: 3",
|
|
11
13
|
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|