pyspecification 3.0.0__tar.gz → 4.1.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.1.0}/PKG-INFO +122 -49
- {pyspecification-3.0.0 → pyspecification-4.1.0}/README.md +119 -48
- {pyspecification-3.0.0 → pyspecification-4.1.0}/pyproject.toml +9 -2
- {pyspecification-3.0.0 → pyspecification-4.1.0}/pyproject.toml.orig +10 -2
- {pyspecification-3.0.0 → pyspecification-4.1.0}/src/pyspecification/__init__.py +17 -5
- {pyspecification-3.0.0 → pyspecification-4.1.0}/src/pyspecification/compilers.py +22 -13
- pyspecification-4.1.0/src/pyspecification/exceptions.py +95 -0
- pyspecification-4.1.0/src/pyspecification/json_schema.py +124 -0
- {pyspecification-3.0.0 → pyspecification-4.1.0}/src/pyspecification/predicate.py +99 -5
- {pyspecification-3.0.0 → pyspecification-4.1.0}/src/pyspecification/registry.py +65 -140
- pyspecification-4.1.0/src/pyspecification/rules.py +326 -0
- pyspecification-4.1.0/src/pyspecification/validators.py +13 -0
- pyspecification-3.0.0/src/pyspecification/exceptions.py +0 -82
- pyspecification-3.0.0/src/pyspecification/json_schema.py +0 -263
- pyspecification-3.0.0/src/pyspecification/processors.py +0 -155
- pyspecification-3.0.0/src/pyspecification/rules.py +0 -216
- pyspecification-3.0.0/src/pyspecification/validators.py +0 -15
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: pyspecification
|
|
3
|
-
Version:
|
|
3
|
+
Version: 4.1.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
|
|
11
|
+
Requires-Dist: pyvocabulary>=0.1.0
|
|
10
12
|
Requires-Python: >=3.12
|
|
11
13
|
Project-URL: Homepage, https://github.com/aaltatan/pyspecification
|
|
12
14
|
Project-URL: Issues, https://github.com/aaltatan/pyspecification/issues
|
|
@@ -65,7 +67,8 @@ This keeps your logic:
|
|
|
65
67
|
- Subscriptable rules for dictionaries, lists, and generic lookup-based data
|
|
66
68
|
- `Predicate` objects that support logical composition
|
|
67
69
|
- Registry pattern for rule registration and lookup
|
|
68
|
-
-
|
|
70
|
+
- `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
|
|
71
|
+
- Argument mistakes and failing processors reported while a rule is built, before anything is evaluated
|
|
69
72
|
- `PredicateCompiler` for compiling structured rule dictionaries into executable predicates
|
|
70
73
|
- JSON schema generation for single rules and for whole compilable expressions
|
|
71
74
|
- Support for both logical and bitwise operator modes
|
|
@@ -240,6 +243,24 @@ print(rules["administrator"].__doc__) # The overridden description
|
|
|
240
243
|
When an override is omitted, the function name and docstring are retained.
|
|
241
244
|
The same options are available on `SubscriptableRulesRegistry`.
|
|
242
245
|
|
|
246
|
+
#### Why a predicate failed
|
|
247
|
+
|
|
248
|
+
`failures(obj)` returns the rules that made a logical predicate fail, or `()`
|
|
249
|
+
when it passes. Each `Failure` carries the rule name, its bound arguments,
|
|
250
|
+
whether it failed because of `~`, and a `message` for users: the rule's
|
|
251
|
+
`message=`, otherwise its description, otherwise its docstring.
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
@registry.rule(message="You must be at least 18")
|
|
255
|
+
def age__ge(user: User, minimum: int) -> bool:
|
|
256
|
+
return user.age >= minimum
|
|
257
|
+
|
|
258
|
+
rule = registry["age__ge"](18) & registry["is_admin"]()
|
|
259
|
+
|
|
260
|
+
[str(f) for f in rule.failures(teen)] # ['age__ge(18)', 'is_admin']
|
|
261
|
+
[f.message for f in rule.failures(teen)] # ['You must be at least 18', None]
|
|
262
|
+
```
|
|
263
|
+
|
|
243
264
|
---
|
|
244
265
|
|
|
245
266
|
### 5. SQLAlchemy integration example
|
|
@@ -488,24 +509,30 @@ This pattern is especially useful when you want:
|
|
|
488
509
|
|
|
489
510
|
---
|
|
490
511
|
|
|
491
|
-
##
|
|
512
|
+
## Processing arguments with `Process`
|
|
492
513
|
|
|
493
|
-
Rules
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
514
|
+
Rules often come from JSON or a web form, where every value is a string, number,
|
|
515
|
+
boolean, list, dict or null, while your rule functions want dates, decimals,
|
|
516
|
+
enums, clean text or loaded data. Mark a parameter with `Process(fn)` inside
|
|
517
|
+
`Annotated`, and `fn` is applied to the value when the rule is built. The marker
|
|
518
|
+
comes from [pyargprocessors](https://github.com/aaltatan/pyargprocessors), a
|
|
519
|
+
dependency of this package, and is re-exported here:
|
|
497
520
|
|
|
498
521
|
```python
|
|
499
522
|
from datetime import datetime
|
|
523
|
+
from typing import Annotated
|
|
500
524
|
|
|
501
|
-
from pyspecification import SubscriptableRulesRegistry
|
|
502
|
-
|
|
525
|
+
from pyspecification import Process, SubscriptableRulesRegistry
|
|
503
526
|
|
|
504
527
|
rules = SubscriptableRulesRegistry[dict[str, object], str, bool](operator="logical")
|
|
505
528
|
|
|
506
529
|
|
|
507
|
-
|
|
508
|
-
|
|
530
|
+
def parse_date(text: str) -> datetime:
|
|
531
|
+
return datetime.strptime(text, "%Y-%m-%d")
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
@rules.rule()
|
|
535
|
+
def datetime__gt(obj: dict[str, object], key: str, value: Annotated[datetime, Process(parse_date)]) -> bool:
|
|
509
536
|
return obj[key] > value
|
|
510
537
|
|
|
511
538
|
|
|
@@ -513,45 +540,72 @@ predicate = rules["datetime__gt"]("birthdate", "2001-06-01")
|
|
|
513
540
|
print(predicate({"birthdate": datetime(2005, 1, 1)})) # True
|
|
514
541
|
```
|
|
515
542
|
|
|
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).
|
|
543
|
+
`Process` works on `object_rule` and `subscriptable_rule` directly as well as
|
|
544
|
+
through the registries. A few things worth knowing:
|
|
545
|
+
|
|
546
|
+
- Name a processed type once with a `type` alias and reuse it in many rules, and
|
|
547
|
+
chain several markers (they run left to right):
|
|
548
|
+
|
|
549
|
+
```python
|
|
550
|
+
type Text = Annotated[str, Process(str.strip), Process(str.lower)]
|
|
551
|
+
type Money = Annotated[Decimal, Process(Decimal)]
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
- A marker applies whether the value was passed positionally or by keyword. On
|
|
555
|
+
`*args` it runs on each item, and on `**kwargs` on each value.
|
|
556
|
+
- Processing happens once, when the rule is built, so a bad value fails before the
|
|
557
|
+
predicate ever runs. Default values you did not pass are never processed.
|
|
558
|
+
- The processor may take a different input than the rule: `Annotated[frozenset[str],
|
|
559
|
+
Process(load_names)]` lets the JSON send a file path while the rule receives the
|
|
560
|
+
loaded names. The JSON schema describes what the JSON must send (the processor's
|
|
561
|
+
first parameter annotation), see below.
|
|
562
|
+
- The object under test and, for subscriptable rules, the key are not
|
|
563
|
+
arguments: a marker on them raises `InvalidProcessorError`.
|
|
564
|
+
- `pyargprocessors` also provides `each` and `optional` to build a processor out
|
|
565
|
+
of another one (lists, nulls) and `process_value` to apply a processed type to
|
|
566
|
+
a single value; see its documentation.
|
|
543
567
|
|
|
544
568
|
If a processor raises, the library raises `ProcessArgumentError` naming the
|
|
545
|
-
|
|
569
|
+
argument and value, with the original exception chained. It also carries
|
|
570
|
+
`argument`, `value` and `cause` attributes:
|
|
546
571
|
|
|
547
572
|
```text
|
|
548
573
|
Argument 'value' with value '06-01-2001' failed to process, time data '06-01-2001' does not match format '%Y-%m-%d'
|
|
549
574
|
```
|
|
550
575
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
576
|
+
`InvalidProcessorError` (a `TypeError`) is raised as soon as the rule is defined
|
|
577
|
+
when a `Process` is not callable, is placed on the object under test or the key,
|
|
578
|
+
or is written as a default value instead of inside `Annotated`.
|
|
579
|
+
|
|
580
|
+
### Argument errors
|
|
581
|
+
|
|
582
|
+
Arguments are bound to the rule signature as soon as the rule is called, so
|
|
583
|
+
mistakes surface while a predicate is being built (including by
|
|
584
|
+
`PredicateCompiler.compile`), not when it is evaluated. They are reported in the
|
|
585
|
+
order Python reports them for a call: keywords that cannot be passed by name,
|
|
586
|
+
surplus or repeated arguments, then missing ones.
|
|
587
|
+
|
|
588
|
+
| Mistake | Exception |
|
|
589
|
+
| --- | --- |
|
|
590
|
+
| a required argument is missing | `MissingArgumentError` |
|
|
591
|
+
| too many positional arguments | `TooManyArgumentsError` |
|
|
592
|
+
| a keyword the rule does not declare | `UnexpectedKeywordArgumentError` |
|
|
593
|
+
| a value given both positionally and by keyword | `MultipleValuesArgumentError` |
|
|
594
|
+
| a positional-only parameter passed by keyword | `PositionalOnlyArgumentError` |
|
|
595
|
+
|
|
596
|
+
A `TypeError` raised from inside your rule function is yours and is never
|
|
597
|
+
reclassified. A function that does not accept the object under test (and, for
|
|
598
|
+
subscriptable rules, the key) positionally raises `InvalidRuleError`.
|
|
599
|
+
|
|
600
|
+
> **Upgrading from 3.x:** the `processors=` option of `rule()` and
|
|
601
|
+
> `register_rule()` is gone. Replace `processors=int` by annotating the
|
|
602
|
+
> parameters (`value: Annotated[int, Process(int)]`), and a mapping such as
|
|
603
|
+
> `processors={"age": int}` by marking just `age`. `ProcessArgumentError` and
|
|
604
|
+
> `InvalidProcessorError` now come from pyargprocessors (re-exported by this
|
|
605
|
+
> package) and derive from its `PyargprocessorsError` instead of
|
|
606
|
+
> `PySpecificationError`. Argument errors are raised when a rule is built instead of when its predicate is
|
|
607
|
+
> called, and a keyword-passed positional-only parameter (such as `key=` for a
|
|
608
|
+
> rule declared `key, value, /`) is now a `PositionalOnlyArgumentError`.
|
|
555
609
|
|
|
556
610
|
---
|
|
557
611
|
|
|
@@ -817,6 +871,11 @@ first member only. String annotations are evaluated when possible, and fall back
|
|
|
817
871
|
to `{}` when they cannot be resolved. Defaults are included when they are plain
|
|
818
872
|
JSON values.
|
|
819
873
|
|
|
874
|
+
A parameter marked with `Process` is described by what its processor accepts, since
|
|
875
|
+
that is what the JSON has to send: with `Annotated[frozenset[str], Process(load_names)]`
|
|
876
|
+
and `def load_names(path: str)`, the schema says `string`. A processor without a
|
|
877
|
+
parameter annotation (such as `int` or `str.strip`) falls back to the annotated type.
|
|
878
|
+
|
|
820
879
|
> **Upgrading from 2.x:** `get_rule_json_schema(rule)` used to return a flat
|
|
821
880
|
> `{parameter: schema, "return": schema}` mapping. It now takes the rule name
|
|
822
881
|
> too and returns the predicate dictionary schema shown above. `get_json_schema`
|
|
@@ -1009,8 +1068,11 @@ The library raises explicit exceptions for rule issues:
|
|
|
1009
1068
|
- `MissingArgumentError`
|
|
1010
1069
|
- `UnexpectedKeywordArgumentError`
|
|
1011
1070
|
- `TooManyArgumentsError`
|
|
1071
|
+
- `MultipleValuesArgumentError`
|
|
1072
|
+
- `PositionalOnlyArgumentError`
|
|
1073
|
+
- `InvalidRuleError`
|
|
1012
1074
|
- `ProcessArgumentError`
|
|
1013
|
-
- `
|
|
1075
|
+
- `InvalidProcessorError`
|
|
1014
1076
|
|
|
1015
1077
|
`ArgumentError` is the base class for failures involving arguments passed to a
|
|
1016
1078
|
rule. Its specialized exceptions describe the problem:
|
|
@@ -1021,11 +1083,22 @@ rule. Its specialized exceptions describe the problem:
|
|
|
1021
1083
|
rule's signature.
|
|
1022
1084
|
- `TooManyArgumentsError` means more positional arguments were provided than
|
|
1023
1085
|
the rule accepts.
|
|
1024
|
-
- `
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
`
|
|
1086
|
+
- `MultipleValuesArgumentError` means a value was given both positionally and
|
|
1087
|
+
by keyword.
|
|
1088
|
+
- `PositionalOnlyArgumentError` means a positional-only parameter was passed by
|
|
1089
|
+
keyword.
|
|
1090
|
+
- `ProcessArgumentError` means a `Process` function could not convert a value.
|
|
1091
|
+
|
|
1092
|
+
`InvalidProcessorError` and `InvalidRuleError` (both `TypeError`s) are raised when
|
|
1093
|
+
a rule is defined: a misplaced or non-callable `Process`, or a function that does
|
|
1094
|
+
not accept the object under test (and key) positionally.
|
|
1095
|
+
|
|
1096
|
+
Every exception of this package derives from `PySpecificationError`, except
|
|
1097
|
+
`ProcessArgumentError` and `InvalidProcessorError`: they come from
|
|
1098
|
+
[pyargprocessors](https://github.com/aaltatan/pyargprocessors), derive from its
|
|
1099
|
+
`PyargprocessorsError`, and are re-exported here. Exceptions raised while
|
|
1100
|
+
compiling, including those two, carry a note with the JSON path of the offending
|
|
1101
|
+
expression, e.g. `at $.expressions[1]`.
|
|
1029
1102
|
|
|
1030
1103
|
`RuleDoesNotExistError` includes the missing name and the available rule names,
|
|
1031
1104
|
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
|
|
@@ -226,6 +227,24 @@ print(rules["administrator"].__doc__) # The overridden description
|
|
|
226
227
|
When an override is omitted, the function name and docstring are retained.
|
|
227
228
|
The same options are available on `SubscriptableRulesRegistry`.
|
|
228
229
|
|
|
230
|
+
#### Why a predicate failed
|
|
231
|
+
|
|
232
|
+
`failures(obj)` returns the rules that made a logical predicate fail, or `()`
|
|
233
|
+
when it passes. Each `Failure` carries the rule name, its bound arguments,
|
|
234
|
+
whether it failed because of `~`, and a `message` for users: the rule's
|
|
235
|
+
`message=`, otherwise its description, otherwise its docstring.
|
|
236
|
+
|
|
237
|
+
```python
|
|
238
|
+
@registry.rule(message="You must be at least 18")
|
|
239
|
+
def age__ge(user: User, minimum: int) -> bool:
|
|
240
|
+
return user.age >= minimum
|
|
241
|
+
|
|
242
|
+
rule = registry["age__ge"](18) & registry["is_admin"]()
|
|
243
|
+
|
|
244
|
+
[str(f) for f in rule.failures(teen)] # ['age__ge(18)', 'is_admin']
|
|
245
|
+
[f.message for f in rule.failures(teen)] # ['You must be at least 18', None]
|
|
246
|
+
```
|
|
247
|
+
|
|
229
248
|
---
|
|
230
249
|
|
|
231
250
|
### 5. SQLAlchemy integration example
|
|
@@ -474,24 +493,30 @@ This pattern is especially useful when you want:
|
|
|
474
493
|
|
|
475
494
|
---
|
|
476
495
|
|
|
477
|
-
##
|
|
496
|
+
## Processing arguments with `Process`
|
|
478
497
|
|
|
479
|
-
Rules
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
498
|
+
Rules often come from JSON or a web form, where every value is a string, number,
|
|
499
|
+
boolean, list, dict or null, while your rule functions want dates, decimals,
|
|
500
|
+
enums, clean text or loaded data. Mark a parameter with `Process(fn)` inside
|
|
501
|
+
`Annotated`, and `fn` is applied to the value when the rule is built. The marker
|
|
502
|
+
comes from [pyargprocessors](https://github.com/aaltatan/pyargprocessors), a
|
|
503
|
+
dependency of this package, and is re-exported here:
|
|
483
504
|
|
|
484
505
|
```python
|
|
485
506
|
from datetime import datetime
|
|
507
|
+
from typing import Annotated
|
|
486
508
|
|
|
487
|
-
from pyspecification import SubscriptableRulesRegistry
|
|
488
|
-
|
|
509
|
+
from pyspecification import Process, SubscriptableRulesRegistry
|
|
489
510
|
|
|
490
511
|
rules = SubscriptableRulesRegistry[dict[str, object], str, bool](operator="logical")
|
|
491
512
|
|
|
492
513
|
|
|
493
|
-
|
|
494
|
-
|
|
514
|
+
def parse_date(text: str) -> datetime:
|
|
515
|
+
return datetime.strptime(text, "%Y-%m-%d")
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
@rules.rule()
|
|
519
|
+
def datetime__gt(obj: dict[str, object], key: str, value: Annotated[datetime, Process(parse_date)]) -> bool:
|
|
495
520
|
return obj[key] > value
|
|
496
521
|
|
|
497
522
|
|
|
@@ -499,45 +524,72 @@ predicate = rules["datetime__gt"]("birthdate", "2001-06-01")
|
|
|
499
524
|
print(predicate({"birthdate": datetime(2005, 1, 1)})) # True
|
|
500
525
|
```
|
|
501
526
|
|
|
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).
|
|
527
|
+
`Process` works on `object_rule` and `subscriptable_rule` directly as well as
|
|
528
|
+
through the registries. A few things worth knowing:
|
|
529
|
+
|
|
530
|
+
- Name a processed type once with a `type` alias and reuse it in many rules, and
|
|
531
|
+
chain several markers (they run left to right):
|
|
532
|
+
|
|
533
|
+
```python
|
|
534
|
+
type Text = Annotated[str, Process(str.strip), Process(str.lower)]
|
|
535
|
+
type Money = Annotated[Decimal, Process(Decimal)]
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
- A marker applies whether the value was passed positionally or by keyword. On
|
|
539
|
+
`*args` it runs on each item, and on `**kwargs` on each value.
|
|
540
|
+
- Processing happens once, when the rule is built, so a bad value fails before the
|
|
541
|
+
predicate ever runs. Default values you did not pass are never processed.
|
|
542
|
+
- The processor may take a different input than the rule: `Annotated[frozenset[str],
|
|
543
|
+
Process(load_names)]` lets the JSON send a file path while the rule receives the
|
|
544
|
+
loaded names. The JSON schema describes what the JSON must send (the processor's
|
|
545
|
+
first parameter annotation), see below.
|
|
546
|
+
- The object under test and, for subscriptable rules, the key are not
|
|
547
|
+
arguments: a marker on them raises `InvalidProcessorError`.
|
|
548
|
+
- `pyargprocessors` also provides `each` and `optional` to build a processor out
|
|
549
|
+
of another one (lists, nulls) and `process_value` to apply a processed type to
|
|
550
|
+
a single value; see its documentation.
|
|
529
551
|
|
|
530
552
|
If a processor raises, the library raises `ProcessArgumentError` naming the
|
|
531
|
-
|
|
553
|
+
argument and value, with the original exception chained. It also carries
|
|
554
|
+
`argument`, `value` and `cause` attributes:
|
|
532
555
|
|
|
533
556
|
```text
|
|
534
557
|
Argument 'value' with value '06-01-2001' failed to process, time data '06-01-2001' does not match format '%Y-%m-%d'
|
|
535
558
|
```
|
|
536
559
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
560
|
+
`InvalidProcessorError` (a `TypeError`) is raised as soon as the rule is defined
|
|
561
|
+
when a `Process` is not callable, is placed on the object under test or the key,
|
|
562
|
+
or is written as a default value instead of inside `Annotated`.
|
|
563
|
+
|
|
564
|
+
### Argument errors
|
|
565
|
+
|
|
566
|
+
Arguments are bound to the rule signature as soon as the rule is called, so
|
|
567
|
+
mistakes surface while a predicate is being built (including by
|
|
568
|
+
`PredicateCompiler.compile`), not when it is evaluated. They are reported in the
|
|
569
|
+
order Python reports them for a call: keywords that cannot be passed by name,
|
|
570
|
+
surplus or repeated arguments, then missing ones.
|
|
571
|
+
|
|
572
|
+
| Mistake | Exception |
|
|
573
|
+
| --- | --- |
|
|
574
|
+
| a required argument is missing | `MissingArgumentError` |
|
|
575
|
+
| too many positional arguments | `TooManyArgumentsError` |
|
|
576
|
+
| a keyword the rule does not declare | `UnexpectedKeywordArgumentError` |
|
|
577
|
+
| a value given both positionally and by keyword | `MultipleValuesArgumentError` |
|
|
578
|
+
| a positional-only parameter passed by keyword | `PositionalOnlyArgumentError` |
|
|
579
|
+
|
|
580
|
+
A `TypeError` raised from inside your rule function is yours and is never
|
|
581
|
+
reclassified. A function that does not accept the object under test (and, for
|
|
582
|
+
subscriptable rules, the key) positionally raises `InvalidRuleError`.
|
|
583
|
+
|
|
584
|
+
> **Upgrading from 3.x:** the `processors=` option of `rule()` and
|
|
585
|
+
> `register_rule()` is gone. Replace `processors=int` by annotating the
|
|
586
|
+
> parameters (`value: Annotated[int, Process(int)]`), and a mapping such as
|
|
587
|
+
> `processors={"age": int}` by marking just `age`. `ProcessArgumentError` and
|
|
588
|
+
> `InvalidProcessorError` now come from pyargprocessors (re-exported by this
|
|
589
|
+
> package) and derive from its `PyargprocessorsError` instead of
|
|
590
|
+
> `PySpecificationError`. Argument errors are raised when a rule is built instead of when its predicate is
|
|
591
|
+
> called, and a keyword-passed positional-only parameter (such as `key=` for a
|
|
592
|
+
> rule declared `key, value, /`) is now a `PositionalOnlyArgumentError`.
|
|
541
593
|
|
|
542
594
|
---
|
|
543
595
|
|
|
@@ -803,6 +855,11 @@ first member only. String annotations are evaluated when possible, and fall back
|
|
|
803
855
|
to `{}` when they cannot be resolved. Defaults are included when they are plain
|
|
804
856
|
JSON values.
|
|
805
857
|
|
|
858
|
+
A parameter marked with `Process` is described by what its processor accepts, since
|
|
859
|
+
that is what the JSON has to send: with `Annotated[frozenset[str], Process(load_names)]`
|
|
860
|
+
and `def load_names(path: str)`, the schema says `string`. A processor without a
|
|
861
|
+
parameter annotation (such as `int` or `str.strip`) falls back to the annotated type.
|
|
862
|
+
|
|
806
863
|
> **Upgrading from 2.x:** `get_rule_json_schema(rule)` used to return a flat
|
|
807
864
|
> `{parameter: schema, "return": schema}` mapping. It now takes the rule name
|
|
808
865
|
> too and returns the predicate dictionary schema shown above. `get_json_schema`
|
|
@@ -995,8 +1052,11 @@ The library raises explicit exceptions for rule issues:
|
|
|
995
1052
|
- `MissingArgumentError`
|
|
996
1053
|
- `UnexpectedKeywordArgumentError`
|
|
997
1054
|
- `TooManyArgumentsError`
|
|
1055
|
+
- `MultipleValuesArgumentError`
|
|
1056
|
+
- `PositionalOnlyArgumentError`
|
|
1057
|
+
- `InvalidRuleError`
|
|
998
1058
|
- `ProcessArgumentError`
|
|
999
|
-
- `
|
|
1059
|
+
- `InvalidProcessorError`
|
|
1000
1060
|
|
|
1001
1061
|
`ArgumentError` is the base class for failures involving arguments passed to a
|
|
1002
1062
|
rule. Its specialized exceptions describe the problem:
|
|
@@ -1007,11 +1067,22 @@ rule. Its specialized exceptions describe the problem:
|
|
|
1007
1067
|
rule's signature.
|
|
1008
1068
|
- `TooManyArgumentsError` means more positional arguments were provided than
|
|
1009
1069
|
the rule accepts.
|
|
1010
|
-
- `
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
`
|
|
1070
|
+
- `MultipleValuesArgumentError` means a value was given both positionally and
|
|
1071
|
+
by keyword.
|
|
1072
|
+
- `PositionalOnlyArgumentError` means a positional-only parameter was passed by
|
|
1073
|
+
keyword.
|
|
1074
|
+
- `ProcessArgumentError` means a `Process` function could not convert a value.
|
|
1075
|
+
|
|
1076
|
+
`InvalidProcessorError` and `InvalidRuleError` (both `TypeError`s) are raised when
|
|
1077
|
+
a rule is defined: a misplaced or non-callable `Process`, or a function that does
|
|
1078
|
+
not accept the object under test (and key) positionally.
|
|
1079
|
+
|
|
1080
|
+
Every exception of this package derives from `PySpecificationError`, except
|
|
1081
|
+
`ProcessArgumentError` and `InvalidProcessorError`: they come from
|
|
1082
|
+
[pyargprocessors](https://github.com/aaltatan/pyargprocessors), derive from its
|
|
1083
|
+
`PyargprocessorsError`, and are re-exported here. Exceptions raised while
|
|
1084
|
+
compiling, including those two, carry a note with the JSON path of the offending
|
|
1085
|
+
expression, e.g. `at $.expressions[1]`.
|
|
1015
1086
|
|
|
1016
1087
|
`RuleDoesNotExistError` includes the missing name and the available rule names,
|
|
1017
1088
|
which is useful when rules are dynamically loaded. Malformed normalized
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pyspecification"
|
|
3
|
-
version = "
|
|
3
|
+
version = "4.1.0"
|
|
4
4
|
description = "A lightweight, typed Python library for composing business rules as reusable, executable predicates."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
7
|
-
dependencies = [
|
|
7
|
+
dependencies = [
|
|
8
|
+
"pyargprocessors>=0.1.0",
|
|
9
|
+
"pyvocabulary>=0.1.0",
|
|
10
|
+
]
|
|
8
11
|
classifiers = [
|
|
9
12
|
"Programming Language :: Python :: 3",
|
|
10
13
|
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
|
@@ -33,3 +36,7 @@ dev = [
|
|
|
33
36
|
"pytest-cov>=7.1.0",
|
|
34
37
|
"sqlalchemy>=2.0.52",
|
|
35
38
|
]
|
|
39
|
+
|
|
40
|
+
[tool.pyright]
|
|
41
|
+
venvPath = "."
|
|
42
|
+
venv = ".venv"
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pyspecification"
|
|
3
|
-
version = "
|
|
3
|
+
version = "4.1.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
|
+
"pyvocabulary>=0.1.0",
|
|
11
|
+
]
|
|
9
12
|
classifiers = [
|
|
10
13
|
"Programming Language :: Python :: 3",
|
|
11
14
|
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
|
@@ -30,3 +33,8 @@ dev = [
|
|
|
30
33
|
"pytest-cov>=7.1.0",
|
|
31
34
|
"sqlalchemy>=2.0.52",
|
|
32
35
|
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
[tool.pyright]
|
|
39
|
+
venvPath = "."
|
|
40
|
+
venv = ".venv"
|
|
@@ -4,36 +4,48 @@ 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,
|
|
17
20
|
TooManyArgumentsError,
|
|
18
21
|
UnexpectedKeywordArgumentError,
|
|
19
22
|
)
|
|
20
|
-
from .json_schema import
|
|
21
|
-
|
|
23
|
+
from .json_schema import (
|
|
24
|
+
get_expression_json_schema,
|
|
25
|
+
get_json_schema,
|
|
26
|
+
get_rule_json_schema,
|
|
27
|
+
)
|
|
28
|
+
from .predicate import Failure, OperatorType, Predicate
|
|
22
29
|
from .registry import ObjectRulesRegistry, SubscriptableRulesRegistry
|
|
23
30
|
from .rules import object_rule, subscriptable_rule
|
|
24
31
|
|
|
25
32
|
__all__ = [
|
|
26
33
|
"ArgumentError",
|
|
27
34
|
"ExpressionWrapperDict",
|
|
28
|
-
"
|
|
35
|
+
"Failure",
|
|
36
|
+
"InvalidProcessorError",
|
|
37
|
+
"InvalidRuleError",
|
|
29
38
|
"MissingArgumentError",
|
|
39
|
+
"MultipleValuesArgumentError",
|
|
30
40
|
"ObjectRulesRegistry",
|
|
31
41
|
"OperatorType",
|
|
32
42
|
"PositionalOnlyArgumentError",
|
|
33
43
|
"Predicate",
|
|
34
44
|
"PredicateCompiler",
|
|
35
45
|
"PredicateDict",
|
|
46
|
+
"Process",
|
|
36
47
|
"ProcessArgumentError",
|
|
48
|
+
"PySpecificationError",
|
|
37
49
|
"RuleAlreadyRegisteredError",
|
|
38
50
|
"RuleDoesNotExistError",
|
|
39
51
|
"RuleKeyDoesNotExistError",
|