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.
@@ -1,12 +1,14 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pyspecification
3
- Version: 3.0.0
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
- - Custom argument processors for coercion and normalization, validated when a rule is registered
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
- ## Custom processors
512
+ ## Processing arguments with `Process`
492
513
 
493
- Rules can apply argument processors to coerce values before evaluation. This is
494
- most useful when rules come from JSON, where every value is a string, number,
495
- boolean, list, dict or null, and your rule functions expect richer types such as
496
- dates or decimals.
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
- @rules.rule(processors={"value": lambda value: datetime.strptime(value, "%Y-%m-%d")})
508
- def datetime__gt(obj: dict[str, object], key: str, value: datetime) -> bool:
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
- The `processors` option accepts:
517
-
518
- - `None` (the default): arguments are passed through untouched.
519
- - a callable: applied to every argument, e.g. `processors=int`.
520
- - a mapping of parameter name to callable: applied to those arguments only,
521
- e.g. `processors={"min_age": int}`. The key `...` (Ellipsis) means "every
522
- argument not named here", e.g. `processors={"min_age": int, ...: str.strip}`.
523
-
524
- Processors are matched by parameter name, so they apply whether a value was
525
- passed positionally or by keyword. For `*args` the processor runs on each item,
526
- and for `**kwargs` each extra keyword name is looked up individually. Default
527
- values you did not pass are left as they are.
528
-
529
- The object under test and, for subscriptable rules, the `key` are not
530
- arguments: they are never processed and cannot be named in the mapping.
531
-
532
- Processors are validated when the rule is registered, so a typo fails
533
- immediately instead of silently never running:
534
-
535
- ```python
536
- @rules.rule(processors={"valu": str}) # InvalidProcessorsError: unknown parameters ['valu']
537
- def string__eq(obj: dict[str, object], key: str, value: str) -> bool: ...
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
- parameter and value, with the original exception chained:
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
- > **Upgrading from 2.x:** processors used to be a `(default_processor,
552
- > named_processors_map)` tuple. Pass a callable instead of the tuple for the
553
- > old "process everything" behavior, or a mapping for named parameters. The
554
- > old tuple form is now rejected with `InvalidProcessorsError`.
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
- - `InvalidProcessorsError`
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
- - `ProcessArgumentError` means an argument processor could not convert or
1025
- otherwise process a value.
1026
-
1027
- `InvalidProcessorsError` (a `TypeError`) is raised at registration when the
1028
- `processors` option is malformed or names a parameter the rule does not have.
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
- - Custom argument processors for coercion and normalization, validated when a rule is registered
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
- ## Custom processors
496
+ ## Processing arguments with `Process`
478
497
 
479
- Rules can apply argument processors to coerce values before evaluation. This is
480
- most useful when rules come from JSON, where every value is a string, number,
481
- boolean, list, dict or null, and your rule functions expect richer types such as
482
- dates or decimals.
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
- @rules.rule(processors={"value": lambda value: datetime.strptime(value, "%Y-%m-%d")})
494
- def datetime__gt(obj: dict[str, object], key: str, value: datetime) -> bool:
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
- The `processors` option accepts:
503
-
504
- - `None` (the default): arguments are passed through untouched.
505
- - a callable: applied to every argument, e.g. `processors=int`.
506
- - a mapping of parameter name to callable: applied to those arguments only,
507
- e.g. `processors={"min_age": int}`. The key `...` (Ellipsis) means "every
508
- argument not named here", e.g. `processors={"min_age": int, ...: str.strip}`.
509
-
510
- Processors are matched by parameter name, so they apply whether a value was
511
- passed positionally or by keyword. For `*args` the processor runs on each item,
512
- and for `**kwargs` each extra keyword name is looked up individually. Default
513
- values you did not pass are left as they are.
514
-
515
- The object under test and, for subscriptable rules, the `key` are not
516
- arguments: they are never processed and cannot be named in the mapping.
517
-
518
- Processors are validated when the rule is registered, so a typo fails
519
- immediately instead of silently never running:
520
-
521
- ```python
522
- @rules.rule(processors={"valu": str}) # InvalidProcessorsError: unknown parameters ['valu']
523
- def string__eq(obj: dict[str, object], key: str, value: str) -> bool: ...
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
- parameter and value, with the original exception chained:
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
- > **Upgrading from 2.x:** processors used to be a `(default_processor,
538
- > named_processors_map)` tuple. Pass a callable instead of the tuple for the
539
- > old "process everything" behavior, or a mapping for named parameters. The
540
- > old tuple form is now rejected with `InvalidProcessorsError`.
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
- - `InvalidProcessorsError`
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
- - `ProcessArgumentError` means an argument processor could not convert or
1011
- otherwise process a value.
1012
-
1013
- `InvalidProcessorsError` (a `TypeError`) is raised at registration when the
1014
- `processors` option is malformed or names a parameter the rule does not have.
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.0.0"
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.0.0"
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
- InvalidProcessorsError,
12
+ InvalidRuleError,
11
13
  MissingArgumentError,
14
+ MultipleValuesArgumentError,
12
15
  PositionalOnlyArgumentError,
13
- ProcessArgumentError,
16
+ PySpecificationError,
14
17
  RuleAlreadyRegisteredError,
15
18
  RuleDoesNotExistError,
16
19
  RuleKeyDoesNotExistError,
17
20
  TooManyArgumentsError,
18
21
  UnexpectedKeywordArgumentError,
19
22
  )
20
- from .json_schema import get_expression_json_schema, get_json_schema, get_rule_json_schema
21
- from .predicate import OperatorType, Predicate
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
- "InvalidProcessorsError",
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",