pyspecification 3.0.0__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-3.0.0 → pyspecification-4.0.0}/PKG-INFO +103 -49
- {pyspecification-3.0.0 → pyspecification-4.0.0}/README.md +101 -48
- {pyspecification-3.0.0 → pyspecification-4.0.0}/pyproject.toml +2 -2
- {pyspecification-3.0.0 → pyspecification-4.0.0}/pyproject.toml.orig +4 -2
- {pyspecification-3.0.0 → pyspecification-4.0.0}/src/pyspecification/__init__.py +11 -4
- {pyspecification-3.0.0 → pyspecification-4.0.0}/src/pyspecification/compilers.py +17 -10
- pyspecification-4.0.0/src/pyspecification/exceptions.py +95 -0
- {pyspecification-3.0.0 → pyspecification-4.0.0}/src/pyspecification/json_schema.py +268 -263
- {pyspecification-3.0.0 → pyspecification-4.0.0}/src/pyspecification/registry.py +22 -105
- pyspecification-4.0.0/src/pyspecification/rules.py +281 -0
- pyspecification-3.0.0/src/pyspecification/exceptions.py +0 -82
- pyspecification-3.0.0/src/pyspecification/processors.py +0 -155
- pyspecification-3.0.0/src/pyspecification/rules.py +0 -216
- {pyspecification-3.0.0 → pyspecification-4.0.0}/src/pyspecification/predicate.py +0 -0
- {pyspecification-3.0.0 → 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,7 +66,8 @@ 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
72
|
- JSON schema generation for single rules and for whole compilable expressions
|
|
71
73
|
- Support for both logical and bitwise operator modes
|
|
@@ -488,24 +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
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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:
|
|
497
501
|
|
|
498
502
|
```python
|
|
499
503
|
from datetime import datetime
|
|
504
|
+
from typing import Annotated
|
|
500
505
|
|
|
501
|
-
from pyspecification import SubscriptableRulesRegistry
|
|
502
|
-
|
|
506
|
+
from pyspecification import Process, SubscriptableRulesRegistry
|
|
503
507
|
|
|
504
508
|
rules = SubscriptableRulesRegistry[dict[str, object], str, bool](operator="logical")
|
|
505
509
|
|
|
506
510
|
|
|
507
|
-
|
|
508
|
-
|
|
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:
|
|
509
517
|
return obj[key] > value
|
|
510
518
|
|
|
511
519
|
|
|
@@ -513,45 +521,72 @@ predicate = rules["datetime__gt"]("birthdate", "2001-06-01")
|
|
|
513
521
|
print(predicate({"birthdate": datetime(2005, 1, 1)})) # True
|
|
514
522
|
```
|
|
515
523
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
- a
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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).
|
|
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.
|
|
543
548
|
|
|
544
549
|
If a processor raises, the library raises `ProcessArgumentError` naming the
|
|
545
|
-
|
|
550
|
+
argument and value, with the original exception chained. It also carries
|
|
551
|
+
`argument`, `value` and `cause` attributes:
|
|
546
552
|
|
|
547
553
|
```text
|
|
548
554
|
Argument 'value' with value '06-01-2001' failed to process, time data '06-01-2001' does not match format '%Y-%m-%d'
|
|
549
555
|
```
|
|
550
556
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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`.
|
|
555
590
|
|
|
556
591
|
---
|
|
557
592
|
|
|
@@ -817,6 +852,11 @@ first member only. String annotations are evaluated when possible, and fall back
|
|
|
817
852
|
to `{}` when they cannot be resolved. Defaults are included when they are plain
|
|
818
853
|
JSON values.
|
|
819
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
|
+
|
|
820
860
|
> **Upgrading from 2.x:** `get_rule_json_schema(rule)` used to return a flat
|
|
821
861
|
> `{parameter: schema, "return": schema}` mapping. It now takes the rule name
|
|
822
862
|
> too and returns the predicate dictionary schema shown above. `get_json_schema`
|
|
@@ -1009,8 +1049,11 @@ The library raises explicit exceptions for rule issues:
|
|
|
1009
1049
|
- `MissingArgumentError`
|
|
1010
1050
|
- `UnexpectedKeywordArgumentError`
|
|
1011
1051
|
- `TooManyArgumentsError`
|
|
1052
|
+
- `MultipleValuesArgumentError`
|
|
1053
|
+
- `PositionalOnlyArgumentError`
|
|
1054
|
+
- `InvalidRuleError`
|
|
1012
1055
|
- `ProcessArgumentError`
|
|
1013
|
-
- `
|
|
1056
|
+
- `InvalidProcessorError`
|
|
1014
1057
|
|
|
1015
1058
|
`ArgumentError` is the base class for failures involving arguments passed to a
|
|
1016
1059
|
rule. Its specialized exceptions describe the problem:
|
|
@@ -1021,11 +1064,22 @@ rule. Its specialized exceptions describe the problem:
|
|
|
1021
1064
|
rule's signature.
|
|
1022
1065
|
- `TooManyArgumentsError` means more positional arguments were provided than
|
|
1023
1066
|
the rule accepts.
|
|
1024
|
-
- `
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
`
|
|
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]`.
|
|
1029
1083
|
|
|
1030
1084
|
`RuleDoesNotExistError` includes the missing name and the available rule names,
|
|
1031
1085
|
which is useful when rules are dynamically loaded. Malformed normalized
|
|
@@ -51,7 +51,8 @@ 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
57
|
- JSON schema generation for single rules and for whole compilable expressions
|
|
57
58
|
- Support for both logical and bitwise operator modes
|
|
@@ -474,24 +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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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:
|
|
483
486
|
|
|
484
487
|
```python
|
|
485
488
|
from datetime import datetime
|
|
489
|
+
from typing import Annotated
|
|
486
490
|
|
|
487
|
-
from pyspecification import SubscriptableRulesRegistry
|
|
488
|
-
|
|
491
|
+
from pyspecification import Process, SubscriptableRulesRegistry
|
|
489
492
|
|
|
490
493
|
rules = SubscriptableRulesRegistry[dict[str, object], str, bool](operator="logical")
|
|
491
494
|
|
|
492
495
|
|
|
493
|
-
|
|
494
|
-
|
|
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:
|
|
495
502
|
return obj[key] > value
|
|
496
503
|
|
|
497
504
|
|
|
@@ -499,45 +506,72 @@ predicate = rules["datetime__gt"]("birthdate", "2001-06-01")
|
|
|
499
506
|
print(predicate({"birthdate": datetime(2005, 1, 1)})) # True
|
|
500
507
|
```
|
|
501
508
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
- a
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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).
|
|
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.
|
|
529
533
|
|
|
530
534
|
If a processor raises, the library raises `ProcessArgumentError` naming the
|
|
531
|
-
|
|
535
|
+
argument and value, with the original exception chained. It also carries
|
|
536
|
+
`argument`, `value` and `cause` attributes:
|
|
532
537
|
|
|
533
538
|
```text
|
|
534
539
|
Argument 'value' with value '06-01-2001' failed to process, time data '06-01-2001' does not match format '%Y-%m-%d'
|
|
535
540
|
```
|
|
536
541
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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`.
|
|
541
575
|
|
|
542
576
|
---
|
|
543
577
|
|
|
@@ -803,6 +837,11 @@ first member only. String annotations are evaluated when possible, and fall back
|
|
|
803
837
|
to `{}` when they cannot be resolved. Defaults are included when they are plain
|
|
804
838
|
JSON values.
|
|
805
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
|
+
|
|
806
845
|
> **Upgrading from 2.x:** `get_rule_json_schema(rule)` used to return a flat
|
|
807
846
|
> `{parameter: schema, "return": schema}` mapping. It now takes the rule name
|
|
808
847
|
> too and returns the predicate dictionary schema shown above. `get_json_schema`
|
|
@@ -995,8 +1034,11 @@ The library raises explicit exceptions for rule issues:
|
|
|
995
1034
|
- `MissingArgumentError`
|
|
996
1035
|
- `UnexpectedKeywordArgumentError`
|
|
997
1036
|
- `TooManyArgumentsError`
|
|
1037
|
+
- `MultipleValuesArgumentError`
|
|
1038
|
+
- `PositionalOnlyArgumentError`
|
|
1039
|
+
- `InvalidRuleError`
|
|
998
1040
|
- `ProcessArgumentError`
|
|
999
|
-
- `
|
|
1041
|
+
- `InvalidProcessorError`
|
|
1000
1042
|
|
|
1001
1043
|
`ArgumentError` is the base class for failures involving arguments passed to a
|
|
1002
1044
|
rule. Its specialized exceptions describe the problem:
|
|
@@ -1007,11 +1049,22 @@ rule. Its specialized exceptions describe the problem:
|
|
|
1007
1049
|
rule's signature.
|
|
1008
1050
|
- `TooManyArgumentsError` means more positional arguments were provided than
|
|
1009
1051
|
the rule accepts.
|
|
1010
|
-
- `
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
`
|
|
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]`.
|
|
1015
1068
|
|
|
1016
1069
|
`RuleDoesNotExistError` includes the missing name and the available rule names,
|
|
1017
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)",
|
|
@@ -4,13 +4,16 @@ A lightweight, typed Python library for composing business rules as reusable, ex
|
|
|
4
4
|
This project was inspired by the work and ideas shared by [ArjanCodes](https://github.com/arjancodes), especially the concepts demonstrated in his video: ["The Most Overengineered Python Pattern I've Ever Built"](https://youtu.be/KqfMiuL3cx4?si=WAn01N2I0OO3KOgc).
|
|
5
5
|
""" # noqa: E501
|
|
6
6
|
|
|
7
|
+
from pyargprocessors import InvalidProcessorError, Process, ProcessArgumentError
|
|
8
|
+
|
|
7
9
|
from .compilers import ExpressionWrapperDict, PredicateCompiler, PredicateDict
|
|
8
10
|
from .exceptions import (
|
|
9
11
|
ArgumentError,
|
|
10
|
-
|
|
12
|
+
InvalidRuleError,
|
|
11
13
|
MissingArgumentError,
|
|
14
|
+
MultipleValuesArgumentError,
|
|
12
15
|
PositionalOnlyArgumentError,
|
|
13
|
-
|
|
16
|
+
PySpecificationError,
|
|
14
17
|
RuleAlreadyRegisteredError,
|
|
15
18
|
RuleDoesNotExistError,
|
|
16
19
|
RuleKeyDoesNotExistError,
|
|
@@ -25,15 +28,19 @@ from .rules import object_rule, subscriptable_rule
|
|
|
25
28
|
__all__ = [
|
|
26
29
|
"ArgumentError",
|
|
27
30
|
"ExpressionWrapperDict",
|
|
28
|
-
"
|
|
31
|
+
"InvalidProcessorError",
|
|
32
|
+
"InvalidRuleError",
|
|
29
33
|
"MissingArgumentError",
|
|
34
|
+
"MultipleValuesArgumentError",
|
|
30
35
|
"ObjectRulesRegistry",
|
|
31
36
|
"OperatorType",
|
|
37
|
+
"Process",
|
|
38
|
+
"ProcessArgumentError",
|
|
32
39
|
"PositionalOnlyArgumentError",
|
|
33
40
|
"Predicate",
|
|
34
41
|
"PredicateCompiler",
|
|
35
42
|
"PredicateDict",
|
|
36
|
-
"
|
|
43
|
+
"PySpecificationError",
|
|
37
44
|
"RuleAlreadyRegisteredError",
|
|
38
45
|
"RuleDoesNotExistError",
|
|
39
46
|
"RuleKeyDoesNotExistError",
|
|
@@ -3,7 +3,9 @@ from collections.abc import Callable
|
|
|
3
3
|
from pprint import pformat
|
|
4
4
|
from typing import Any, Literal, TypedDict, TypeGuard
|
|
5
5
|
|
|
6
|
-
from
|
|
6
|
+
from pyargprocessors import PyargprocessorsError
|
|
7
|
+
|
|
8
|
+
from .exceptions import PySpecificationError, RuleDoesNotExistError
|
|
7
9
|
from .json_schema import get_json_schema
|
|
8
10
|
from .predicate import Predicate, ReturnType
|
|
9
11
|
|
|
@@ -133,7 +135,13 @@ class PredicateCompiler[T, R: ReturnType]:
|
|
|
133
135
|
self._initial_predicate_factory = initial_predicate_factory
|
|
134
136
|
|
|
135
137
|
def compile(self, expression: ExpressionDict) -> Predicate[T, R]:
|
|
136
|
-
"""Compiles an expression into a predicate.
|
|
138
|
+
"""Compiles an expression into a predicate.
|
|
139
|
+
|
|
140
|
+
Every problem is reported while compiling, before any object is evaluated:
|
|
141
|
+
unknown rules, bad arguments and failing `Process` functions. Errors raised
|
|
142
|
+
by this library carry a note with the JSON path of the offending
|
|
143
|
+
expression, e.g. `at $.expressions[1]`.
|
|
144
|
+
"""
|
|
137
145
|
return self._compile(expression, path="$")
|
|
138
146
|
|
|
139
147
|
def _compile(self, expression: ExpressionDict, path: str) -> Predicate[T, R]:
|
|
@@ -141,19 +149,18 @@ class PredicateCompiler[T, R: ReturnType]:
|
|
|
141
149
|
return self._compile_wrapper(expression, path)
|
|
142
150
|
|
|
143
151
|
if is_predicate_dict(expression):
|
|
144
|
-
return self._compile_single(expression)
|
|
152
|
+
return self._compile_single(expression, path)
|
|
145
153
|
|
|
146
154
|
raise TypeError(_invalid_expression_message(expression, path))
|
|
147
155
|
|
|
148
|
-
def _compile_single(self, single: PredicateDict) -> Predicate[T, R]:
|
|
149
|
-
if single["name"] not in self._rules:
|
|
150
|
-
raise RuleDoesNotExistError(single["name"], self._rules.keys())
|
|
151
|
-
|
|
156
|
+
def _compile_single(self, single: PredicateDict, path: str) -> Predicate[T, R]:
|
|
152
157
|
try:
|
|
158
|
+
if single["name"] not in self._rules:
|
|
159
|
+
raise RuleDoesNotExistError(single["name"], self._rules.keys())
|
|
160
|
+
|
|
153
161
|
predicate = self._rules[single["name"]](*single["args"], **single["kwargs"])
|
|
154
|
-
except
|
|
155
|
-
|
|
156
|
-
raise MissingArgumentError(str(e)) from e
|
|
162
|
+
except (PySpecificationError, PyargprocessorsError) as error:
|
|
163
|
+
error.add_note(f"at {path}")
|
|
157
164
|
raise
|
|
158
165
|
|
|
159
166
|
if single["inverse"]:
|