Metadata-Version: 2.4
Name: rule-cascade
Version: 1.0.0a7
Summary: Rule Cascade for Python, and the reference implementation: compile, publish and evaluate YAML/JSON business rules.
Author-email: Yarlis LLC <support@rulescascade.com>
Maintainer-email: Yarlis LLC <support@rulescascade.com>
License-Expression: Apache-2.0
Project-URL: Homepage, https://rulescascade.com
Project-URL: Documentation, https://rulescascade.com/usage/python/
Project-URL: Changelog, https://rulescascade.com/reference/changelog/
Project-URL: Getting started, https://rulescascade.com/get-started/first-app/
Project-URL: Security, https://rulescascade.com/reference/security/
Keywords: rule-cascade,rules-cascade,business-rules,rules-engine,validation,decision-engine,fastapi,django,flask,problem-details
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Financial and Insurance Industry
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Framework :: Django
Classifier: Framework :: FastAPI
Classifier: Framework :: Flask
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: Office/Business
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: NOTICE
Requires-Dist: jsonschema>=4.21
Provides-Extra: yaml
Requires-Dist: pyyaml>=6.0; extra == "yaml"
Provides-Extra: fastapi
Requires-Dist: fastapi>=0.110; extra == "fastapi"
Provides-Extra: flask
Requires-Dist: flask>=3.0; extra == "flask"
Provides-Extra: django
Requires-Dist: django>=4.2; extra == "django"
Dynamic: license-file

# rule-cascade (Python)

The Rule Cascade runtime for Python, and the **reference implementation** of the
[specification](../../spec/v1/SPECIFICATION.md). It implements both conformance levels: it reads a
bundle and evaluates (evaluator), and it loads source documents and produces bundles (compiler).

Requires Python 3.10 or later. The only dependency is `jsonschema`, which validates source documents
against the ruleset schema.

```bash
pip install rule-cascade               # add the extra "rule-cascade[yaml]" to read YAML sources
```

