rule-cascade 1.0.0a2__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.
- rule_cascade-1.0.0a2/LICENSE +202 -0
- rule_cascade-1.0.0a2/NOTICE +9 -0
- rule_cascade-1.0.0a2/PKG-INFO +349 -0
- rule_cascade-1.0.0a2/README.md +334 -0
- rule_cascade-1.0.0a2/pyproject.toml +25 -0
- rule_cascade-1.0.0a2/setup.cfg +4 -0
- rule_cascade-1.0.0a2/src/rule_cascade/__init__.py +26 -0
- rule_cascade-1.0.0a2/src/rule_cascade/__main__.py +5 -0
- rule_cascade-1.0.0a2/src/rule_cascade/cron.py +139 -0
- rule_cascade-1.0.0a2/src/rule_cascade/engine.py +218 -0
- rule_cascade-1.0.0a2/src/rule_cascade/evaluate.py +260 -0
- rule_cascade-1.0.0a2/src/rule_cascade/expressions.py +487 -0
- rule_cascade-1.0.0a2/src/rule_cascade/holder.py +131 -0
- rule_cascade-1.0.0a2/src/rule_cascade/rule-cascade.schema.json +1436 -0
- rule_cascade-1.0.0a2/src/rule_cascade/ruleset.py +583 -0
- rule_cascade-1.0.0a2/src/rule_cascade/values.py +108 -0
- rule_cascade-1.0.0a2/src/rule_cascade.egg-info/PKG-INFO +349 -0
- rule_cascade-1.0.0a2/src/rule_cascade.egg-info/SOURCES.txt +22 -0
- rule_cascade-1.0.0a2/src/rule_cascade.egg-info/dependency_links.txt +1 -0
- rule_cascade-1.0.0a2/src/rule_cascade.egg-info/requires.txt +4 -0
- rule_cascade-1.0.0a2/src/rule_cascade.egg-info/top_level.txt +1 -0
- rule_cascade-1.0.0a2/tests/test_conformance.py +29 -0
- rule_cascade-1.0.0a2/tests/test_cron.py +61 -0
- rule_cascade-1.0.0a2/tests/test_holder.py +88 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Rule Cascade
|
|
2
|
+
Copyright 2026 Yarlis LLC
|
|
3
|
+
|
|
4
|
+
This product is licensed under the Apache License, Version 2.0 (see LICENSE).
|
|
5
|
+
Contributors keep the copyright of their contributions, which they license
|
|
6
|
+
under the same terms; the Git history records each one.
|
|
7
|
+
|
|
8
|
+
The name "Rule Cascade" and its logos are trademarks of Yarlis LLC and are not
|
|
9
|
+
licensed under the Apache License. See TRADEMARKS.md.
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rule-cascade
|
|
3
|
+
Version: 1.0.0a2
|
|
4
|
+
Summary: Rule Cascade for Python, and the reference implementation: compile, publish and evaluate YAML/JSON business rules.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Project-URL: Repository, https://github.com/YarlisAISolutions/rule-cascade
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
License-File: NOTICE
|
|
11
|
+
Requires-Dist: jsonschema>=4.21
|
|
12
|
+
Provides-Extra: yaml
|
|
13
|
+
Requires-Dist: pyyaml>=6.0; extra == "yaml"
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# rule-cascade (Python)
|
|
17
|
+
|
|
18
|
+
The Rule Cascade runtime for Python, and the **reference implementation** of the
|
|
19
|
+
[specification](../../spec/v1/SPECIFICATION.md). It implements both conformance levels: it reads a
|
|
20
|
+
bundle and evaluates (evaluator), and it loads source documents and produces bundles (compiler).
|
|
21
|
+
|
|
22
|
+
Requires Python 3.10 or later. The only dependency is `jsonschema`, which validates source documents
|
|
23
|
+
against the ruleset schema.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install ./packages/python # from the repository root
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The package is not published to an index. To use it from a checkout without installing it, put
|
|
30
|
+
`packages/python/src` on `PYTHONPATH`; that is what the Makefile, CI and `tools/rulecheck.py` do.
|
|
31
|
+
|
|
32
|
+
| Module | What it is |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `rule_cascade.expressions` | Expressions, operators and portable patterns: specification section 4 |
|
|
35
|
+
| `rule_cascade.ruleset` | Loading, inheritance, load-time checks, checksum, manifests, bundles: sections 5 to 7 |
|
|
36
|
+
| `rule_cascade.evaluate` | Evaluation of a request against a manifest: section 8 |
|
|
37
|
+
| `rule_cascade.values` | Numbers, equality, canonical JSON and rendering, shared by the others |
|
|
38
|
+
| `rule_cascade.engine` | The engine protocol: section 13 |
|
|
39
|
+
|
|
40
|
+
The snippets below run from the repository root. They read the JSON fixtures of the conformance
|
|
41
|
+
suite, which are the example rulesets in `examples/contracts` and `examples/catalog` converted to
|
|
42
|
+
JSON.
|
|
43
|
+
|
|
44
|
+
## Compile and evaluate
|
|
45
|
+
|
|
46
|
+
`load(document, registry, loader)` runs every step of specification section 5 and returns a
|
|
47
|
+
`RuleSet`, or raises `LoadError`. It never returns a ruleset that failed a check.
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
import json
|
|
51
|
+
from pathlib import Path
|
|
52
|
+
|
|
53
|
+
from rule_cascade import LoadError, load
|
|
54
|
+
|
|
55
|
+
fixtures = Path("conformance/fixtures")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def read(name):
|
|
59
|
+
return json.loads((fixtures / name).read_text(encoding="utf-8"))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
registry = { # ruleset id -> document, so `extends` can be resolved
|
|
63
|
+
"acme.org.base": read("acme-org-base.ruleset.json"),
|
|
64
|
+
"acme.payments.transfer": read("payments-transfer.ruleset.json"),
|
|
65
|
+
}
|
|
66
|
+
schemas = {"./payments.openapi.yaml": read("payments.openapi.json")}
|
|
67
|
+
|
|
68
|
+
rules = load(registry["acme.payments.transfer"], registry, schemas.get)
|
|
69
|
+
print(rules.id, rules.version, rules.checksum)
|
|
70
|
+
# acme.payments.transfer 1.0.0 sha256:c192dd53b5b1d307d52ccbc27fc1674114e8714d53b699b24088a648ae242c7e
|
|
71
|
+
|
|
72
|
+
result = rules.evaluate({
|
|
73
|
+
"entity": "Transfer",
|
|
74
|
+
"operation": "create",
|
|
75
|
+
"data": {"type": "international", "amount": 12000, "currency": "USD",
|
|
76
|
+
"beneficiary": {"name": "Ana", "country": "ES"}},
|
|
77
|
+
"actor": {"id": "u-1", "roles": ["teller"]},
|
|
78
|
+
})
|
|
79
|
+
print(result["decision"])
|
|
80
|
+
for finding in result["findings"]:
|
|
81
|
+
print(finding["code"], finding["severity"], finding["blocking"], finding["fields"])
|
|
82
|
+
# deny
|
|
83
|
+
# ORG-TRF-003 warning False ['/memo']
|
|
84
|
+
# PAY-TRF-002 error True ['/beneficiary/swiftCode']
|
|
85
|
+
# PAY-TRF-003 warning True ['/amount', '/beneficiary/name']
|
|
86
|
+
print(result["effects"])
|
|
87
|
+
# [{'type': 'value', 'field': '/fee', 'value': 180, 'rule': 'transfer.fee.international'}]
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
- `registry` maps ruleset ids to source documents. The parent named by `extends` must be in it.
|
|
91
|
+
- `loader(file)` returns the parsed document behind the file part of an entity's `$ref`, or `None`.
|
|
92
|
+
Without a loader the `PATH_UNKNOWN` and `SCHEMA_REF_UNRESOLVED` checks are skipped; every other
|
|
93
|
+
check still runs.
|
|
94
|
+
- The result is a `dict` with `ruleset`, `version`, `checksum`, `decision`, `findings`, `effects` and
|
|
95
|
+
`commands`, as specification section 8 defines them.
|
|
96
|
+
|
|
97
|
+
`evaluate(request, channel="server", operators=None)` checks the shape of the request before it
|
|
98
|
+
evaluates anything and raises `ValueError` for a request that is not well formed:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
rules.evaluate({"entity": "Transfer", "operation": "create", "data": []})
|
|
102
|
+
# ValueError: 'data' must be an object
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
A ruleset that breaks a rule of section 5 does not load. `LoadError.problems` is a list of
|
|
106
|
+
`{code, message, rule?}` and `LoadError.codes` lists the codes:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
broken = json.loads(json.dumps(registry["acme.payments.transfer"]))
|
|
110
|
+
broken["overrides"]["params"]["maxTransferAmount"] = 60000 # the parent allows only lower values
|
|
111
|
+
try:
|
|
112
|
+
load(broken, registry, schemas.get)
|
|
113
|
+
except LoadError as err:
|
|
114
|
+
print(err.codes)
|
|
115
|
+
# ['PARAM_LOOSENED']
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### YAML
|
|
119
|
+
|
|
120
|
+
The package reads no YAML: it takes parsed documents. `read()` in
|
|
121
|
+
[`tools/rulecheck.py`](../../tools/rulecheck.py) parses a ruleset file by the YAML 1.2 core schema,
|
|
122
|
+
as specification section 12 requires, and `python tools/rulecheck.py check <file>` reports every
|
|
123
|
+
scalar that YAML 1.1 and YAML 1.2 parsers read differently. A file that passes that check means the
|
|
124
|
+
same to `yaml.safe_load`.
|
|
125
|
+
|
|
126
|
+
## Manifests and channels
|
|
127
|
+
|
|
128
|
+
A loaded ruleset holds two manifests. The server manifest contains everything. The client manifest
|
|
129
|
+
contains only what a browser may see.
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
print(len(rules.manifest("server")["rules"]), len(rules.manifest("client")["rules"]))
|
|
133
|
+
# 14 9
|
|
134
|
+
print(sorted(rules.manifest("client")["params"]))
|
|
135
|
+
# ['internationalFeeRate', 'largeTransferThreshold', 'maxTransferAmount']
|
|
136
|
+
|
|
137
|
+
blocked = {"entity": "Transfer", "operation": "create",
|
|
138
|
+
"data": {"type": "international", "amount": 500, "currency": "USD", "memo": "gift",
|
|
139
|
+
"beneficiary": {"name": "X", "country": "KP", "swiftCode": "ABCDKPPY"}}}
|
|
140
|
+
print(rules.evaluate(blocked)["decision"], rules.evaluate(blocked, "client")["decision"])
|
|
141
|
+
# deny allow
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The rule that blocks the country is `enforcement: server`, so the client channel does not know it.
|
|
145
|
+
A client evaluation is advice; the server evaluation is the decision.
|
|
146
|
+
|
|
147
|
+
`rule_cascade.evaluate(manifest, request, operators=None)` evaluates a manifest received from
|
|
148
|
+
elsewhere, for example one fetched from a rule server.
|
|
149
|
+
|
|
150
|
+
## Bundles
|
|
151
|
+
|
|
152
|
+
A bundle is the compiled form of a ruleset: one JSON document with both manifests. Compile once, in
|
|
153
|
+
CI, and evaluate the same bundle in every runtime.
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
from rule_cascade import RuleSet
|
|
157
|
+
|
|
158
|
+
bundle = rules.bundle()
|
|
159
|
+
print(list(bundle))
|
|
160
|
+
# ['ruleCascadeBundle', 'id', 'version', 'checksum', 'manifests']
|
|
161
|
+
Path("acme.payments.transfer.bundle.json").write_text(json.dumps(bundle), encoding="utf-8")
|
|
162
|
+
|
|
163
|
+
loaded = RuleSet.from_bundle(json.loads(Path("acme.payments.transfer.bundle.json").read_text(encoding="utf-8")))
|
|
164
|
+
print(loaded.checksum == rules.checksum, loaded.resolved)
|
|
165
|
+
# True None
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`RuleSet.from_bundle` raises `LoadError` with `BUNDLE_UNSUPPORTED` for a bundle whose format is not
|
|
169
|
+
version 1.x and `BUNDLE_INVALID` for one without a usable `server` and `client` manifest. It runs no
|
|
170
|
+
other check, because the compiler already did. `resolved` is `None` for a ruleset read from a
|
|
171
|
+
bundle: a bundle holds manifests, not the resolved source. A bundle contains the server manifest, so
|
|
172
|
+
it is never sent to a browser.
|
|
173
|
+
|
|
174
|
+
From the command line, `python tools/rulecheck.py compile <file> -o <id>.bundle.json` writes the
|
|
175
|
+
bundle of a ruleset file.
|
|
176
|
+
|
|
177
|
+
## Custom operators
|
|
178
|
+
|
|
179
|
+
A ruleset declares the custom operators it uses under `operators` and calls them as
|
|
180
|
+
`{"op": "x-<name>", "args": [...]}`. The host supplies each one as a function, by name:
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
def luhn(text):
|
|
184
|
+
if not isinstance(text, str) or len(text) < 2 or not all("0" <= c <= "9" for c in text):
|
|
185
|
+
return False
|
|
186
|
+
total = 0
|
|
187
|
+
for i, c in enumerate(reversed(text)):
|
|
188
|
+
d = int(c) * (2 if i % 2 else 1)
|
|
189
|
+
total += d - 9 if d > 9 else d
|
|
190
|
+
return total % 10 == 0
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
operators = {"x-luhn": luhn}
|
|
194
|
+
|
|
195
|
+
customer = RuleSet.from_bundle(json.loads(
|
|
196
|
+
Path("conformance/bundles/acme.onboarding.customer.bundle.json").read_text(encoding="utf-8")))
|
|
197
|
+
print(customer.manifest("server")["operators"]) # what this manifest needs; check it at start-up
|
|
198
|
+
# ['x-luhn']
|
|
199
|
+
|
|
200
|
+
request = {"entity": "Customer", "operation": "update", "original": {},
|
|
201
|
+
"data": {"loyaltyNumber": "79927398710"}, "view": {"section": "membership"}}
|
|
202
|
+
finding = customer.evaluate(request, "server", operators)["findings"][0]
|
|
203
|
+
print(finding["code"], finding["message"])
|
|
204
|
+
# ONB-CUS-001 This loyalty number is not valid. Check the digits.
|
|
205
|
+
print(finding["location"])
|
|
206
|
+
# {'page': 'onboarding', 'screen': 'profile', 'section': 'membership'}
|
|
207
|
+
|
|
208
|
+
finding = customer.evaluate(request, "server")["findings"][0] # not registered: fails closed
|
|
209
|
+
print(finding["code"], finding["blocking"], finding["detail"])
|
|
210
|
+
# RULE-EVALUATION-ERROR True custom operator x-luhn is not registered
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
An operator receives its arguments as positional plain values (`None`, `bool`, `str`, `int` or
|
|
214
|
+
`float` rounded to 15 significant digits, `list`, `dict`) and returns a JSON value. It must be a pure
|
|
215
|
+
function. An operator that is missing, raises, or returns NaN or an infinity makes the rule fail
|
|
216
|
+
closed with a `RULE-EVALUATION-ERROR` finding.
|
|
217
|
+
|
|
218
|
+
The `view` in the request above narrows the evaluation to one place in the user interface, and
|
|
219
|
+
`finding["location"]` reports the place the rule's target names (specification section 8).
|
|
220
|
+
|
|
221
|
+
Check at start-up that every operator the rules need is registered. A missing operator does not
|
|
222
|
+
fail the load; it fails every rule that uses it, closed.
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
rules = RuleSet.from_bundle(bundle) # the onboarding catalog
|
|
226
|
+
print(rules.missing_operators({})) # ['x-luhn']
|
|
227
|
+
print(rules.missing_operators({"x-luhn": lambda text: True})) # []
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## One manifest on its own
|
|
231
|
+
|
|
232
|
+
A front end receives the client manifest, not the bundle. A ruleset read from one manifest has that
|
|
233
|
+
one channel:
|
|
234
|
+
|
|
235
|
+
```python
|
|
236
|
+
from rule_cascade import ChannelError, RuleSet
|
|
237
|
+
|
|
238
|
+
client = RuleSet.from_manifest(bundle["manifests"]["client"])
|
|
239
|
+
print(client.channels) # ['client']
|
|
240
|
+
print(client.evaluate({"entity": "Customer", "operation": "create", "data": {"fullName": "Maya"}})["decision"]) # deny
|
|
241
|
+
try:
|
|
242
|
+
client.evaluate({"entity": "Customer", "operation": "create"}, "server")
|
|
243
|
+
except ChannelError as err:
|
|
244
|
+
print(err) # ruleset acme.onboarding.customer has no server manifest
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
## Expressions
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
from rule_cascade import EvalError, evaluate_expression
|
|
251
|
+
|
|
252
|
+
print(evaluate_expression({"op": "add", "args": [0.1, 0.2]}))
|
|
253
|
+
# 0.3
|
|
254
|
+
vat = {"params": ["amount"],
|
|
255
|
+
"body": {"op": "round", "args": [{"op": "mul", "args": [{"var": "arg.amount"}, 0.21]}, 2]}}
|
|
256
|
+
print(evaluate_expression({"fn": "vat", "args": [{"var": "data.net"}]}, {"data": {"net": 19.99}}, {"vat": vat}))
|
|
257
|
+
# 4.2
|
|
258
|
+
print(evaluate_expression({"var": "data.rate"}, {"data": {"rate": 0.1234567890123456}}))
|
|
259
|
+
# 0.123456789012346
|
|
260
|
+
try:
|
|
261
|
+
evaluate_expression({"op": "lt", "args": [None, 100]})
|
|
262
|
+
except EvalError as err:
|
|
263
|
+
print(err)
|
|
264
|
+
# number expected, got None
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
`evaluate_expression(expr, env=None, functions=None, operators=None)` returns a plain JSON value and
|
|
268
|
+
raises `EvalError` for an evaluation error. Arithmetic is decimal with 34 significant digits; every
|
|
269
|
+
number that leaves is rounded half even to 15 significant digits (specification 4.2).
|
|
270
|
+
`rule_cascade.expressions.pattern_problem(pattern)` returns why a pattern is outside the portable
|
|
271
|
+
subset of specification 4.4, or `None` when it is inside.
|
|
272
|
+
|
|
273
|
+
## Refreshing the rules
|
|
274
|
+
|
|
275
|
+
`RuleSetHolder` loads the rules again on an interval (seconds) or a cron schedule and swaps them in.
|
|
276
|
+
When a load raises, it keeps the last good rules; a ruleset with the checksum already held is not
|
|
277
|
+
swapped in. The schedule runs on a daemon `threading.Timer`.
|
|
278
|
+
|
|
279
|
+
```python
|
|
280
|
+
from rule_cascade import RuleSet, RuleSetHolder
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def load_bundle():
|
|
284
|
+
return RuleSet.from_bundle(json.loads(Path("transfer.bundle.json").read_text(encoding="utf-8")))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
rules = RuleSetHolder(load_bundle, interval=300, on_reload=lambda r: r.error and print("not refreshed:", r.error))
|
|
288
|
+
# or RuleSetHolder(load_bundle, cron="0 * * * *", tz=ZoneInfo("Europe/Paris"))
|
|
289
|
+
result = rules.get().evaluate(request)
|
|
290
|
+
rules.close()
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
`CronSchedule("*/5 * * * *").next(after, tz)` is the cron syntax of
|
|
294
|
+
[docs/caching.md](../../docs/caching.md#cron-syntax). `packages/python/bench/evaluate.py` measures
|
|
295
|
+
evaluations per second ([docs/performance.md](../../docs/performance.md)).
|
|
296
|
+
|
|
297
|
+
## Engine protocol
|
|
298
|
+
|
|
299
|
+
The package runs as a program that any language drives over standard input and output: one JSON
|
|
300
|
+
request per line in, one JSON response per line out (specification section 13).
|
|
301
|
+
|
|
302
|
+
```bash
|
|
303
|
+
printf '%s\n' '{"id":1,"command":"version"}' '{"id":2,"command":"expression","expr":{"op":"add","args":[0.1,0.2]}}' \
|
|
304
|
+
| python -m rule_cascade engine
|
|
305
|
+
# {"id":1,"ok":true,"result":{"engine":"rule-cascade-python","engineVersion":"1.0.0a1","ruleCascade":"1.0.0","bundle":"1.0.0","levels":["evaluator","compiler"],"operators":[]}}
|
|
306
|
+
# {"id":2,"ok":true,"result":0.3}
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
It implements `version`, `load`, `manifest`, `evaluate`, `expression` and `compile`.
|
|
310
|
+
`--conformance-operators` registers the three operators of the conformance suite (`x-test-reverse`,
|
|
311
|
+
`x-test-sum`, `x-luhn`); without the option no custom operator is registered, and rules that use one
|
|
312
|
+
fail closed. To serve the protocol with operators of your own, call
|
|
313
|
+
`rule_cascade.engine.serve(sys.stdin, sys.stdout, operators)` from a program of yours, or use the
|
|
314
|
+
`Engine` class directly: `Engine(operators).handle(request)` takes a request object and returns the
|
|
315
|
+
response object.
|
|
316
|
+
|
|
317
|
+
## Role as the reference implementation
|
|
318
|
+
|
|
319
|
+
The other runtimes (TypeScript, Java, Go) are ports of this package and must agree with it on every
|
|
320
|
+
case of the [conformance suite](../../conformance/README.md). Three things follow:
|
|
321
|
+
|
|
322
|
+
- A change to the specification is implemented here first, then in the other runtimes.
|
|
323
|
+
- The generated parts of the suite (checksums, bundles, the evaluation corpus) are produced by this
|
|
324
|
+
package through `python tools/rulecheck.py sync`. They prove that the runtimes agree with the
|
|
325
|
+
reference. The hand-written expression, load-error and protocol cases and the golden tests are
|
|
326
|
+
what tie the reference to the specification.
|
|
327
|
+
- [`tools/rulecheck.py`](../../tools/rulecheck.py) is the command-line front end of this package:
|
|
328
|
+
`check`, `compile`, `manifest`, `conformance`, `sync`, `jsonlogic` and `derive`.
|
|
329
|
+
|
|
330
|
+
The code is written to be read next to the specification. It is not optimised: use it for tooling,
|
|
331
|
+
tests and services where Python is the language of the host.
|
|
332
|
+
|
|
333
|
+
## Tests
|
|
334
|
+
|
|
335
|
+
From the repository root, with the tool dependencies installed
|
|
336
|
+
(`pip install -r tools/requirements.txt`):
|
|
337
|
+
|
|
338
|
+
```bash
|
|
339
|
+
PYTHONPATH=packages/python/src python -m unittest discover -s packages/python/tests -q # or: make python
|
|
340
|
+
python tools/rulecheck.py conformance | tail -1 # or: make conformance
|
|
341
|
+
# conformance: 1899 cases, 0 failure(s)
|
|
342
|
+
PYTHONPATH=packages/python/src python tools/rulecheck.py conformance \
|
|
343
|
+
--engine "python -m rule_cascade engine --conformance-operators" | tail -1
|
|
344
|
+
# conformance: 1899 cases, 0 failure(s)
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
The first command runs the whole conformance suite through the engine protocol in process and checks
|
|
348
|
+
that the generated files are current. The last one runs the same cases against the package started
|
|
349
|
+
as a separate program.
|