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.
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pyspecification
3
- Version: 3.0.0
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
- - Custom argument processors for coercion and normalization, validated when a rule is registered
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
- ## Custom processors
493
+ ## Processing arguments with `Process`
492
494
 
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.
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
- @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:
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
- 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).
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
- parameter and value, with the original exception chained:
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
- > **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`.
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
- - `InvalidProcessorsError`
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
- - `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.
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
- - 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
@@ -474,24 +475,30 @@ This pattern is especially useful when you want:
474
475
 
475
476
  ---
476
477
 
477
- ## Custom processors
478
+ ## Processing arguments with `Process`
478
479
 
479
- Rules can apply argument processors to coerce values before evaluation. This is
480
- most useful when rules come from JSON, where every value is a string, number,
481
- boolean, list, dict or null, and your rule functions expect richer types such as
482
- dates or decimals.
480
+ 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
- @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:
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
- 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).
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
- parameter and value, with the original exception chained:
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
- > **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`.
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
- - `InvalidProcessorsError`
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
- - `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.
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.0.0"
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.0.0"
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
- InvalidProcessorsError,
12
+ InvalidRuleError,
11
13
  MissingArgumentError,
14
+ MultipleValuesArgumentError,
12
15
  PositionalOnlyArgumentError,
13
- ProcessArgumentError,
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
- "InvalidProcessorsError",
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
- "ProcessArgumentError",
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 .exceptions import MissingArgumentError, RuleDoesNotExistError, is_missing_argument_exception
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 TypeError as e:
155
- if is_missing_argument_exception(e):
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"]: