pyspecification 2.0.2__tar.gz → 4.0.0__tar.gz

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