The command-line tool for every language is [`rcas`](https://rulescascade.com/reference/cli/).

| Module | What it is |
|---|---|
| `rule_cascade.expressions` | Expressions, operators and portable patterns: specification section 4 |
| `rule_cascade.ruleset` | Loading, inheritance, load-time checks, checksum, manifests, bundles: sections 5 to 7 |
| `rule_cascade.evaluate` | Evaluation of a request against a manifest: section 8 |
| `rule_cascade.values` | Numbers, equality, canonical JSON and rendering, shared by the others |
| `rule_cascade.engine` | The engine protocol: section 13 |

The snippets below run from the repository root. They read the JSON fixtures of the conformance
suite, which are the example rulesets in `examples/contracts` and `examples/catalog` converted to
JSON.

## Compile and evaluate

`load(document, registry, loader)` runs every step of specification section 5 and returns a
`RuleSet`, or raises `LoadError`. It never returns a ruleset that failed a check.

```python
import json
from pathlib import Path

from rule_cascade import LoadError, load

fixtures = Path("conformance/fixtures")


def read(name):
    return json.loads((fixtures / name).read_text(encoding="utf-8"))


registry = {                                    # ruleset id -> document, so `extends` can be resolved
    "acme.org.base": read("acme-org-base.ruleset.json"),
    "acme.payments.transfer": read("payments-transfer.ruleset.json"),
}
schemas = {"./payments.openapi.yaml": read("payments.openapi.json")}

rules = load(registry["acme.payments.transfer"], registry, schemas.get)
print(rules.id, rules.version, rules.checksum)
# acme.payments.transfer 1.0.0 sha256:c192dd53b5b1d307d52ccbc27fc1674114e8714d53b699b24088a648ae242c7e

result = rules.evaluate({
    "entity": "Transfer",
    "operation": "create",
    "data": {"type": "international", "amount": 12000, "currency": "USD",
             "beneficiary": {"name": "Ana", "country": "ES"}},
    "actor": {"id": "u-1", "roles": ["teller"]},
})
print(result["decision"])
for finding in result["findings"]:
    print(finding["code"], finding["severity"], finding["blocking"], finding["fields"])
# deny
# ORG-TRF-003 warning False ['/memo']
# PAY-TRF-002 error True ['/beneficiary/swiftCode']
# PAY-TRF-003 warning True ['/amount', '/beneficiary/name']
print(result["effects"])
# [{'type': 'value', 'field': '/fee', 'value': 180, 'rule': 'transfer.fee.international'}]
```

- `registry` maps ruleset ids to source documents. The parent named by `extends` must be in it.
- `loader(file)` returns the parsed document behind the file part of an entity's `$ref`, or `None`.
  Without a loader the `PATH_UNKNOWN` and `SCHEMA_REF_UNRESOLVED` checks are skipped; every other
  check still runs.
- The result is a `dict` with `ruleset`, `version`, `checksum`, `decision`, `findings`, `effects` and
  `commands`, as specification section 8 defines them.

`evaluate(request, channel="server", operators=None)` checks the shape of the request before it
evaluates anything and raises `ValueError` for a request that is not well formed:

```python
rules.evaluate({"entity": "Transfer", "operation": "create", "data": []})
# ValueError: 'data' must be an object
```

A ruleset that breaks a rule of section 5 does not load. `LoadError.problems` is a list of
`{code, message, rule?}` and `LoadError.codes` lists the codes:

```python
broken = json.loads(json.dumps(registry["acme.payments.transfer"]))
broken["overrides"]["params"]["maxTransferAmount"] = 60000      # the parent allows only lower values
try:
    load(broken, registry, schemas.get)
except LoadError as err:
    print(err.codes)
# ['PARAM_LOOSENED']
```

### YAML

The package reads no YAML: it takes parsed documents. `read()` in
[`tools/rulecheck.py`](../../tools/rulecheck.py) parses a ruleset file by the YAML 1.2 core schema,
as specification section 12 requires, and `python tools/rulecheck.py check <file>` reports every
scalar that YAML 1.1 and YAML 1.2 parsers read differently. A file that passes that check means the
same to `yaml.safe_load`.

## Enforcing with a decorator

`rules.enforce(request, "server")` returns an allowed result and raises `RuleViolation` for a denied
one; `error.problem()` is the HTTP answer (RFC 9457 problem details, status 422, the same in every
Rule Cascade runtime). `@enforce_rules` does it before a function runs, sync or async:

```python
from rule_cascade import enforce_rules, current_evaluation
from rule_cascade.fastapi import install          # or rule_cascade.flask.init_app, rule_cascade.django

install(app)                                      # RuleViolation -> 422, RequestError -> 400

@app.post("/orders", status_code=201)
@enforce_rules(rules, entity="Order", operation="create", data="order", actor="user")
def create_order(order: Order, user: User = Depends(current_user)):
    evaluation = current_evaluation()             # the allowed result: warnings, computed values, commands
```

`data`, `original`, `actor`, `resolutions` name a parameter or are callables; `data` defaults to the
first parameter and may be a pydantic model or a dataclass. `rules` may be a `RuleSetHolder`. The
framework modules import their framework only when used. Walkthrough:
https://rulescascade.com/get-started/first-app/

## Manifests and channels

A loaded ruleset holds two manifests. The server manifest contains everything. The client manifest
contains only what a browser may see.

```python
print(len(rules.manifest("server")["rules"]), len(rules.manifest("client")["rules"]))
# 14 9
print(sorted(rules.manifest("client")["params"]))
# ['internationalFeeRate', 'largeTransferThreshold', 'maxTransferAmount']

blocked = {"entity": "Transfer", "operation": "create",
           "data": {"type": "international", "amount": 500, "currency": "USD", "memo": "gift",
                    "beneficiary": {"name": "X", "country": "KP", "swiftCode": "ABCDKPPY"}}}
print(rules.evaluate(blocked)["decision"], rules.evaluate(blocked, "client")["decision"])
# deny allow
```

The rule that blocks the country is `enforcement: server`, so the client channel does not know it.
A client evaluation is advice; the server evaluation is the decision.

`rule_cascade.evaluate(manifest, request, operators=None)` evaluates a manifest received from
elsewhere, for example one fetched from a rule server.

## Bundles

A bundle is the compiled form of a ruleset: one JSON document with both manifests. Compile once, in
CI, and evaluate the same bundle in every runtime.

```python
from rule_cascade import RuleSet

bundle = rules.bundle()
print(list(bundle))
# ['ruleCascadeBundle', 'id', 'version', 'checksum', 'manifests']
Path("acme.payments.transfer.bundle.json").write_text(json.dumps(bundle), encoding="utf-8")

loaded = RuleSet.from_bundle(json.loads(Path("acme.payments.transfer.bundle.json").read_text(encoding="utf-8")))
print(loaded.checksum == rules.checksum, loaded.resolved)
# True None
```

`RuleSet.from_bundle` raises `LoadError` with `BUNDLE_UNSUPPORTED` for a bundle whose format is not
version 1.x and `BUNDLE_INVALID` for one without a usable `server` and `client` manifest. It runs no
other check, because the compiler already did. `resolved` is `None` for a ruleset read from a
bundle: a bundle holds manifests, not the resolved source. A bundle contains the server manifest, so
it is never sent to a browser.

From the command line, `python tools/rulecheck.py compile <file> -o <id>.bundle.json` writes the
bundle of a ruleset file.

## Custom operators

A ruleset declares the custom operators it uses under `operators` and calls them as
`{"op": "x-<name>", "args": [...]}`. The host supplies each one as a function, by name:

```python
def luhn(text):
    if not isinstance(text, str) or len(text) < 2 or not all("0" <= c <= "9" for c in text):
        return False
    total = 0
    for i, c in enumerate(reversed(text)):
        d = int(c) * (2 if i % 2 else 1)
        total += d - 9 if d > 9 else d
    return total % 10 == 0


operators = {"x-luhn": luhn}

customer = RuleSet.from_bundle(json.loads(
    Path("conformance/bundles/acme.onboarding.customer.bundle.json").read_text(encoding="utf-8")))
print(customer.manifest("server")["operators"])      # what this manifest needs; check it at start-up
# ['x-luhn']

request = {"entity": "Customer", "operation": "update", "original": {},
           "data": {"loyaltyNumber": "79927398710"}, "view": {"section": "membership"}}
finding = customer.evaluate(request, "server", operators)["findings"][0]
print(finding["code"], finding["message"])
# ONB-CUS-001 This loyalty number is not valid. Check the digits.
print(finding["location"])
# {'page': 'onboarding', 'screen': 'profile', 'section': 'membership'}

finding = customer.evaluate(request, "server")["findings"][0]      # not registered: fails closed
print(finding["code"], finding["blocking"], finding["detail"])
# RULE-EVALUATION-ERROR True custom operator x-luhn is not registered
```

An operator receives its arguments as positional plain values (`None`, `bool`, `str`, `int` or
`float` rounded to 15 significant digits, `list`, `dict`) and returns a JSON value. It must be a pure
function. An operator that is missing, raises, or returns NaN or an infinity makes the rule fail
closed with a `RULE-EVALUATION-ERROR` finding.

The `view` in the request above narrows the evaluation to one place in the user interface, and
`finding["location"]` reports the place the rule's target names (specification section 8).

Check at start-up that every operator the rules need is registered. A missing operator does not
fail the load; it fails every rule that uses it, closed.

```python
rules = RuleSet.from_bundle(bundle)                              # the onboarding catalog
print(rules.missing_operators({}))                               # ['x-luhn']
print(rules.missing_operators({"x-luhn": lambda text: True}))    # []
```

## One manifest on its own

A front end receives the client manifest, not the bundle. A ruleset read from one manifest has that
one channel:

```python
from rule_cascade import ChannelError, RuleSet

client = RuleSet.from_manifest(bundle["manifests"]["client"])
print(client.channels)                                           # ['client']
print(client.evaluate({"entity": "Customer", "operation": "create", "data": {"fullName": "Maya"}})["decision"])  # deny
try:
    client.evaluate({"entity": "Customer", "operation": "create"}, "server")
except ChannelError as err:
    print(err)                                                   # ruleset acme.onboarding.customer has no server manifest
```

## Expressions

```python
from rule_cascade import EvalError, evaluate_expression

print(evaluate_expression({"op": "add", "args": [0.1, 0.2]}))
# 0.3
vat = {"params": ["amount"],
       "body": {"op": "round", "args": [{"op": "mul", "args": [{"var": "arg.amount"}, 0.21]}, 2]}}
print(evaluate_expression({"fn": "vat", "args": [{"var": "data.net"}]}, {"data": {"net": 19.99}}, {"vat": vat}))
# 4.2
print(evaluate_expression({"var": "data.rate"}, {"data": {"rate": 0.1234567890123456}}))
# 0.123456789012346
try:
    evaluate_expression({"op": "lt", "args": [None, 100]})
except EvalError as err:
    print(err)
# number expected, got None
```

`evaluate_expression(expr, env=None, functions=None, operators=None)` returns a plain JSON value and
raises `EvalError` for an evaluation error. Arithmetic is decimal with 34 significant digits; every
number that leaves is rounded half even to 15 significant digits (specification 4.2).
`rule_cascade.expressions.pattern_problem(pattern)` returns why a pattern is outside the portable
subset of specification 4.4, or `None` when it is inside.

## Refreshing the rules

`RuleSetHolder` loads the rules again on an interval (seconds) or a cron schedule and swaps them in.
When a load raises, it keeps the last good rules; a ruleset with the checksum already held is not
swapped in. The schedule runs on a daemon `threading.Timer`.

```python
from rule_cascade import RuleSet, RuleSetHolder


def load_bundle():
    return RuleSet.from_bundle(json.loads(Path("transfer.bundle.json").read_text(encoding="utf-8")))


rules = RuleSetHolder(load_bundle, interval=300, on_reload=lambda r: r.error and print("not refreshed:", r.error))
# or RuleSetHolder(load_bundle, cron="0 * * * *", tz=ZoneInfo("Europe/Paris"))
result = rules.get().evaluate(request)
rules.close()
```

`CronSchedule("*/5 * * * *").next(after, tz)` is the cron syntax of
[docs/caching.md](../../docs/caching.md#cron-syntax). `packages/python/bench/evaluate.py` measures
evaluations per second ([docs/performance.md](../../docs/performance.md)).

## Engine protocol

The package runs as a program that any language drives over standard input and output: one JSON
request per line in, one JSON response per line out (specification section 13).

```bash
printf '%s\n' '{"id":1,"command":"version"}' '{"id":2,"command":"expression","expr":{"op":"add","args":[0.1,0.2]}}' \
  | python -m rule_cascade engine
# {"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":[]}}
# {"id":2,"ok":true,"result":0.3}
```

It implements `version`, `load`, `manifest`, `evaluate`, `expression` and `compile`.
`--conformance-operators` registers the three operators of the conformance suite (`x-test-reverse`,
`x-test-sum`, `x-luhn`); without the option no custom operator is registered, and rules that use one
fail closed. To serve the protocol with operators of your own, call
`rule_cascade.engine.serve(sys.stdin, sys.stdout, operators)` from a program of yours, or use the
`Engine` class directly: `Engine(operators).handle(request)` takes a request object and returns the
response object.

## Role as the reference implementation

The other runtimes (TypeScript, Java, Go) are ports of this package and must agree with it on every
case of the [conformance suite](../../conformance/README.md). Three things follow:

- A change to the specification is implemented here first, then in the other runtimes.
- The generated parts of the suite (checksums, bundles, the evaluation corpus) are produced by this
  package through `python tools/rulecheck.py sync`. They prove that the runtimes agree with the
  reference. The hand-written expression, load-error and protocol cases and the golden tests are
  what tie the reference to the specification.
- [`tools/rulecheck.py`](../../tools/rulecheck.py) is the command-line front end of this package:
  `check`, `compile`, `manifest`, `conformance`, `sync`, `jsonlogic` and `derive`.

The code is written to be read next to the specification. It is not optimised: use it for tooling,
tests and services where Python is the language of the host.

## Tests

From the repository root, with the tool dependencies installed
(`pip install -r tools/requirements.txt`):

```bash
PYTHONPATH=packages/python/src python -m unittest discover -s packages/python/tests -q   # or: make python
python tools/rulecheck.py conformance | tail -1                                          # or: make conformance
# conformance: 1899 cases, 0 failure(s)
PYTHONPATH=packages/python/src python tools/rulecheck.py conformance \
  --engine "python -m rule_cascade engine --conformance-operators" | tail -1
# conformance: 1899 cases, 0 failure(s)
```

The first command runs the whole conformance suite through the engine protocol in process and checks
that the generated files are current. The last one runs the same cases against the package started
as a separate program.
