rule-cascade 1.0.0a2__py3-none-any.whl
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/__init__.py +26 -0
- rule_cascade/__main__.py +5 -0
- rule_cascade/cron.py +139 -0
- rule_cascade/engine.py +218 -0
- rule_cascade/evaluate.py +260 -0
- rule_cascade/expressions.py +487 -0
- rule_cascade/holder.py +131 -0
- rule_cascade/rule-cascade.schema.json +1436 -0
- rule_cascade/ruleset.py +583 -0
- rule_cascade/values.py +108 -0
- rule_cascade-1.0.0a2.dist-info/METADATA +349 -0
- rule_cascade-1.0.0a2.dist-info/RECORD +16 -0
- rule_cascade-1.0.0a2.dist-info/WHEEL +5 -0
- rule_cascade-1.0.0a2.dist-info/licenses/LICENSE +202 -0
- rule_cascade-1.0.0a2.dist-info/licenses/NOTICE +9 -0
- rule_cascade-1.0.0a2.dist-info/top_level.txt +1 -0
rule_cascade/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Rule Cascade: load, check and evaluate YAML/JSON business rule contracts.
|
|
2
|
+
|
|
3
|
+
This package is also the reference implementation of the specification. The other runtimes are
|
|
4
|
+
ports of it and must agree with it on every case in the conformance suite.
|
|
5
|
+
|
|
6
|
+
from rule_cascade import load, LoadError
|
|
7
|
+
|
|
8
|
+
rules = load(document, registry, loader) # raises LoadError
|
|
9
|
+
result = rules.evaluate({"entity": "Transfer", "operation": "create", "data": payload})
|
|
10
|
+
if result["decision"] == "deny": ...
|
|
11
|
+
"""
|
|
12
|
+
from .cron import CronError, CronSchedule
|
|
13
|
+
from .evaluate import ENGINE_ERROR_CODE, ENGINE_ERROR_MESSAGE, RequestError, evaluate, missing_operators, request_problem
|
|
14
|
+
from .expressions import EvalError, evaluate_expression
|
|
15
|
+
from .holder import Reload, RuleSetHolder
|
|
16
|
+
from .ruleset import ChannelError, LoadError, RuleSet, load, manifest, resolve, schema_problems, static_problems
|
|
17
|
+
from .values import canonical, canon_num, equal, plain, render
|
|
18
|
+
|
|
19
|
+
__version__ = "1.0.0a2"
|
|
20
|
+
__all__ = [
|
|
21
|
+
"ChannelError", "CronError", "CronSchedule", "ENGINE_ERROR_CODE", "ENGINE_ERROR_MESSAGE", "EvalError", "LoadError",
|
|
22
|
+
"Reload", "RequestError", "RuleSet", "RuleSetHolder", "canonical",
|
|
23
|
+
"equal", "missing_operators", "request_problem",
|
|
24
|
+
"evaluate", "evaluate_expression", "load", "manifest", "plain", "render", "resolve", "schema_problems",
|
|
25
|
+
"static_problems",
|
|
26
|
+
]
|
rule_cascade/__main__.py
ADDED
rule_cascade/cron.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""A five-field cron schedule: ``minute hour day-of-month month day-of-week``.
|
|
2
|
+
|
|
3
|
+
Each field is ``*``, a number, a range ``a-b``, a step ``*/n``, ``a-b/n`` or ``a/n`` (a to the maximum),
|
|
4
|
+
or a comma-separated list of those. Day of week is 0 to 7, where 0 and 7 are both Sunday. Names
|
|
5
|
+
(``JAN``, ``MON``) and macros (``@daily``) are not supported. When day of month and day of week are
|
|
6
|
+
both restricted, a day matches when either matches; a field that begins with ``*`` is not restricted.
|
|
7
|
+
A schedule that can never fire, such as ``0 0 30 2 *``, is refused.
|
|
8
|
+
|
|
9
|
+
The TypeScript, Java and Go runtimes implement the same semantics; ``tools/cron-cases.json`` is the
|
|
10
|
+
table all four are tested against.
|
|
11
|
+
|
|
12
|
+
schedule = CronSchedule("*/5 * * * *")
|
|
13
|
+
schedule.next(datetime.now(timezone.utc)) # the next fire time, in the zone given
|
|
14
|
+
"""
|
|
15
|
+
import datetime as _dt
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
__all__ = ["CronSchedule", "CronError"]
|
|
19
|
+
|
|
20
|
+
_NAMES = ("minute", "hour", "day of month", "month", "day of week")
|
|
21
|
+
_MIN = (0, 0, 1, 1, 0)
|
|
22
|
+
_MAX = (59, 23, 31, 12, 7)
|
|
23
|
+
_DAYS_IN_MONTH = (31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
|
|
24
|
+
_SEARCH_YEARS = 30 # bounds the search for the next fire time; only reachable through a bug
|
|
25
|
+
_DIGITS = re.compile(r"[0-9]+\Z")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CronError(ValueError):
|
|
29
|
+
"""The expression is not a five-field cron schedule this module accepts."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _digits(text):
|
|
33
|
+
return min(int(text), 100000)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _field(text, index):
|
|
37
|
+
name, low, high = _NAMES[index], _MIN[index], _MAX[index]
|
|
38
|
+
allowed = [False] * (high + 1)
|
|
39
|
+
|
|
40
|
+
def number(s):
|
|
41
|
+
if not _DIGITS.match(s):
|
|
42
|
+
raise CronError(f"{name}: '{s}' is not a number")
|
|
43
|
+
n = _digits(s)
|
|
44
|
+
if n < low or n > high:
|
|
45
|
+
raise CronError(f"{name}: {n} is outside {low}-{high}")
|
|
46
|
+
return n
|
|
47
|
+
|
|
48
|
+
for part in text.split(","):
|
|
49
|
+
pieces = part.split("/")
|
|
50
|
+
if len(pieces) > 2:
|
|
51
|
+
raise CronError(f"{name}: '{part}' has more than one step")
|
|
52
|
+
base, step = pieces[0], 1
|
|
53
|
+
if len(pieces) == 2:
|
|
54
|
+
if not _DIGITS.match(pieces[1]) or _digits(pieces[1]) == 0:
|
|
55
|
+
raise CronError(f"{name}: step '{pieces[1]}' is not a positive number")
|
|
56
|
+
step = _digits(pieces[1])
|
|
57
|
+
if base == "*":
|
|
58
|
+
start, end = low, high
|
|
59
|
+
elif "-" in base:
|
|
60
|
+
bounds = base.split("-")
|
|
61
|
+
if len(bounds) != 2:
|
|
62
|
+
raise CronError(f"{name}: '{base}' is not a range")
|
|
63
|
+
start, end = number(bounds[0]), number(bounds[1])
|
|
64
|
+
if start > end:
|
|
65
|
+
raise CronError(f"{name}: range '{base}' is reversed")
|
|
66
|
+
else:
|
|
67
|
+
start = number(base)
|
|
68
|
+
end = high if len(pieces) == 2 else start # a/n runs from a to the maximum
|
|
69
|
+
for v in range(start, end + 1, step):
|
|
70
|
+
allowed[v] = True
|
|
71
|
+
return allowed
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class CronSchedule:
|
|
75
|
+
"""A parsed cron expression. Immutable."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, expression):
|
|
78
|
+
if not isinstance(expression, str):
|
|
79
|
+
raise CronError("a cron expression is a string")
|
|
80
|
+
parts = [p for p in re.split(r"[ \t]+", expression.strip()) if p]
|
|
81
|
+
if len(parts) != 5:
|
|
82
|
+
raise CronError(f"'{expression}': a cron expression has five fields "
|
|
83
|
+
f"(minute hour day-of-month month day-of-week), found {len(parts)}")
|
|
84
|
+
self.expression = expression
|
|
85
|
+
self._minutes, self._hours, self._days, self._months, self._weekdays = (
|
|
86
|
+
_field(p, i) for i, p in enumerate(parts))
|
|
87
|
+
if self._weekdays[7]:
|
|
88
|
+
self._weekdays[0] = True
|
|
89
|
+
self._day_star = parts[2].startswith("*")
|
|
90
|
+
self._weekday_star = parts[4].startswith("*")
|
|
91
|
+
if not self._day_star and self._weekday_star:
|
|
92
|
+
possible = any(self._months[m] and self._days[d]
|
|
93
|
+
for m in range(1, 13) for d in range(1, _DAYS_IN_MONTH[m - 1] + 1))
|
|
94
|
+
if not possible:
|
|
95
|
+
raise CronError(f"'{expression}' never fires: no chosen month has the chosen days")
|
|
96
|
+
|
|
97
|
+
def __repr__(self):
|
|
98
|
+
return f"CronSchedule({self.expression!r})"
|
|
99
|
+
|
|
100
|
+
def _day_matches(self, t):
|
|
101
|
+
by_day = self._days[t.day]
|
|
102
|
+
by_weekday = self._weekdays[t.isoweekday() % 7] # isoweekday: Monday 1 ... Sunday 7
|
|
103
|
+
return (by_day and by_weekday) if (self._day_star or self._weekday_star) else (by_day or by_weekday)
|
|
104
|
+
|
|
105
|
+
def _next_wall(self, after):
|
|
106
|
+
"""The first matching wall-clock minute strictly after ``after`` (a naive datetime)."""
|
|
107
|
+
t = after.replace(second=0, microsecond=0) + _dt.timedelta(minutes=1)
|
|
108
|
+
limit = after.year + _SEARCH_YEARS
|
|
109
|
+
while t.year <= limit:
|
|
110
|
+
if not self._months[t.month]:
|
|
111
|
+
t = t.replace(day=1, hour=0, minute=0) # the day first: 31 January has no 31 February
|
|
112
|
+
t = t.replace(year=t.year + 1, month=1) if t.month == 12 else t.replace(month=t.month + 1)
|
|
113
|
+
elif not self._day_matches(t):
|
|
114
|
+
t = t.replace(hour=0, minute=0) + _dt.timedelta(days=1)
|
|
115
|
+
elif not self._hours[t.hour]:
|
|
116
|
+
t = t.replace(minute=0) + _dt.timedelta(hours=1)
|
|
117
|
+
elif not self._minutes[t.minute]:
|
|
118
|
+
t += _dt.timedelta(minutes=1)
|
|
119
|
+
else:
|
|
120
|
+
return t
|
|
121
|
+
raise CronError(f"'{self.expression}' does not fire within {_SEARCH_YEARS} years")
|
|
122
|
+
|
|
123
|
+
def next(self, after, tz=None):
|
|
124
|
+
"""The first fire time strictly after ``after``, an aware datetime, on the wall clock of ``tz``
|
|
125
|
+
(default: the zone of ``after``). Seconds are ignored. A wall-clock time that does not exist in
|
|
126
|
+
the zone (the hour skipped in spring) is skipped; one that happens twice fires once, at the first
|
|
127
|
+
occurrence. A naive ``after`` is read as UTC."""
|
|
128
|
+
if after.tzinfo is None:
|
|
129
|
+
after = after.replace(tzinfo=_dt.timezone.utc)
|
|
130
|
+
tz = tz or after.tzinfo
|
|
131
|
+
start = after.replace(second=0, microsecond=0)
|
|
132
|
+
wall = start.astimezone(tz).replace(tzinfo=None)
|
|
133
|
+
while True:
|
|
134
|
+
wall = self._next_wall(wall)
|
|
135
|
+
first = wall.replace(tzinfo=tz, fold=0) # fold=0: the earlier of two instants in an overlap
|
|
136
|
+
if first.astimezone(_dt.timezone.utc).astimezone(tz).replace(tzinfo=None) != wall:
|
|
137
|
+
continue # in a gap: the zone never shows this time
|
|
138
|
+
if first > start:
|
|
139
|
+
return first
|
rule_cascade/engine.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""The engine protocol: JSON Lines over standard input and output (specification section 13).
|
|
2
|
+
|
|
3
|
+
One JSON object per line in, one per line out, in the same order. It is how a program in any language,
|
|
4
|
+
on any operating system, drives an engine it cannot link to, and how the conformance suite certifies one.
|
|
5
|
+
|
|
6
|
+
python -m rule_cascade engine < requests.jsonl
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .evaluate import request_problem
|
|
13
|
+
from .expressions import MAX_VALUE_DEPTH, EvalError, evaluate_expression, too_deep
|
|
14
|
+
from .ruleset import BUNDLE_VERSION, LoadError, RuleSet, load
|
|
15
|
+
|
|
16
|
+
SPEC_VERSION = "1.0.0"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ProtocolError(Exception):
|
|
20
|
+
def __init__(self, code, message, **extra):
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.error = dict(code=code, message=message, **extra)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _need(request, key, kind):
|
|
26
|
+
value = request.get(key)
|
|
27
|
+
if not isinstance(value, kind):
|
|
28
|
+
raise ProtocolError("BAD_REQUEST", f"'{key}' is missing or has the wrong type")
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _optional(request, key):
|
|
33
|
+
"""An optional object member: absent and null are the same."""
|
|
34
|
+
value = request.get(key)
|
|
35
|
+
if value is not None and not isinstance(value, dict):
|
|
36
|
+
raise ProtocolError("BAD_REQUEST", f"'{key}' must be an object")
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _not_json(token):
|
|
41
|
+
raise ValueError(f"{token} is not JSON")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _double(text):
|
|
45
|
+
value = float(text)
|
|
46
|
+
if value in (float("inf"), float("-inf")):
|
|
47
|
+
raise ValueError(f"{text} is outside the range of a double")
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _whole(text):
|
|
52
|
+
value = int(text)
|
|
53
|
+
if abs(value) > 2 ** 53:
|
|
54
|
+
_double(text) # beyond 2^53 a whole number is still read; beyond the doubles it is refused
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Engine:
|
|
59
|
+
"""Holds loaded rulesets between requests. `operators` are the host's custom operators."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, operators=None):
|
|
62
|
+
self.operators = operators or {}
|
|
63
|
+
self.rulesets = {}
|
|
64
|
+
|
|
65
|
+
def _check_source(self, request):
|
|
66
|
+
"""Members first: a malformed request is BAD_REQUEST whatever rulesets are loaded."""
|
|
67
|
+
if "bundle" in request:
|
|
68
|
+
_need(request, "bundle", dict)
|
|
69
|
+
elif "manifest" in request:
|
|
70
|
+
_need(request, "manifest", dict)
|
|
71
|
+
else:
|
|
72
|
+
_need(request, "ruleset", str)
|
|
73
|
+
channel = request.get("channel") # optional: absent and null are the same
|
|
74
|
+
if channel not in (None, "server", "client"):
|
|
75
|
+
raise ProtocolError("BAD_REQUEST", "'channel' must be server or client")
|
|
76
|
+
return channel
|
|
77
|
+
|
|
78
|
+
def _ruleset(self, request):
|
|
79
|
+
try:
|
|
80
|
+
if "bundle" in request:
|
|
81
|
+
return RuleSet.from_bundle(request["bundle"])
|
|
82
|
+
if "manifest" in request:
|
|
83
|
+
return RuleSet.from_manifest(request["manifest"])
|
|
84
|
+
except LoadError as err:
|
|
85
|
+
raise ProtocolError("LOAD_FAILED", str(err), problems=err.problems)
|
|
86
|
+
if request["ruleset"] not in self.rulesets:
|
|
87
|
+
raise ProtocolError("UNKNOWN_RULESET", f"ruleset {request['ruleset']} has not been loaded")
|
|
88
|
+
return self.rulesets[request["ruleset"]]
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def _manifest(ruleset, channel):
|
|
92
|
+
"""The manifest to use: the one asked for; by default the server's, or the only one there is."""
|
|
93
|
+
if channel is None:
|
|
94
|
+
channel = "server" if "server" in ruleset.channels else ruleset.channels[0]
|
|
95
|
+
if channel not in ruleset.channels:
|
|
96
|
+
raise ProtocolError("CHANNEL_UNAVAILABLE", f"ruleset {ruleset.id} was loaded without a {channel} manifest")
|
|
97
|
+
return channel
|
|
98
|
+
|
|
99
|
+
def handle(self, request):
|
|
100
|
+
"""One request object in, one response object out. Never raises."""
|
|
101
|
+
response = {}
|
|
102
|
+
try:
|
|
103
|
+
if not isinstance(request, dict):
|
|
104
|
+
raise ProtocolError("BAD_REQUEST", "a request is a JSON object")
|
|
105
|
+
if "id" in request:
|
|
106
|
+
response["id"] = request["id"]
|
|
107
|
+
response.update(ok=True, result=self._dispatch(request))
|
|
108
|
+
except ProtocolError as err:
|
|
109
|
+
response.update(ok=False, error=err.error)
|
|
110
|
+
except RecursionError: # a member nested beyond what the checks before evaluation reach
|
|
111
|
+
response.update(ok=False, error={"code": "BAD_REQUEST", "message": "a member is nested too deeply"})
|
|
112
|
+
return response
|
|
113
|
+
|
|
114
|
+
def _dispatch(self, request):
|
|
115
|
+
command = request.get("command")
|
|
116
|
+
if command == "version":
|
|
117
|
+
return {"engine": "rule-cascade-python", "engineVersion": __version__, "ruleCascade": SPEC_VERSION,
|
|
118
|
+
"bundle": BUNDLE_VERSION, "levels": ["evaluator", "compiler"],
|
|
119
|
+
"operators": sorted(self.operators)}
|
|
120
|
+
if command == "compile":
|
|
121
|
+
document = _need(request, "document", dict)
|
|
122
|
+
registry = dict(_optional(request, "registry") or {})
|
|
123
|
+
schemas = _optional(request, "schemaDocuments")
|
|
124
|
+
try:
|
|
125
|
+
meta = document.get("metadata")
|
|
126
|
+
if isinstance(meta, dict) and isinstance(meta.get("id"), str):
|
|
127
|
+
registry.setdefault(meta["id"], document)
|
|
128
|
+
return load(document, registry, schemas.get if schemas is not None else None).bundle()
|
|
129
|
+
except LoadError as err:
|
|
130
|
+
raise ProtocolError("LOAD_FAILED", str(err), problems=err.problems)
|
|
131
|
+
if command == "load":
|
|
132
|
+
_need(request, "manifest" if "manifest" in request and "bundle" not in request else "bundle", dict)
|
|
133
|
+
ruleset = self._ruleset(request)
|
|
134
|
+
self.rulesets[ruleset.id] = ruleset # replaces whatever was loaded under that id
|
|
135
|
+
return {"ruleset": ruleset.id, "version": ruleset.version, "checksum": ruleset.checksum,
|
|
136
|
+
"channels": ruleset.channels, "missingOperators": ruleset.missing_operators(self.operators)}
|
|
137
|
+
if command == "manifest":
|
|
138
|
+
channel = self._check_source(request)
|
|
139
|
+
ruleset = self._ruleset(request)
|
|
140
|
+
return ruleset.manifest(self._manifest(ruleset, channel))
|
|
141
|
+
if command == "evaluate":
|
|
142
|
+
channel = self._check_source(request)
|
|
143
|
+
why = request_problem(request.get("request"))
|
|
144
|
+
if why:
|
|
145
|
+
raise ProtocolError("BAD_REQUEST", f"'request': {why}")
|
|
146
|
+
ruleset = self._ruleset(request)
|
|
147
|
+
return ruleset.evaluate(request["request"], self._manifest(ruleset, channel), self.operators)
|
|
148
|
+
if command == "expression":
|
|
149
|
+
if "expr" not in request:
|
|
150
|
+
raise ProtocolError("BAD_REQUEST", "'expr' is missing")
|
|
151
|
+
env, functions = _optional(request, "env"), _optional(request, "functions")
|
|
152
|
+
for root, value in (env or {}).items():
|
|
153
|
+
if too_deep(value):
|
|
154
|
+
raise ProtocolError("BAD_REQUEST", f"'env.{root}' is nested more than {MAX_VALUE_DEPTH} deep")
|
|
155
|
+
try:
|
|
156
|
+
return evaluate_expression(request["expr"], env, functions, self.operators)
|
|
157
|
+
except EvalError as err:
|
|
158
|
+
raise ProtocolError("EVALUATION_ERROR", str(err))
|
|
159
|
+
raise ProtocolError("BAD_REQUEST", f"unknown command {command!r}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def handle_line(self, line):
|
|
163
|
+
"""One line of text in, one response object out."""
|
|
164
|
+
try:
|
|
165
|
+
request = json.loads(line, parse_constant=_not_json, parse_float=_double, parse_int=_whole)
|
|
166
|
+
except (ValueError, RecursionError) as err: # RecursionError: nested beyond what the parser handles
|
|
167
|
+
return {"ok": False, "error": {"code": "BAD_REQUEST", "message": f"not JSON: {err}"}}
|
|
168
|
+
return self.handle(request)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def serve(lines, out, operators=None):
|
|
172
|
+
"""Answer requests until the input ends."""
|
|
173
|
+
engine = Engine(operators)
|
|
174
|
+
for line in lines:
|
|
175
|
+
if not line.strip():
|
|
176
|
+
continue
|
|
177
|
+
out.write(json.dumps(engine.handle_line(line), ensure_ascii=False, separators=(",", ":")) + "\n")
|
|
178
|
+
out.flush()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _luhn(text):
|
|
182
|
+
if not isinstance(text, str) or len(text) < 2 or not all("0" <= c <= "9" for c in text):
|
|
183
|
+
return False
|
|
184
|
+
total = 0
|
|
185
|
+
for i, c in enumerate(reversed(text)):
|
|
186
|
+
d = int(c) * (2 if i % 2 else 1)
|
|
187
|
+
total += d - 9 if d > 9 else d
|
|
188
|
+
return total % 10 == 0
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _reverse(text):
|
|
192
|
+
if not isinstance(text, str):
|
|
193
|
+
raise TypeError("x-test-reverse expects a string")
|
|
194
|
+
return text[::-1]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _sum(*numbers):
|
|
198
|
+
total = 0.0 # ordinary IEEE 754 doubles, added left to right
|
|
199
|
+
for x in numbers:
|
|
200
|
+
if isinstance(x, bool) or not isinstance(x, (int, float)):
|
|
201
|
+
raise TypeError("x-test-sum expects numbers")
|
|
202
|
+
total += float(x)
|
|
203
|
+
return total
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# The three operators every conformance runner registers (conformance/README.md).
|
|
207
|
+
CONFORMANCE_OPERATORS = {"x-test-reverse": _reverse, "x-test-sum": _sum, "x-luhn": _luhn}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def main(argv=None):
|
|
211
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
212
|
+
if not argv or argv[0] != "engine" or set(argv[1:]) - {"--conformance-operators"}:
|
|
213
|
+
print("usage: python -m rule_cascade engine [--conformance-operators]", file=sys.stderr)
|
|
214
|
+
return 2
|
|
215
|
+
sys.stdin.reconfigure(encoding="utf-8")
|
|
216
|
+
sys.stdout.reconfigure(encoding="utf-8", newline="\n")
|
|
217
|
+
serve(sys.stdin, sys.stdout, CONFORMANCE_OPERATORS if "--conformance-operators" in argv else None)
|
|
218
|
+
return 0
|
rule_cascade/evaluate.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Evaluation of one operation on one entity against a manifest.
|
|
2
|
+
|
|
3
|
+
This module is the executable definition of section 8 of spec/v1/SPECIFICATION.md.
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from .expressions import FUNCTIONS, MAX_VALUE_DEPTH, OPERATORS, EvalError, boolean, ev, lookup, too_deep
|
|
8
|
+
from .ruleset import PLACEHOLDER, VIEW_KEYS
|
|
9
|
+
from .values import dec, equal, plain, render
|
|
10
|
+
|
|
11
|
+
ENGINE_ERROR_CODE = "RULE-EVALUATION-ERROR"
|
|
12
|
+
ENGINE_ERROR_MESSAGE = "This rule could not be evaluated."
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def fill(template, args):
|
|
16
|
+
return PLACEHOLDER.sub(lambda m: render(args[m.group(1)]) if m.group(1) in args else m.group(0), template)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def pointer_path(root, pointer):
|
|
20
|
+
return root + pointer.replace("/", ".")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def set_pointer(obj, pointer, value):
|
|
24
|
+
*parents, last = pointer.strip("/").split("/")
|
|
25
|
+
for p in parents:
|
|
26
|
+
if not isinstance(obj.get(p), dict):
|
|
27
|
+
obj[p] = {}
|
|
28
|
+
obj = obj[p]
|
|
29
|
+
obj[last] = value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _is(kind):
|
|
33
|
+
return lambda v: v is None or isinstance(v, kind)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _strings(v):
|
|
37
|
+
return v is None or (isinstance(v, list) and all(isinstance(x, str) for x in v))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def request_problem(request):
|
|
41
|
+
"""Why `request` is not an evaluation request, or None. Checked before anything is evaluated,
|
|
42
|
+
so a malformed request is refused instead of half-evaluated. Optional members may be null."""
|
|
43
|
+
if not isinstance(request, dict):
|
|
44
|
+
return "a request is an object"
|
|
45
|
+
for key in ("entity", "operation"):
|
|
46
|
+
if not isinstance(request.get(key), str):
|
|
47
|
+
return f"'{key}' must be a string"
|
|
48
|
+
for key, ok, what in (("data", _is(dict), "an object"), ("original", _is(dict), "an object"),
|
|
49
|
+
("actor", _is(dict), "an object"), ("ctx", _is(dict), "an object"),
|
|
50
|
+
("view", _is(dict), "an object"), ("resolutions", _is(list), "a list"),
|
|
51
|
+
("trigger", _is(str), "a string"), ("locale", _is(str), "a string")):
|
|
52
|
+
if not ok(request.get(key)):
|
|
53
|
+
return f"'{key}' must be {what}"
|
|
54
|
+
for key in ("data", "original", "actor", "ctx"):
|
|
55
|
+
if too_deep(request.get(key)):
|
|
56
|
+
return f"'{key}' is nested more than {MAX_VALUE_DEPTH} deep"
|
|
57
|
+
if not _strings((request.get("actor") or {}).get("roles")):
|
|
58
|
+
return "'actor.roles' must be a list of strings"
|
|
59
|
+
for key in VIEW_KEYS:
|
|
60
|
+
if not _is(str)((request.get("view") or {}).get(key)):
|
|
61
|
+
return f"'view.{key}' must be a string"
|
|
62
|
+
for r in request.get("resolutions") or []:
|
|
63
|
+
if not (isinstance(r, dict) and isinstance(r.get("rule"), str) and isinstance(r.get("type"), str)
|
|
64
|
+
and _is(str)(r.get("justification"))):
|
|
65
|
+
return "a resolution is an object with a string 'rule' and 'type' and an optional string 'justification'"
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def locale_chain(default, wanted):
|
|
70
|
+
"""Catalogs to merge, least specific first: the default locale, then every prefix of the requested
|
|
71
|
+
tag. `fr-CA` reads en, fr, fr-CA: a regional catalog only needs the messages that differ."""
|
|
72
|
+
parts = wanted.split("-") if wanted else []
|
|
73
|
+
return [default] + ["-".join(parts[:i]) for i in range(1, len(parts) + 1)]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class RequestError(ValueError):
|
|
77
|
+
"""The evaluation request does not have the shape of specification section 8."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def missing_operators(mf, operators=None):
|
|
81
|
+
"""Custom operators the manifest's rules need that the host has not registered. Check it at start-up:
|
|
82
|
+
a missing operator does not fail the load, it fails every rule that uses it, closed."""
|
|
83
|
+
return sorted(name for name in set(mf.get("operators") or []) if not callable((operators or {}).get(name)))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def evaluate(mf, request, operators=None):
|
|
87
|
+
"""Pure: no I/O, no clock, no randomness. `operators` maps custom operator names to host functions."""
|
|
88
|
+
why = request_problem(request)
|
|
89
|
+
if why:
|
|
90
|
+
raise RequestError(why)
|
|
91
|
+
entity, operation = request["entity"], request["operation"]
|
|
92
|
+
data = json.loads(json.dumps(request.get("data") or {})) # working copy; compute rules write into it
|
|
93
|
+
actor = request.get("actor") or {}
|
|
94
|
+
env = {"data": data, "original": request.get("original"), "actor": actor,
|
|
95
|
+
"ctx": request.get("ctx") or {}, "params": mf.get("params") or {},
|
|
96
|
+
FUNCTIONS: mf.get("functions") or {}, OPERATORS: operators or {}}
|
|
97
|
+
client = mf["channel"] == "client"
|
|
98
|
+
wanted = ("client", "both") if client else ("server", "both")
|
|
99
|
+
trigger = request.get("trigger") if client else None
|
|
100
|
+
view = request.get("view") or {}
|
|
101
|
+
field_types = (mf.get("fieldTypes") or {}).get(entity, {})
|
|
102
|
+
|
|
103
|
+
def selected(r):
|
|
104
|
+
target = r["target"]
|
|
105
|
+
return (r.get("enabled", True)
|
|
106
|
+
and target.get("entity", entity) == entity
|
|
107
|
+
and operation in r["operations"]
|
|
108
|
+
and r.get("enforcement", "both") in wanted
|
|
109
|
+
and (trigger is None or trigger in r.get("triggers", ["submit"]))
|
|
110
|
+
# a view narrows evaluation to one place in the UI; rules that name no place always apply
|
|
111
|
+
and all(view.get(k) is None or target.get(k) is None or view[k] == target[k] for k in VIEW_KEYS))
|
|
112
|
+
|
|
113
|
+
rules = [r for r in mf["rules"] if selected(r)]
|
|
114
|
+
messages = mf.get("messages") or {}
|
|
115
|
+
catalog = {}
|
|
116
|
+
for locale in locale_chain(mf.get("defaultLocale", "en"), request.get("locale")):
|
|
117
|
+
catalog.update(messages.get(locale, {}))
|
|
118
|
+
resolutions = {(x["rule"], x["type"]): x for x in request.get("resolutions") or []}
|
|
119
|
+
roles = set(actor.get("roles") or [])
|
|
120
|
+
findings, effects, commands = [], [], []
|
|
121
|
+
|
|
122
|
+
def engine_error(rule, err):
|
|
123
|
+
findings.append({"rule": rule["id"], "code": ENGINE_ERROR_CODE, "severity": "error",
|
|
124
|
+
"message": ENGINE_ERROR_MESSAGE, "fields": [], "blocking": True, "status": "open",
|
|
125
|
+
"resolution": "none", "source": rule["origin"], "detail": str(err)})
|
|
126
|
+
|
|
127
|
+
def priority(rule): # a number like any other: the double nearest to what was written
|
|
128
|
+
return dec(rule.get("priority", 0))
|
|
129
|
+
|
|
130
|
+
def applies(rule, scope):
|
|
131
|
+
return "when" not in rule or boolean(ev(rule["when"], scope))
|
|
132
|
+
|
|
133
|
+
def of_kind(kind): # stable: equal priorities keep document order
|
|
134
|
+
return sorted((r for r in rules if r["kind"] == kind), key=lambda r: -priority(r))
|
|
135
|
+
|
|
136
|
+
def loses(previous_rule, rule, what):
|
|
137
|
+
"""Same target, different value. True = this rule loses quietly; raises when it is a real conflict."""
|
|
138
|
+
if mf.get("conflictPolicy", "fail") == "fail" or priority(previous_rule) == priority(rule):
|
|
139
|
+
raise EvalError(f"conflicting {what} from {previous_rule['id']}")
|
|
140
|
+
return True
|
|
141
|
+
|
|
142
|
+
# Phase 1: compute
|
|
143
|
+
written = {}
|
|
144
|
+
for r in of_kind("compute"):
|
|
145
|
+
try:
|
|
146
|
+
if not applies(r, env):
|
|
147
|
+
continue
|
|
148
|
+
for a in r["assign"]:
|
|
149
|
+
if a.get("mode", "default") == "default" and lookup(pointer_path("data", a["field"]), env) is not None:
|
|
150
|
+
continue
|
|
151
|
+
value = plain(ev(a["value"], env))
|
|
152
|
+
if a["field"] in written:
|
|
153
|
+
if equal(written[a["field"]][0], value) or loses(written[a["field"]][1], r, a["field"]):
|
|
154
|
+
continue
|
|
155
|
+
written[a["field"]] = (value, r)
|
|
156
|
+
set_pointer(data, a["field"], value)
|
|
157
|
+
effects.append({"type": "value", "field": a["field"], "value": value, "rule": r["id"]})
|
|
158
|
+
except EvalError as err:
|
|
159
|
+
engine_error(r, err)
|
|
160
|
+
|
|
161
|
+
# Phase 2: state
|
|
162
|
+
state = {}
|
|
163
|
+
for r in of_kind("state"):
|
|
164
|
+
try:
|
|
165
|
+
if not applies(r, env):
|
|
166
|
+
continue
|
|
167
|
+
# A rule applies all of its effects or, when one of them is a conflict, none of them:
|
|
168
|
+
# the outcome never depends on the order of the members of `set`.
|
|
169
|
+
staged = {}
|
|
170
|
+
for e in r["effects"]:
|
|
171
|
+
for prop, val in e["set"].items():
|
|
172
|
+
key = (e["field"], prop)
|
|
173
|
+
previous = staged.get(key) or state.get(key)
|
|
174
|
+
if previous and (equal(previous[0], val) or loses(previous[1], r, f"{prop} of {e['field']}")):
|
|
175
|
+
continue
|
|
176
|
+
staged[key] = (val, r)
|
|
177
|
+
state.update(staged)
|
|
178
|
+
except EvalError as err:
|
|
179
|
+
engine_error(r, err)
|
|
180
|
+
grouped = {}
|
|
181
|
+
for (field, prop), (val, r) in state.items():
|
|
182
|
+
grouped.setdefault((field, r["id"]), {})[prop] = val
|
|
183
|
+
effects += [{"type": "state", "field": f, "set": s, "rule": rid} for (f, rid), s in grouped.items()]
|
|
184
|
+
|
|
185
|
+
# Phase 3: validation
|
|
186
|
+
for r in of_kind("validation"):
|
|
187
|
+
try:
|
|
188
|
+
target = r["target"]
|
|
189
|
+
if "type" in target:
|
|
190
|
+
# once per field bound to the type, in pointer order, with `value` and `field` in scope
|
|
191
|
+
pointers = sorted(p for p, t in field_types.items() if t == target["type"])
|
|
192
|
+
scopes = [(dict(env, value=lookup(pointer_path("data", p), env), field=p), [p]) for p in pointers]
|
|
193
|
+
elif "forEach" in r:
|
|
194
|
+
coll = lookup(pointer_path("data", r["forEach"]), env)
|
|
195
|
+
if coll is None:
|
|
196
|
+
coll = []
|
|
197
|
+
if not isinstance(coll, list):
|
|
198
|
+
raise EvalError(f"forEach {r['forEach']} is not a list")
|
|
199
|
+
own = [target["field"]] if "field" in target else target.get("fields", [])
|
|
200
|
+
scopes = [(dict(env, item=x), [f"{r['forEach']}/{i}{p}" for p in own]) for i, x in enumerate(coll)]
|
|
201
|
+
else:
|
|
202
|
+
scopes = [(env, [target["field"]] if "field" in target else list(target.get("fields", [])))]
|
|
203
|
+
for scope, fields in scopes:
|
|
204
|
+
if not applies(r, scope) or boolean(ev(r["assert"], scope)):
|
|
205
|
+
continue
|
|
206
|
+
spec = r["finding"]
|
|
207
|
+
args = {k: plain(ev(x, scope)) for k, x in spec.get("args", {}).items()}
|
|
208
|
+
acceptance = r.get("acceptance", {})
|
|
209
|
+
severity = r["severity"]
|
|
210
|
+
status, resolution = "open", "none"
|
|
211
|
+
if severity == "error" and acceptance.get("allowed"):
|
|
212
|
+
resolution = "accept-risk"
|
|
213
|
+
given = resolutions.get((r["id"], "accept-risk"))
|
|
214
|
+
role_ok = not acceptance.get("roles") or bool(roles & set(acceptance["roles"]))
|
|
215
|
+
justified = acceptance.get("justification", "required") == "none" or bool(
|
|
216
|
+
(given or {}).get("justification"))
|
|
217
|
+
if given and role_ok and justified:
|
|
218
|
+
status = "accepted"
|
|
219
|
+
elif severity == "warning" and r.get("acknowledgement") == "required":
|
|
220
|
+
resolution = "acknowledge"
|
|
221
|
+
if (r["id"], "acknowledge") in resolutions:
|
|
222
|
+
status = "acknowledged"
|
|
223
|
+
finding = {
|
|
224
|
+
"rule": r["id"], "code": spec["code"], "severity": severity,
|
|
225
|
+
"message": fill(catalog.get(spec["message"], spec["message"]), args),
|
|
226
|
+
"fields": fields,
|
|
227
|
+
"blocking": status == "open" and (severity == "error" or resolution == "acknowledge"),
|
|
228
|
+
"status": status, "resolution": resolution, "source": r["origin"],
|
|
229
|
+
}
|
|
230
|
+
location = {k: target[k] for k in VIEW_KEYS if k in target}
|
|
231
|
+
if location:
|
|
232
|
+
finding["location"] = location
|
|
233
|
+
if acceptance.get("roles"):
|
|
234
|
+
finding["acceptableBy"] = acceptance["roles"]
|
|
235
|
+
findings.append(finding)
|
|
236
|
+
except EvalError as err:
|
|
237
|
+
engine_error(r, err)
|
|
238
|
+
|
|
239
|
+
decision = "deny" if any(f["blocking"] for f in findings) else "allow"
|
|
240
|
+
|
|
241
|
+
# Phase 4: action - server only, and only when allowed. The host runs commands after it has persisted.
|
|
242
|
+
if decision == "allow" and not client:
|
|
243
|
+
for r in of_kind("action"):
|
|
244
|
+
try:
|
|
245
|
+
if not applies(r, env):
|
|
246
|
+
continue
|
|
247
|
+
for c in r["commands"]:
|
|
248
|
+
cmd = {"name": c["name"], "type": c["type"], "rule": r["id"],
|
|
249
|
+
"idempotencyKey": ":".join(render(plain(ev(p, env))) for p in c["idempotencyKey"]),
|
|
250
|
+
"payload": {k: plain(ev(x, env)) for k, x in c.get("payload", {}).items()}}
|
|
251
|
+
if "ref" in c:
|
|
252
|
+
cmd["ref"] = c["ref"]
|
|
253
|
+
commands.append(cmd)
|
|
254
|
+
except EvalError as err:
|
|
255
|
+
engine_error(r, err)
|
|
256
|
+
decision, commands = "deny", []
|
|
257
|
+
break
|
|
258
|
+
|
|
259
|
+
return {"ruleset": mf["id"], "version": mf["version"], "checksum": mf["checksum"],
|
|
260
|
+
"decision": decision, "findings": findings, "effects": effects, "commands": commands}
|