pyruleset 0.1.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.
- pyruleset-0.1.0/PKG-INFO +385 -0
- pyruleset-0.1.0/README.md +361 -0
- pyruleset-0.1.0/pyproject.toml +125 -0
- pyruleset-0.1.0/pyproject.toml.orig +84 -0
- pyruleset-0.1.0/src/pyruleset/__init__.py +40 -0
- pyruleset-0.1.0/src/pyruleset/compilers.py +124 -0
- pyruleset-0.1.0/src/pyruleset/exceptions.py +14 -0
- pyruleset-0.1.0/src/pyruleset/json_schema.py +65 -0
- pyruleset-0.1.0/src/pyruleset/py.typed +0 -0
- pyruleset-0.1.0/src/pyruleset/ruleset.py +174 -0
pyruleset-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyruleset
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Conditional values: pick a value by the first matching condition, composable with | and definable as JSON.
|
|
5
|
+
Keywords: rules,decision-table,policy,conditional,json,dsl,composition
|
|
6
|
+
Author: aaltatan
|
|
7
|
+
Author-email: aaltatan <a.altatan@gmail.com>
|
|
8
|
+
License-Expression: GPL-3.0-only
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Dist: pyvocabulary>=0.1.0
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Project-URL: Homepage, https://github.com/aaltatan/pyruleset
|
|
21
|
+
Project-URL: Repository, https://github.com/aaltatan/pyruleset
|
|
22
|
+
Project-URL: Issues, https://github.com/aaltatan/pyruleset/issues
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# pyruleset
|
|
26
|
+
|
|
27
|
+
Conditional values that compose with `|` and can be defined as JSON: the first branch whose
|
|
28
|
+
condition matches decides.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
price = (
|
|
32
|
+
when(tier__eq("gold"), subtotal * Decimal("0.80"), label="gold 20%")
|
|
33
|
+
| when(items__ge(10), subtotal * Decimal("0.95"), label="bulk 5%")
|
|
34
|
+
| otherwise(subtotal)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
price(order) # Decimal('142.5000')
|
|
38
|
+
price.explain(order) # 'bulk 5%'
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Conditions are any `T -> bool` callable, such as a
|
|
42
|
+
[pyspecification](https://github.com/aaltatan/pyspecification) `Predicate`. Values are any
|
|
43
|
+
`T -> V` callable, such as a [pyformula](https://github.com/aaltatan/pyformula) `Formula`, a
|
|
44
|
+
[pyflowstep](https://github.com/aaltatan/pyflowstep) `Flow`, or a plain function.
|
|
45
|
+
|
|
46
|
+
| Piece | What it does |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| [`when`, `otherwise`](#quick-start) | Build branches and the fallback, chained with `\|` |
|
|
49
|
+
| [`Ruleset`](#deciding-and-explaining) | Callable: returns the value of the first matching branch; `explain` and `decide` say which one |
|
|
50
|
+
| [`PartialRuleset`](#a-missing-fallback-is-a-type-error) | Branches without a fallback: composes, but is not callable |
|
|
51
|
+
| [`RulesetCompiler`](#from-json) | Builds a `Ruleset` from JSON, delegating `when` and `then` to compilers you inject |
|
|
52
|
+
| [`get_ruleset_json_schema`](#json-schema) | The JSON Schema of that JSON, embedding your condition and value schemas |
|
|
53
|
+
|
|
54
|
+
## Installation
|
|
55
|
+
|
|
56
|
+
pyruleset is not on PyPI yet. Install it from GitHub:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install git+https://github.com/aaltatan/pyruleset
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
or with uv:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
uv add git+https://github.com/aaltatan/pyruleset
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Python 3.12+. The only runtime dependency is
|
|
69
|
+
[pyvocabulary](https://github.com/aaltatan/pyvocabulary). pyspecification, pyformula and
|
|
70
|
+
pyflowstep are optional: pyruleset only needs callables.
|
|
71
|
+
|
|
72
|
+
## Quick start
|
|
73
|
+
|
|
74
|
+
The examples in this README build on each other and run as doctests.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
>>> from dataclasses import dataclass
|
|
78
|
+
>>> from decimal import Decimal
|
|
79
|
+
>>> from pyformula import variable
|
|
80
|
+
>>> from pyspecification import object_rule
|
|
81
|
+
>>> from pyruleset import otherwise, when
|
|
82
|
+
|
|
83
|
+
>>> @dataclass(frozen=True)
|
|
84
|
+
... class Order:
|
|
85
|
+
... tier: str
|
|
86
|
+
... subtotal: Decimal
|
|
87
|
+
... items: int
|
|
88
|
+
|
|
89
|
+
>>> @object_rule()
|
|
90
|
+
... def tier__eq(order: Order, tier: str) -> bool:
|
|
91
|
+
... return order.tier == tier
|
|
92
|
+
|
|
93
|
+
>>> @object_rule()
|
|
94
|
+
... def items__ge(order: Order, count: int) -> bool:
|
|
95
|
+
... return order.items >= count
|
|
96
|
+
|
|
97
|
+
>>> @variable()
|
|
98
|
+
... def subtotal(order: Order) -> Decimal:
|
|
99
|
+
... return order.subtotal
|
|
100
|
+
|
|
101
|
+
>>> price = (
|
|
102
|
+
... when(tier__eq("gold"), subtotal * Decimal("0.80"), label="gold 20%")
|
|
103
|
+
... | when(items__ge(10), subtotal * Decimal("0.95"), label="bulk 5%")
|
|
104
|
+
... | otherwise(subtotal)
|
|
105
|
+
... )
|
|
106
|
+
>>> price
|
|
107
|
+
Ruleset(gold 20% | bulk 5% | otherwise)
|
|
108
|
+
|
|
109
|
+
>>> order = Order("regular", Decimal("150.00"), 12)
|
|
110
|
+
>>> price(order)
|
|
111
|
+
Decimal('142.5000')
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Branches are tried in order and the first match wins. Conditions after the match are not
|
|
116
|
+
evaluated. When nothing matches, the `otherwise` value is used.
|
|
117
|
+
|
|
118
|
+
## Deciding and explaining
|
|
119
|
+
|
|
120
|
+
`explain(subject)` names the branch that decided, e.g. for an audit log: its label, else the
|
|
121
|
+
condition's name, else `"otherwise"`. `decide(subject)` returns the `Branch` (or `Otherwise`)
|
|
122
|
+
itself.
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
>>> price.explain(order)
|
|
126
|
+
'bulk 5%'
|
|
127
|
+
>>> price.explain(Order("regular", Decimal("60.00"), 1))
|
|
128
|
+
'otherwise'
|
|
129
|
+
>>> price.decide(order)
|
|
130
|
+
Branch(condition=..., label='bulk 5%')
|
|
131
|
+
|
|
132
|
+
>>> unlabelled = when(items__ge(10), subtotal) | otherwise(subtotal)
|
|
133
|
+
>>> unlabelled.explain(order), unlabelled
|
|
134
|
+
('items__ge', Ruleset(items__ge | otherwise))
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
A condition can return any value: truthy counts as a match.
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
>>> size = when(len, lambda items: f"{len(items)} items") | otherwise(lambda _: "empty")
|
|
142
|
+
>>> size([1, 2]), size([])
|
|
143
|
+
('2 items', 'empty')
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## A missing fallback is a type error
|
|
148
|
+
|
|
149
|
+
`when(...) | when(...)` is a `PartialRuleset`. It composes, but it has no `__call__`, so a
|
|
150
|
+
type checker flags a call to it, and at run time it fails instead of returning `None`. Only
|
|
151
|
+
`| otherwise(...)` (or `| ruleset`) makes it callable.
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
>>> partial = when(tier__eq("gold"), subtotal * Decimal("0.80"), label="gold")
|
|
155
|
+
>>> partial
|
|
156
|
+
PartialRuleset(gold)
|
|
157
|
+
>>> partial(order)
|
|
158
|
+
Traceback (most recent call last):
|
|
159
|
+
...
|
|
160
|
+
TypeError: 'PartialRuleset' object is not callable
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
A `Ruleset` is closed: it has no `|`, so nothing can be added after its fallback by accident.
|
|
165
|
+
|
|
166
|
+
## Overrides on top of a base policy
|
|
167
|
+
|
|
168
|
+
`partial | ruleset` puts the partial's branches first. The base policy stays unchanged, and
|
|
169
|
+
the override is easy to drop later.
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
>>> black_friday = when(items__ge(5), subtotal * Decimal("0.70"), label="black friday")
|
|
173
|
+
>>> today = black_friday | price
|
|
174
|
+
>>> today
|
|
175
|
+
Ruleset(black friday | gold 20% | bulk 5% | otherwise)
|
|
176
|
+
>>> today(order), price(order)
|
|
177
|
+
(Decimal('105.0000'), Decimal('142.5000'))
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Every operator returns a new object; rulesets are frozen dataclasses.
|
|
182
|
+
|
|
183
|
+
## Any callable as a value
|
|
184
|
+
|
|
185
|
+
Values do not have to be formulas. A pyflowstep `Flow` turns the ruleset into "pick a
|
|
186
|
+
procedure and run it":
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
>>> from pyflowstep import step
|
|
190
|
+
>>> @step
|
|
191
|
+
... def add(total: int, amount: int) -> int:
|
|
192
|
+
... return total + amount
|
|
193
|
+
|
|
194
|
+
>>> bonus = when(lambda n: n >= 100, add(10) >> add(5), label="big") | otherwise(add(0))
|
|
195
|
+
>>> bonus(100), bonus(7)
|
|
196
|
+
(115, 7)
|
|
197
|
+
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## From JSON
|
|
201
|
+
|
|
202
|
+
A definition has `branches` (optional) and `otherwise` (required). Each branch has `when`,
|
|
203
|
+
`then` and an optional `label`:
|
|
204
|
+
|
|
205
|
+
```json
|
|
206
|
+
{
|
|
207
|
+
"branches": [
|
|
208
|
+
{"label": "gold 20%",
|
|
209
|
+
"when": {"name": "tier__eq", "args": ["gold"], "kwargs": {}, "inverse": false},
|
|
210
|
+
"then": {"operator": "multiply", "expressions": ["subtotal", 0.80]}}
|
|
211
|
+
],
|
|
212
|
+
"otherwise": "subtotal"
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
pyruleset does not know the languages inside `when` and `then`. `RulesetCompiler` delegates
|
|
217
|
+
them to the compilers you pass in: anything with a `compile(definition)` method, such as
|
|
218
|
+
pyspecification's `PredicateCompiler` and pyformula's `FormulaCompiler`.
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
>>> import json
|
|
222
|
+
>>> from pyformula import FormulaCompiler
|
|
223
|
+
>>> from pyspecification import Predicate, PredicateCompiler
|
|
224
|
+
>>> from pyruleset import RulesetCompiler
|
|
225
|
+
|
|
226
|
+
>>> rules = {"tier__eq": tier__eq, "items__ge": items__ge}
|
|
227
|
+
>>> compiler = RulesetCompiler(
|
|
228
|
+
... PredicateCompiler(
|
|
229
|
+
... rules,
|
|
230
|
+
... lambda wrapper: Predicate(lambda _: wrapper["operator"] == "all", operator="logical"),
|
|
231
|
+
... ),
|
|
232
|
+
... FormulaCompiler({"subtotal": subtotal}),
|
|
233
|
+
... )
|
|
234
|
+
|
|
235
|
+
>>> stored = '''
|
|
236
|
+
... {
|
|
237
|
+
... "branches": [
|
|
238
|
+
... {"label": "gold 20%",
|
|
239
|
+
... "when": {"name": "tier__eq", "args": ["gold"], "kwargs": {}, "inverse": false},
|
|
240
|
+
... "then": {"operator": "multiply", "expressions": ["subtotal", 0.80]}}
|
|
241
|
+
... ],
|
|
242
|
+
... "otherwise": "subtotal"
|
|
243
|
+
... }
|
|
244
|
+
... '''
|
|
245
|
+
>>> policy = compiler.compile(json.loads(stored, parse_float=Decimal))
|
|
246
|
+
>>> policy
|
|
247
|
+
Ruleset(gold 20% | otherwise)
|
|
248
|
+
>>> policy(Order("gold", Decimal("120.00"), 2))
|
|
249
|
+
Decimal('96.0000')
|
|
250
|
+
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### Errors
|
|
254
|
+
|
|
255
|
+
The whole definition is checked when it is compiled, before any subject is evaluated.
|
|
256
|
+
|
|
257
|
+
A wrong shape raises `InvalidRulesetDefinitionError` (also a `ValueError`) with its JSON
|
|
258
|
+
path:
|
|
259
|
+
|
|
260
|
+
```python
|
|
261
|
+
>>> compiler.compile({"branches": []})
|
|
262
|
+
Traceback (most recent call last):
|
|
263
|
+
...
|
|
264
|
+
pyruleset.exceptions.InvalidRulesetDefinitionError: Invalid ruleset at $: missing 'otherwise'
|
|
265
|
+
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Errors from the injected compilers keep their own type. pyruleset only adds a note with the
|
|
269
|
+
full JSON path, nested paths included (e.g. `at $.branches[1].when.expressions[0]`):
|
|
270
|
+
|
|
271
|
+
```python
|
|
272
|
+
>>> from pyspecification import RuleDoesNotExistError
|
|
273
|
+
>>> typo = json.loads(stored.replace("tier__eq", "tier__is"), parse_float=Decimal)
|
|
274
|
+
>>> try:
|
|
275
|
+
... compiler.compile(typo)
|
|
276
|
+
... except RuleDoesNotExistError as error:
|
|
277
|
+
... print(f"{type(error).__name__}: {error}", error.__notes__)
|
|
278
|
+
RuleDoesNotExistError: Rule 'tier__is' does not exist. Available rules: tier__eq, items__ge ['at $.branches[0].when']
|
|
279
|
+
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
## Money
|
|
283
|
+
|
|
284
|
+
`json.loads` turns `0.80` into a binary float, which then leaks into every `Decimal` it
|
|
285
|
+
touches. Keep money exact in one of two ways:
|
|
286
|
+
|
|
287
|
+
- Parse with `json.loads(text, parse_float=Decimal)`, as above, so every JSON number with a
|
|
288
|
+
decimal point becomes a `Decimal` before it reaches the compiler.
|
|
289
|
+
- Or, with pyformula 0.2+, build the value compiler with
|
|
290
|
+
`FormulaCompiler(variables, literal=decimal_literal)`, which converts each number literal
|
|
291
|
+
through `str` (`Decimal("0.8")`, not `Decimal(0.8)`), whoever parsed the JSON.
|
|
292
|
+
|
|
293
|
+
## JSON Schema
|
|
294
|
+
|
|
295
|
+
`get_ruleset_json_schema(condition_schema, value_schema)` describes a definition, e.g. to
|
|
296
|
+
validate an admin form or to get completion in an editor. The two schemas describe one
|
|
297
|
+
`when` and one `then`/`otherwise`. Each is embedded under its own `$id`, so the
|
|
298
|
+
`#/$defs/...` refs inside it keep pointing into itself.
|
|
299
|
+
|
|
300
|
+
```python
|
|
301
|
+
>>> from jsonschema import Draft202012Validator
|
|
302
|
+
>>> from pyspecification import get_expression_json_schema
|
|
303
|
+
>>> from pyruleset import get_ruleset_json_schema
|
|
304
|
+
|
|
305
|
+
>>> formula_schema = {"anyOf": [{"const": "subtotal"}, {"type": "number"}, {"type": "object"}]}
|
|
306
|
+
>>> schema = get_ruleset_json_schema(get_expression_json_schema(rules), formula_schema)
|
|
307
|
+
>>> validator = Draft202012Validator(schema)
|
|
308
|
+
>>> validator.is_valid(json.loads(stored))
|
|
309
|
+
True
|
|
310
|
+
>>> validator.is_valid(json.loads(stored.replace("tier__eq", "tier__is")))
|
|
311
|
+
False
|
|
312
|
+
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
`jsonschema` is only used here to show the schema at work; pyruleset does not depend on it.
|
|
316
|
+
|
|
317
|
+
## First match only
|
|
318
|
+
|
|
319
|
+
A ruleset picks exactly one value. There is no "all matches" mode, on purpose: each ruleset
|
|
320
|
+
stays easy to read and to explain. To stack several decisions, give each part its own
|
|
321
|
+
ruleset and add the results:
|
|
322
|
+
|
|
323
|
+
```python
|
|
324
|
+
>>> shipping = (
|
|
325
|
+
... when(items__ge(10), lambda _: Decimal("0.00"), label="free shipping")
|
|
326
|
+
... | otherwise(lambda _: Decimal("5.00"))
|
|
327
|
+
... )
|
|
328
|
+
>>> def total(order: Order) -> Decimal:
|
|
329
|
+
... return price(order) + shipping(order)
|
|
330
|
+
>>> small = Order("regular", Decimal("60.00"), 1)
|
|
331
|
+
>>> total(small), [price.explain(small), shipping.explain(small)]
|
|
332
|
+
(Decimal('65.00'), ['otherwise', 'otherwise'])
|
|
333
|
+
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Progressive brackets (tax bands) are not a ruleset either: they are a sum of clamped terms,
|
|
337
|
+
one per band.
|
|
338
|
+
|
|
339
|
+
## Examples
|
|
340
|
+
|
|
341
|
+
Runnable scripts in [`examples/`](examples), each asserting its own results:
|
|
342
|
+
|
|
343
|
+
- [`pricing_policy.py`](examples/pricing_policy.py): a JSON policy from an admin panel,
|
|
344
|
+
rejected on save with the JSON path of a typo, and `explain` for the audit log.
|
|
345
|
+
- [`expense_approvals.py`](examples/expense_approvals.py): who approves an expense, with a
|
|
346
|
+
temporary "CFO on leave" override on top of the base policy.
|
|
347
|
+
- [`ticket_routing.py`](examples/ticket_routing.py): each branch's value is a pyflowstep
|
|
348
|
+
`Flow` that assigns, prioritizes and tags the ticket.
|
|
349
|
+
|
|
350
|
+
```bash
|
|
351
|
+
uv run python examples/pricing_policy.py
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
## API reference
|
|
355
|
+
|
|
356
|
+
| Name | Kind | Summary |
|
|
357
|
+
| --- | --- | --- |
|
|
358
|
+
| `when(condition, value, /, *, label=None) -> PartialRuleset` | function | One branch; chain more with `\|` |
|
|
359
|
+
| `otherwise(value, /) -> Otherwise` | function | The fallback that closes a ruleset |
|
|
360
|
+
| `Ruleset(branches, fallback)` | class | `__call__(subject)`, `decide(subject)`, `explain(subject)` |
|
|
361
|
+
| `PartialRuleset(branches)` | class | `\|` with a partial, an `Otherwise` or a `Ruleset`; not callable |
|
|
362
|
+
| `Branch(condition, value, label=None)` | class | One `when` pair; `str()` is its label or condition name |
|
|
363
|
+
| `Otherwise(value)` | class | What `decide` returns when no branch matches |
|
|
364
|
+
| `FALLBACK_LABEL` | constant | `"otherwise"`, what `explain` returns for the fallback |
|
|
365
|
+
| `Condition[T]`, `Value[T, V]` | type | `Callable[[T], Any]`, `Callable[[T], V]` |
|
|
366
|
+
| `RulesetCompiler(conditions, values, /)` | class | `compile(definition) -> Ruleset` |
|
|
367
|
+
| `Compiler[F]` | protocol | Anything with `compile(definition, /) -> F` |
|
|
368
|
+
| `RulesetDict`, `BranchDict` | type | `TypedDict`s of the JSON shape |
|
|
369
|
+
| `get_ruleset_json_schema(condition_schema, value_schema, /) -> dict` | function | JSON Schema of a definition |
|
|
370
|
+
| `PyrulesetError` | exception | Base class of pyruleset's errors |
|
|
371
|
+
| `InvalidRulesetDefinitionError` | exception | Wrong definition shape; also a `ValueError` |
|
|
372
|
+
|
|
373
|
+
## Development
|
|
374
|
+
|
|
375
|
+
```bash
|
|
376
|
+
uv sync
|
|
377
|
+
uv run pytest --cov --cov-branch # tests, doctests in src/ and this README, examples
|
|
378
|
+
uv run --python 3.13 pytest
|
|
379
|
+
uvx ruff check . && uvx ruff format --check .
|
|
380
|
+
uvx pyright src
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
## License
|
|
384
|
+
|
|
385
|
+
GPL-3.0-only.
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# pyruleset
|
|
2
|
+
|
|
3
|
+
Conditional values that compose with `|` and can be defined as JSON: the first branch whose
|
|
4
|
+
condition matches decides.
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
price = (
|
|
8
|
+
when(tier__eq("gold"), subtotal * Decimal("0.80"), label="gold 20%")
|
|
9
|
+
| when(items__ge(10), subtotal * Decimal("0.95"), label="bulk 5%")
|
|
10
|
+
| otherwise(subtotal)
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
price(order) # Decimal('142.5000')
|
|
14
|
+
price.explain(order) # 'bulk 5%'
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Conditions are any `T -> bool` callable, such as a
|
|
18
|
+
[pyspecification](https://github.com/aaltatan/pyspecification) `Predicate`. Values are any
|
|
19
|
+
`T -> V` callable, such as a [pyformula](https://github.com/aaltatan/pyformula) `Formula`, a
|
|
20
|
+
[pyflowstep](https://github.com/aaltatan/pyflowstep) `Flow`, or a plain function.
|
|
21
|
+
|
|
22
|
+
| Piece | What it does |
|
|
23
|
+
| --- | --- |
|
|
24
|
+
| [`when`, `otherwise`](#quick-start) | Build branches and the fallback, chained with `\|` |
|
|
25
|
+
| [`Ruleset`](#deciding-and-explaining) | Callable: returns the value of the first matching branch; `explain` and `decide` say which one |
|
|
26
|
+
| [`PartialRuleset`](#a-missing-fallback-is-a-type-error) | Branches without a fallback: composes, but is not callable |
|
|
27
|
+
| [`RulesetCompiler`](#from-json) | Builds a `Ruleset` from JSON, delegating `when` and `then` to compilers you inject |
|
|
28
|
+
| [`get_ruleset_json_schema`](#json-schema) | The JSON Schema of that JSON, embedding your condition and value schemas |
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
pyruleset is not on PyPI yet. Install it from GitHub:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install git+https://github.com/aaltatan/pyruleset
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
or with uv:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
uv add git+https://github.com/aaltatan/pyruleset
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Python 3.12+. The only runtime dependency is
|
|
45
|
+
[pyvocabulary](https://github.com/aaltatan/pyvocabulary). pyspecification, pyformula and
|
|
46
|
+
pyflowstep are optional: pyruleset only needs callables.
|
|
47
|
+
|
|
48
|
+
## Quick start
|
|
49
|
+
|
|
50
|
+
The examples in this README build on each other and run as doctests.
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
>>> from dataclasses import dataclass
|
|
54
|
+
>>> from decimal import Decimal
|
|
55
|
+
>>> from pyformula import variable
|
|
56
|
+
>>> from pyspecification import object_rule
|
|
57
|
+
>>> from pyruleset import otherwise, when
|
|
58
|
+
|
|
59
|
+
>>> @dataclass(frozen=True)
|
|
60
|
+
... class Order:
|
|
61
|
+
... tier: str
|
|
62
|
+
... subtotal: Decimal
|
|
63
|
+
... items: int
|
|
64
|
+
|
|
65
|
+
>>> @object_rule()
|
|
66
|
+
... def tier__eq(order: Order, tier: str) -> bool:
|
|
67
|
+
... return order.tier == tier
|
|
68
|
+
|
|
69
|
+
>>> @object_rule()
|
|
70
|
+
... def items__ge(order: Order, count: int) -> bool:
|
|
71
|
+
... return order.items >= count
|
|
72
|
+
|
|
73
|
+
>>> @variable()
|
|
74
|
+
... def subtotal(order: Order) -> Decimal:
|
|
75
|
+
... return order.subtotal
|
|
76
|
+
|
|
77
|
+
>>> price = (
|
|
78
|
+
... when(tier__eq("gold"), subtotal * Decimal("0.80"), label="gold 20%")
|
|
79
|
+
... | when(items__ge(10), subtotal * Decimal("0.95"), label="bulk 5%")
|
|
80
|
+
... | otherwise(subtotal)
|
|
81
|
+
... )
|
|
82
|
+
>>> price
|
|
83
|
+
Ruleset(gold 20% | bulk 5% | otherwise)
|
|
84
|
+
|
|
85
|
+
>>> order = Order("regular", Decimal("150.00"), 12)
|
|
86
|
+
>>> price(order)
|
|
87
|
+
Decimal('142.5000')
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Branches are tried in order and the first match wins. Conditions after the match are not
|
|
92
|
+
evaluated. When nothing matches, the `otherwise` value is used.
|
|
93
|
+
|
|
94
|
+
## Deciding and explaining
|
|
95
|
+
|
|
96
|
+
`explain(subject)` names the branch that decided, e.g. for an audit log: its label, else the
|
|
97
|
+
condition's name, else `"otherwise"`. `decide(subject)` returns the `Branch` (or `Otherwise`)
|
|
98
|
+
itself.
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
>>> price.explain(order)
|
|
102
|
+
'bulk 5%'
|
|
103
|
+
>>> price.explain(Order("regular", Decimal("60.00"), 1))
|
|
104
|
+
'otherwise'
|
|
105
|
+
>>> price.decide(order)
|
|
106
|
+
Branch(condition=..., label='bulk 5%')
|
|
107
|
+
|
|
108
|
+
>>> unlabelled = when(items__ge(10), subtotal) | otherwise(subtotal)
|
|
109
|
+
>>> unlabelled.explain(order), unlabelled
|
|
110
|
+
('items__ge', Ruleset(items__ge | otherwise))
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
A condition can return any value: truthy counts as a match.
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
>>> size = when(len, lambda items: f"{len(items)} items") | otherwise(lambda _: "empty")
|
|
118
|
+
>>> size([1, 2]), size([])
|
|
119
|
+
('2 items', 'empty')
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## A missing fallback is a type error
|
|
124
|
+
|
|
125
|
+
`when(...) | when(...)` is a `PartialRuleset`. It composes, but it has no `__call__`, so a
|
|
126
|
+
type checker flags a call to it, and at run time it fails instead of returning `None`. Only
|
|
127
|
+
`| otherwise(...)` (or `| ruleset`) makes it callable.
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
>>> partial = when(tier__eq("gold"), subtotal * Decimal("0.80"), label="gold")
|
|
131
|
+
>>> partial
|
|
132
|
+
PartialRuleset(gold)
|
|
133
|
+
>>> partial(order)
|
|
134
|
+
Traceback (most recent call last):
|
|
135
|
+
...
|
|
136
|
+
TypeError: 'PartialRuleset' object is not callable
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
A `Ruleset` is closed: it has no `|`, so nothing can be added after its fallback by accident.
|
|
141
|
+
|
|
142
|
+
## Overrides on top of a base policy
|
|
143
|
+
|
|
144
|
+
`partial | ruleset` puts the partial's branches first. The base policy stays unchanged, and
|
|
145
|
+
the override is easy to drop later.
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
>>> black_friday = when(items__ge(5), subtotal * Decimal("0.70"), label="black friday")
|
|
149
|
+
>>> today = black_friday | price
|
|
150
|
+
>>> today
|
|
151
|
+
Ruleset(black friday | gold 20% | bulk 5% | otherwise)
|
|
152
|
+
>>> today(order), price(order)
|
|
153
|
+
(Decimal('105.0000'), Decimal('142.5000'))
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Every operator returns a new object; rulesets are frozen dataclasses.
|
|
158
|
+
|
|
159
|
+
## Any callable as a value
|
|
160
|
+
|
|
161
|
+
Values do not have to be formulas. A pyflowstep `Flow` turns the ruleset into "pick a
|
|
162
|
+
procedure and run it":
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
>>> from pyflowstep import step
|
|
166
|
+
>>> @step
|
|
167
|
+
... def add(total: int, amount: int) -> int:
|
|
168
|
+
... return total + amount
|
|
169
|
+
|
|
170
|
+
>>> bonus = when(lambda n: n >= 100, add(10) >> add(5), label="big") | otherwise(add(0))
|
|
171
|
+
>>> bonus(100), bonus(7)
|
|
172
|
+
(115, 7)
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## From JSON
|
|
177
|
+
|
|
178
|
+
A definition has `branches` (optional) and `otherwise` (required). Each branch has `when`,
|
|
179
|
+
`then` and an optional `label`:
|
|
180
|
+
|
|
181
|
+
```json
|
|
182
|
+
{
|
|
183
|
+
"branches": [
|
|
184
|
+
{"label": "gold 20%",
|
|
185
|
+
"when": {"name": "tier__eq", "args": ["gold"], "kwargs": {}, "inverse": false},
|
|
186
|
+
"then": {"operator": "multiply", "expressions": ["subtotal", 0.80]}}
|
|
187
|
+
],
|
|
188
|
+
"otherwise": "subtotal"
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
pyruleset does not know the languages inside `when` and `then`. `RulesetCompiler` delegates
|
|
193
|
+
them to the compilers you pass in: anything with a `compile(definition)` method, such as
|
|
194
|
+
pyspecification's `PredicateCompiler` and pyformula's `FormulaCompiler`.
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
>>> import json
|
|
198
|
+
>>> from pyformula import FormulaCompiler
|
|
199
|
+
>>> from pyspecification import Predicate, PredicateCompiler
|
|
200
|
+
>>> from pyruleset import RulesetCompiler
|
|
201
|
+
|
|
202
|
+
>>> rules = {"tier__eq": tier__eq, "items__ge": items__ge}
|
|
203
|
+
>>> compiler = RulesetCompiler(
|
|
204
|
+
... PredicateCompiler(
|
|
205
|
+
... rules,
|
|
206
|
+
... lambda wrapper: Predicate(lambda _: wrapper["operator"] == "all", operator="logical"),
|
|
207
|
+
... ),
|
|
208
|
+
... FormulaCompiler({"subtotal": subtotal}),
|
|
209
|
+
... )
|
|
210
|
+
|
|
211
|
+
>>> stored = '''
|
|
212
|
+
... {
|
|
213
|
+
... "branches": [
|
|
214
|
+
... {"label": "gold 20%",
|
|
215
|
+
... "when": {"name": "tier__eq", "args": ["gold"], "kwargs": {}, "inverse": false},
|
|
216
|
+
... "then": {"operator": "multiply", "expressions": ["subtotal", 0.80]}}
|
|
217
|
+
... ],
|
|
218
|
+
... "otherwise": "subtotal"
|
|
219
|
+
... }
|
|
220
|
+
... '''
|
|
221
|
+
>>> policy = compiler.compile(json.loads(stored, parse_float=Decimal))
|
|
222
|
+
>>> policy
|
|
223
|
+
Ruleset(gold 20% | otherwise)
|
|
224
|
+
>>> policy(Order("gold", Decimal("120.00"), 2))
|
|
225
|
+
Decimal('96.0000')
|
|
226
|
+
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Errors
|
|
230
|
+
|
|
231
|
+
The whole definition is checked when it is compiled, before any subject is evaluated.
|
|
232
|
+
|
|
233
|
+
A wrong shape raises `InvalidRulesetDefinitionError` (also a `ValueError`) with its JSON
|
|
234
|
+
path:
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
>>> compiler.compile({"branches": []})
|
|
238
|
+
Traceback (most recent call last):
|
|
239
|
+
...
|
|
240
|
+
pyruleset.exceptions.InvalidRulesetDefinitionError: Invalid ruleset at $: missing 'otherwise'
|
|
241
|
+
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Errors from the injected compilers keep their own type. pyruleset only adds a note with the
|
|
245
|
+
full JSON path, nested paths included (e.g. `at $.branches[1].when.expressions[0]`):
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
>>> from pyspecification import RuleDoesNotExistError
|
|
249
|
+
>>> typo = json.loads(stored.replace("tier__eq", "tier__is"), parse_float=Decimal)
|
|
250
|
+
>>> try:
|
|
251
|
+
... compiler.compile(typo)
|
|
252
|
+
... except RuleDoesNotExistError as error:
|
|
253
|
+
... print(f"{type(error).__name__}: {error}", error.__notes__)
|
|
254
|
+
RuleDoesNotExistError: Rule 'tier__is' does not exist. Available rules: tier__eq, items__ge ['at $.branches[0].when']
|
|
255
|
+
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
## Money
|
|
259
|
+
|
|
260
|
+
`json.loads` turns `0.80` into a binary float, which then leaks into every `Decimal` it
|
|
261
|
+
touches. Keep money exact in one of two ways:
|
|
262
|
+
|
|
263
|
+
- Parse with `json.loads(text, parse_float=Decimal)`, as above, so every JSON number with a
|
|
264
|
+
decimal point becomes a `Decimal` before it reaches the compiler.
|
|
265
|
+
- Or, with pyformula 0.2+, build the value compiler with
|
|
266
|
+
`FormulaCompiler(variables, literal=decimal_literal)`, which converts each number literal
|
|
267
|
+
through `str` (`Decimal("0.8")`, not `Decimal(0.8)`), whoever parsed the JSON.
|
|
268
|
+
|
|
269
|
+
## JSON Schema
|
|
270
|
+
|
|
271
|
+
`get_ruleset_json_schema(condition_schema, value_schema)` describes a definition, e.g. to
|
|
272
|
+
validate an admin form or to get completion in an editor. The two schemas describe one
|
|
273
|
+
`when` and one `then`/`otherwise`. Each is embedded under its own `$id`, so the
|
|
274
|
+
`#/$defs/...` refs inside it keep pointing into itself.
|
|
275
|
+
|
|
276
|
+
```python
|
|
277
|
+
>>> from jsonschema import Draft202012Validator
|
|
278
|
+
>>> from pyspecification import get_expression_json_schema
|
|
279
|
+
>>> from pyruleset import get_ruleset_json_schema
|
|
280
|
+
|
|
281
|
+
>>> formula_schema = {"anyOf": [{"const": "subtotal"}, {"type": "number"}, {"type": "object"}]}
|
|
282
|
+
>>> schema = get_ruleset_json_schema(get_expression_json_schema(rules), formula_schema)
|
|
283
|
+
>>> validator = Draft202012Validator(schema)
|
|
284
|
+
>>> validator.is_valid(json.loads(stored))
|
|
285
|
+
True
|
|
286
|
+
>>> validator.is_valid(json.loads(stored.replace("tier__eq", "tier__is")))
|
|
287
|
+
False
|
|
288
|
+
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
`jsonschema` is only used here to show the schema at work; pyruleset does not depend on it.
|
|
292
|
+
|
|
293
|
+
## First match only
|
|
294
|
+
|
|
295
|
+
A ruleset picks exactly one value. There is no "all matches" mode, on purpose: each ruleset
|
|
296
|
+
stays easy to read and to explain. To stack several decisions, give each part its own
|
|
297
|
+
ruleset and add the results:
|
|
298
|
+
|
|
299
|
+
```python
|
|
300
|
+
>>> shipping = (
|
|
301
|
+
... when(items__ge(10), lambda _: Decimal("0.00"), label="free shipping")
|
|
302
|
+
... | otherwise(lambda _: Decimal("5.00"))
|
|
303
|
+
... )
|
|
304
|
+
>>> def total(order: Order) -> Decimal:
|
|
305
|
+
... return price(order) + shipping(order)
|
|
306
|
+
>>> small = Order("regular", Decimal("60.00"), 1)
|
|
307
|
+
>>> total(small), [price.explain(small), shipping.explain(small)]
|
|
308
|
+
(Decimal('65.00'), ['otherwise', 'otherwise'])
|
|
309
|
+
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
Progressive brackets (tax bands) are not a ruleset either: they are a sum of clamped terms,
|
|
313
|
+
one per band.
|
|
314
|
+
|
|
315
|
+
## Examples
|
|
316
|
+
|
|
317
|
+
Runnable scripts in [`examples/`](examples), each asserting its own results:
|
|
318
|
+
|
|
319
|
+
- [`pricing_policy.py`](examples/pricing_policy.py): a JSON policy from an admin panel,
|
|
320
|
+
rejected on save with the JSON path of a typo, and `explain` for the audit log.
|
|
321
|
+
- [`expense_approvals.py`](examples/expense_approvals.py): who approves an expense, with a
|
|
322
|
+
temporary "CFO on leave" override on top of the base policy.
|
|
323
|
+
- [`ticket_routing.py`](examples/ticket_routing.py): each branch's value is a pyflowstep
|
|
324
|
+
`Flow` that assigns, prioritizes and tags the ticket.
|
|
325
|
+
|
|
326
|
+
```bash
|
|
327
|
+
uv run python examples/pricing_policy.py
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
## API reference
|
|
331
|
+
|
|
332
|
+
| Name | Kind | Summary |
|
|
333
|
+
| --- | --- | --- |
|
|
334
|
+
| `when(condition, value, /, *, label=None) -> PartialRuleset` | function | One branch; chain more with `\|` |
|
|
335
|
+
| `otherwise(value, /) -> Otherwise` | function | The fallback that closes a ruleset |
|
|
336
|
+
| `Ruleset(branches, fallback)` | class | `__call__(subject)`, `decide(subject)`, `explain(subject)` |
|
|
337
|
+
| `PartialRuleset(branches)` | class | `\|` with a partial, an `Otherwise` or a `Ruleset`; not callable |
|
|
338
|
+
| `Branch(condition, value, label=None)` | class | One `when` pair; `str()` is its label or condition name |
|
|
339
|
+
| `Otherwise(value)` | class | What `decide` returns when no branch matches |
|
|
340
|
+
| `FALLBACK_LABEL` | constant | `"otherwise"`, what `explain` returns for the fallback |
|
|
341
|
+
| `Condition[T]`, `Value[T, V]` | type | `Callable[[T], Any]`, `Callable[[T], V]` |
|
|
342
|
+
| `RulesetCompiler(conditions, values, /)` | class | `compile(definition) -> Ruleset` |
|
|
343
|
+
| `Compiler[F]` | protocol | Anything with `compile(definition, /) -> F` |
|
|
344
|
+
| `RulesetDict`, `BranchDict` | type | `TypedDict`s of the JSON shape |
|
|
345
|
+
| `get_ruleset_json_schema(condition_schema, value_schema, /) -> dict` | function | JSON Schema of a definition |
|
|
346
|
+
| `PyrulesetError` | exception | Base class of pyruleset's errors |
|
|
347
|
+
| `InvalidRulesetDefinitionError` | exception | Wrong definition shape; also a `ValueError` |
|
|
348
|
+
|
|
349
|
+
## Development
|
|
350
|
+
|
|
351
|
+
```bash
|
|
352
|
+
uv sync
|
|
353
|
+
uv run pytest --cov --cov-branch # tests, doctests in src/ and this README, examples
|
|
354
|
+
uv run --python 3.13 pytest
|
|
355
|
+
uvx ruff check . && uvx ruff format --check .
|
|
356
|
+
uvx pyright src
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
## License
|
|
360
|
+
|
|
361
|
+
GPL-3.0-only.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pyruleset"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Conditional values: pick a value by the first matching condition, composable with | and definable as JSON."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "GPL-3.0-only"
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
|
+
dependencies = ["pyvocabulary>=0.1.0"]
|
|
9
|
+
keywords = [
|
|
10
|
+
"rules",
|
|
11
|
+
"decision-table",
|
|
12
|
+
"policy",
|
|
13
|
+
"conditional",
|
|
14
|
+
"json",
|
|
15
|
+
"dsl",
|
|
16
|
+
"composition",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 3 - Alpha",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
27
|
+
"Typing :: Typed",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[[project.authors]]
|
|
31
|
+
name = "aaltatan"
|
|
32
|
+
email = "a.altatan@gmail.com"
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/aaltatan/pyruleset"
|
|
36
|
+
Repository = "https://github.com/aaltatan/pyruleset"
|
|
37
|
+
Issues = "https://github.com/aaltatan/pyruleset/issues"
|
|
38
|
+
|
|
39
|
+
[build-system]
|
|
40
|
+
requires = ["uv_build>=0.11.32,<0.12.0"]
|
|
41
|
+
build-backend = "uv_build"
|
|
42
|
+
|
|
43
|
+
[dependency-groups]
|
|
44
|
+
dev = [
|
|
45
|
+
"jsonschema>=4.26.0",
|
|
46
|
+
"pyflowstep>=0.6.0",
|
|
47
|
+
"pyformula>=0.1.1",
|
|
48
|
+
"pyspecification>=2.0.2",
|
|
49
|
+
"pytest>=9.1.1",
|
|
50
|
+
"pytest-cov>=6.0",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
[tool.pytest.ini_options]
|
|
54
|
+
testpaths = [
|
|
55
|
+
"tests",
|
|
56
|
+
"src",
|
|
57
|
+
"README.md",
|
|
58
|
+
]
|
|
59
|
+
pythonpath = ["."]
|
|
60
|
+
addopts = [
|
|
61
|
+
"--doctest-modules",
|
|
62
|
+
"--doctest-glob=README.md",
|
|
63
|
+
"--strict-markers",
|
|
64
|
+
]
|
|
65
|
+
doctest_optionflags = [
|
|
66
|
+
"ELLIPSIS",
|
|
67
|
+
"NORMALIZE_WHITESPACE",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
[tool.coverage.run]
|
|
71
|
+
source = ["pyruleset"]
|
|
72
|
+
branch = true
|
|
73
|
+
|
|
74
|
+
[tool.coverage.report]
|
|
75
|
+
show_missing = true
|
|
76
|
+
exclude_also = [
|
|
77
|
+
"@overload",
|
|
78
|
+
"if TYPE_CHECKING:",
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
[tool.ruff]
|
|
82
|
+
line-length = 100
|
|
83
|
+
target-version = "py312"
|
|
84
|
+
|
|
85
|
+
[tool.ruff.lint]
|
|
86
|
+
select = ["ALL"]
|
|
87
|
+
ignore = [
|
|
88
|
+
"COM812",
|
|
89
|
+
"CPY001",
|
|
90
|
+
"D100",
|
|
91
|
+
"D101",
|
|
92
|
+
"D102",
|
|
93
|
+
"D103",
|
|
94
|
+
"D104",
|
|
95
|
+
"D105",
|
|
96
|
+
"D107",
|
|
97
|
+
"D203",
|
|
98
|
+
"D213",
|
|
99
|
+
"ANN401",
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
[tool.ruff.lint.per-file-ignores]
|
|
103
|
+
"tests/**" = [
|
|
104
|
+
"S101",
|
|
105
|
+
"PLR2004",
|
|
106
|
+
"ANN",
|
|
107
|
+
"ARG",
|
|
108
|
+
"INP001",
|
|
109
|
+
"E501",
|
|
110
|
+
]
|
|
111
|
+
"examples/**" = [
|
|
112
|
+
"S101",
|
|
113
|
+
"T201",
|
|
114
|
+
"INP001",
|
|
115
|
+
"PT017",
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
[tool.uv.sources.pyvocabulary]
|
|
119
|
+
path = "../pyvocabulary"
|
|
120
|
+
editable = true
|
|
121
|
+
|
|
122
|
+
[tool.pyright]
|
|
123
|
+
venvPath = "."
|
|
124
|
+
venv = ".venv"
|
|
125
|
+
typeCheckingMode = "strict"
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pyruleset"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Conditional values: pick a value by the first matching condition, composable with | and definable as JSON."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "aaltatan", email = "a.altatan@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
license = "GPL-3.0-only"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"pyvocabulary>=0.1.0",
|
|
13
|
+
]
|
|
14
|
+
keywords = ["rules", "decision-table", "policy", "conditional", "json", "dsl", "composition"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/aaltatan/pyruleset"
|
|
29
|
+
Repository = "https://github.com/aaltatan/pyruleset"
|
|
30
|
+
Issues = "https://github.com/aaltatan/pyruleset/issues"
|
|
31
|
+
|
|
32
|
+
[build-system]
|
|
33
|
+
requires = ["uv_build>=0.11.32,<0.12.0"]
|
|
34
|
+
build-backend = "uv_build"
|
|
35
|
+
|
|
36
|
+
[dependency-groups]
|
|
37
|
+
dev = [
|
|
38
|
+
"jsonschema>=4.26.0",
|
|
39
|
+
"pyflowstep>=0.6.0",
|
|
40
|
+
"pyformula>=0.1.1",
|
|
41
|
+
"pyspecification>=2.0.2",
|
|
42
|
+
"pytest>=9.1.1",
|
|
43
|
+
"pytest-cov>=6.0",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
testpaths = ["tests", "src", "README.md"]
|
|
48
|
+
pythonpath = ["."]
|
|
49
|
+
addopts = ["--doctest-modules", "--doctest-glob=README.md", "--strict-markers"]
|
|
50
|
+
doctest_optionflags = ["ELLIPSIS", "NORMALIZE_WHITESPACE"]
|
|
51
|
+
|
|
52
|
+
[tool.coverage.run]
|
|
53
|
+
source = ["pyruleset"]
|
|
54
|
+
branch = true
|
|
55
|
+
|
|
56
|
+
[tool.coverage.report]
|
|
57
|
+
show_missing = true
|
|
58
|
+
exclude_also = ["@overload", "if TYPE_CHECKING:"]
|
|
59
|
+
|
|
60
|
+
[tool.ruff]
|
|
61
|
+
line-length = 100
|
|
62
|
+
target-version = "py312"
|
|
63
|
+
|
|
64
|
+
[tool.ruff.lint]
|
|
65
|
+
select = ["ALL"]
|
|
66
|
+
ignore = [
|
|
67
|
+
"COM812", # conflicts with the formatter
|
|
68
|
+
"CPY001", # no per-file copyright headers
|
|
69
|
+
"D100", "D101", "D102", "D103", "D104", "D105", "D107", "D203", "D213",
|
|
70
|
+
"ANN401", # JSON definitions and condition results genuinely are `Any`
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
[tool.ruff.lint.per-file-ignores]
|
|
74
|
+
"tests/**" = ["S101", "PLR2004", "ANN", "ARG", "INP001", "E501"]
|
|
75
|
+
"examples/**" = ["S101", "T201", "INP001", "PT017"] # plain scripts that print and assert
|
|
76
|
+
|
|
77
|
+
[tool.uv.sources]
|
|
78
|
+
# the sibling checkout in ~/dev until pyvocabulary is published on PyPI
|
|
79
|
+
pyvocabulary = { path = "../pyvocabulary", editable = true }
|
|
80
|
+
|
|
81
|
+
[tool.pyright]
|
|
82
|
+
venvPath = "."
|
|
83
|
+
venv = ".venv"
|
|
84
|
+
typeCheckingMode = "strict"
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""pyruleset: conditional values that are composable and definable as JSON.
|
|
2
|
+
|
|
3
|
+
Pick a value by the first condition that matches. Conditions are any
|
|
4
|
+
`T -> bool` callable (a pyspecification `Predicate` fits), values any
|
|
5
|
+
`T -> V` callable (a pyformula `Formula` or a pyflowstep `Flow` fits).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .compilers import BranchDict, Compiler, RulesetCompiler, RulesetDict
|
|
9
|
+
from .exceptions import InvalidRulesetDefinitionError, PyrulesetError
|
|
10
|
+
from .json_schema import get_ruleset_json_schema
|
|
11
|
+
from .ruleset import (
|
|
12
|
+
FALLBACK_LABEL,
|
|
13
|
+
Branch,
|
|
14
|
+
Condition,
|
|
15
|
+
Otherwise,
|
|
16
|
+
PartialRuleset,
|
|
17
|
+
Ruleset,
|
|
18
|
+
Value,
|
|
19
|
+
otherwise,
|
|
20
|
+
when,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"FALLBACK_LABEL",
|
|
25
|
+
"Branch",
|
|
26
|
+
"BranchDict",
|
|
27
|
+
"Compiler",
|
|
28
|
+
"Condition",
|
|
29
|
+
"InvalidRulesetDefinitionError",
|
|
30
|
+
"Otherwise",
|
|
31
|
+
"PartialRuleset",
|
|
32
|
+
"PyrulesetError",
|
|
33
|
+
"Ruleset",
|
|
34
|
+
"RulesetCompiler",
|
|
35
|
+
"RulesetDict",
|
|
36
|
+
"Value",
|
|
37
|
+
"get_ruleset_json_schema",
|
|
38
|
+
"otherwise",
|
|
39
|
+
"when",
|
|
40
|
+
]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Compile a ruleset from JSON: `{"branches": [{"when": ..., "then": ...}], "otherwise": ...}`.
|
|
2
|
+
|
|
3
|
+
pyruleset does not know the expression languages inside `when` and `then`. Both
|
|
4
|
+
are delegated to injected compilers, e.g. pyspecification's `PredicateCompiler`
|
|
5
|
+
for conditions and pyformula's `FormulaCompiler` for values.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from typing import Any, NotRequired, Protocol, TypedDict, cast
|
|
10
|
+
|
|
11
|
+
from pyvocabulary import index_path, key_path, located
|
|
12
|
+
|
|
13
|
+
from .exceptions import InvalidRulesetDefinitionError
|
|
14
|
+
from .ruleset import Branch, Condition, Ruleset, Value
|
|
15
|
+
|
|
16
|
+
_BRANCH_KEYS = {"when", "then", "label"}
|
|
17
|
+
_RULESET_KEYS = {"branches", "otherwise"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BranchDict(TypedDict):
|
|
21
|
+
when: Any
|
|
22
|
+
then: Any
|
|
23
|
+
label: NotRequired[str]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RulesetDict(TypedDict):
|
|
27
|
+
branches: list[BranchDict]
|
|
28
|
+
otherwise: Any
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Compiler[F](Protocol):
|
|
32
|
+
"""Anything with a `compile(definition) -> F` method."""
|
|
33
|
+
|
|
34
|
+
def compile(self, definition: Any, /) -> F: ...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RulesetCompiler[T, V]:
|
|
38
|
+
"""Compile ruleset definitions, delegating `when` and `then` to injected compilers.
|
|
39
|
+
|
|
40
|
+
Every problem is reported here, before any subject is evaluated. Errors from
|
|
41
|
+
the injected compilers keep their type and get a note with the full JSON path.
|
|
42
|
+
|
|
43
|
+
Example:
|
|
44
|
+
```python
|
|
45
|
+
>>> class Field:
|
|
46
|
+
... def compile(self, name):
|
|
47
|
+
... return lambda subject: subject[name]
|
|
48
|
+
>>> compiler = RulesetCompiler(Field(), Field())
|
|
49
|
+
>>> ruleset = compiler.compile({
|
|
50
|
+
... "branches": [{"when": "vip", "then": "vip_price", "label": "vip"}],
|
|
51
|
+
... "otherwise": "price",
|
|
52
|
+
... })
|
|
53
|
+
>>> ruleset
|
|
54
|
+
Ruleset(vip | otherwise)
|
|
55
|
+
>>> ruleset({"vip": True, "vip_price": 80, "price": 100})
|
|
56
|
+
80
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
__slots__ = ("_conditions", "_values")
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self, conditions: Compiler[Condition[T]], values: Compiler[Value[T, V]], /
|
|
66
|
+
) -> None:
|
|
67
|
+
self._conditions = conditions
|
|
68
|
+
self._values = values
|
|
69
|
+
|
|
70
|
+
def compile(self, definition: RulesetDict | Mapping[str, Any], /) -> Ruleset[T, V]:
|
|
71
|
+
"""Compile `definition` into a `Ruleset`.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
InvalidRulesetDefinitionError: If the definition has the wrong shape.
|
|
75
|
+
|
|
76
|
+
"""
|
|
77
|
+
_check_keys(definition, required={"otherwise"}, allowed=_RULESET_KEYS, path="$")
|
|
78
|
+
branches = definition.get("branches", [])
|
|
79
|
+
if not isinstance(branches, list):
|
|
80
|
+
msg = f"Invalid ruleset at $.branches: expected a list, received {branches!r}"
|
|
81
|
+
raise InvalidRulesetDefinitionError(msg)
|
|
82
|
+
|
|
83
|
+
path = key_path("$", "branches")
|
|
84
|
+
compiled = tuple(
|
|
85
|
+
self._compile_branch(branch, index_path(path, index))
|
|
86
|
+
for index, branch in enumerate(cast("list[Any]", branches))
|
|
87
|
+
)
|
|
88
|
+
with located(key_path("$", "otherwise"), Exception):
|
|
89
|
+
fallback = self._values.compile(definition["otherwise"])
|
|
90
|
+
|
|
91
|
+
return Ruleset(compiled, fallback)
|
|
92
|
+
|
|
93
|
+
def _compile_branch(self, branch: Any, path: str) -> Branch[T, V]:
|
|
94
|
+
_check_keys(branch, required={"when", "then"}, allowed=_BRANCH_KEYS, path=path)
|
|
95
|
+
|
|
96
|
+
label = branch.get("label")
|
|
97
|
+
if label is not None and not isinstance(label, str):
|
|
98
|
+
msg = f"Invalid branch at {path}.label: expected a string, received {label!r}"
|
|
99
|
+
raise InvalidRulesetDefinitionError(msg)
|
|
100
|
+
|
|
101
|
+
with located(key_path(path, "when"), Exception):
|
|
102
|
+
condition = self._conditions.compile(branch["when"])
|
|
103
|
+
with located(key_path(path, "then"), Exception):
|
|
104
|
+
value = self._values.compile(branch["then"])
|
|
105
|
+
|
|
106
|
+
return Branch(condition, value, label)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _check_keys(item: Any, *, required: set[str], allowed: set[str], path: str) -> None:
|
|
110
|
+
if not isinstance(item, Mapping):
|
|
111
|
+
msg = f"Invalid ruleset at {path}: expected an object, received {item!r}"
|
|
112
|
+
raise InvalidRulesetDefinitionError(msg)
|
|
113
|
+
|
|
114
|
+
keys = set(cast("Mapping[str, Any]", item))
|
|
115
|
+
if missing := sorted(required - keys):
|
|
116
|
+
msg = f"Invalid ruleset at {path}: missing {', '.join(map(repr, missing))}"
|
|
117
|
+
raise InvalidRulesetDefinitionError(msg)
|
|
118
|
+
|
|
119
|
+
if unknown := sorted(keys - allowed):
|
|
120
|
+
msg = (
|
|
121
|
+
f"Invalid ruleset at {path}: unknown {', '.join(map(repr, unknown))}. "
|
|
122
|
+
f"Allowed keys: {', '.join(sorted(allowed))}"
|
|
123
|
+
)
|
|
124
|
+
raise InvalidRulesetDefinitionError(msg)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Exceptions raised by pyruleset.
|
|
2
|
+
|
|
3
|
+
Errors raised by the injected condition and value compilers keep their own type
|
|
4
|
+
(e.g. pyspecification's `RuleDoesNotExistError`); pyruleset only adds a note
|
|
5
|
+
with their full JSON path, e.g. `at $.branches[1].when.expressions[0]`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PyrulesetError(Exception):
|
|
10
|
+
"""Base class for every exception raised by pyruleset."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class InvalidRulesetDefinitionError(PyrulesetError, ValueError):
|
|
14
|
+
"""Raised when a ruleset definition does not have the expected shape."""
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""The JSON Schema of a ruleset definition, as accepted by `RulesetCompiler.compile`.
|
|
2
|
+
|
|
3
|
+
pyruleset does not know the languages inside `when` and `then`, so their schemas
|
|
4
|
+
are passed in, e.g. pyspecification's `get_expression_json_schema(rules)`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pyvocabulary import JSON_SCHEMA_DIALECT
|
|
11
|
+
|
|
12
|
+
CONDITION_SCHEMA_ID = "urn:pyruleset:condition"
|
|
13
|
+
VALUE_SCHEMA_ID = "urn:pyruleset:value"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_ruleset_json_schema(
|
|
17
|
+
condition_schema: Mapping[str, Any],
|
|
18
|
+
value_schema: Mapping[str, Any],
|
|
19
|
+
/,
|
|
20
|
+
) -> dict[str, Any]:
|
|
21
|
+
"""Return the JSON Schema of `{"branches": [{"when", "then", "label"}], "otherwise"}`.
|
|
22
|
+
|
|
23
|
+
Each sub-schema is embedded under its own `$id`, so the `#/$defs/...` refs it
|
|
24
|
+
uses internally keep pointing into itself rather than into the ruleset schema.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
condition_schema: The schema of one `when`, e.g. a pyspecification expression.
|
|
28
|
+
value_schema: The schema of one `then` and of `otherwise`.
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
```python
|
|
32
|
+
>>> schema = get_ruleset_json_schema({"type": "string"}, {"type": "number"})
|
|
33
|
+
>>> schema["required"], schema["properties"]["otherwise"]
|
|
34
|
+
(['otherwise'], {'$ref': 'urn:pyruleset:value'})
|
|
35
|
+
>>> schema["$defs"]["value"]
|
|
36
|
+
{'type': 'number', '$id': 'urn:pyruleset:value'}
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
"""
|
|
41
|
+
branch = {
|
|
42
|
+
"type": "object",
|
|
43
|
+
"properties": {
|
|
44
|
+
"when": {"$ref": CONDITION_SCHEMA_ID},
|
|
45
|
+
"then": {"$ref": VALUE_SCHEMA_ID},
|
|
46
|
+
"label": {"type": "string"},
|
|
47
|
+
},
|
|
48
|
+
"required": ["when", "then"],
|
|
49
|
+
"additionalProperties": False,
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
"$schema": JSON_SCHEMA_DIALECT,
|
|
53
|
+
"type": "object",
|
|
54
|
+
"properties": {
|
|
55
|
+
"branches": {"type": "array", "items": {"$ref": "#/$defs/branch"}},
|
|
56
|
+
"otherwise": {"$ref": VALUE_SCHEMA_ID},
|
|
57
|
+
},
|
|
58
|
+
"required": ["otherwise"],
|
|
59
|
+
"additionalProperties": False,
|
|
60
|
+
"$defs": {
|
|
61
|
+
"branch": branch,
|
|
62
|
+
"condition": {**condition_schema, "$id": CONDITION_SCHEMA_ID},
|
|
63
|
+
"value": {**value_schema, "$id": VALUE_SCHEMA_ID},
|
|
64
|
+
},
|
|
65
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Conditional values: the first branch whose condition matches decides.
|
|
2
|
+
|
|
3
|
+
```python
|
|
4
|
+
price = (
|
|
5
|
+
when(is_gold(), subtotal * Decimal("0.80"), label="gold 20%")
|
|
6
|
+
| when(items__ge(10), subtotal * Decimal("0.95"), label="bulk 5%")
|
|
7
|
+
| otherwise(subtotal)
|
|
8
|
+
)
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
A chain without `otherwise` is a `PartialRuleset`: it composes, but it has no
|
|
12
|
+
`__call__`, so a missing fallback is a type error instead of a `None` at run time.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any, overload
|
|
18
|
+
|
|
19
|
+
type Condition[T] = Callable[[T], Any]
|
|
20
|
+
type Value[T, V] = Callable[[T], V]
|
|
21
|
+
|
|
22
|
+
FALLBACK_LABEL = "otherwise"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class Branch[T, V]:
|
|
27
|
+
"""One `when(condition, value)` pair, optionally labelled for `explain` and `repr`."""
|
|
28
|
+
|
|
29
|
+
condition: Condition[T]
|
|
30
|
+
value: Value[T, V]
|
|
31
|
+
label: str | None = None
|
|
32
|
+
|
|
33
|
+
def __str__(self) -> str:
|
|
34
|
+
return self.label or _name_of(self.condition)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class Otherwise[T, V]:
|
|
39
|
+
"""The fallback that closes a ruleset. Build it with `otherwise(value)`."""
|
|
40
|
+
|
|
41
|
+
value: Value[T, V]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class Ruleset[T, V]:
|
|
46
|
+
"""Branches tried in order, then a fallback. Calling it returns the decided value.
|
|
47
|
+
|
|
48
|
+
Example:
|
|
49
|
+
```python
|
|
50
|
+
>>> sign = (
|
|
51
|
+
... when(lambda n: n < 0, lambda _: "negative", label="below zero")
|
|
52
|
+
... | when(lambda n: n == 0, lambda _: "zero", label="zero")
|
|
53
|
+
... | otherwise(lambda _: "positive")
|
|
54
|
+
... )
|
|
55
|
+
>>> sign
|
|
56
|
+
Ruleset(below zero | zero | otherwise)
|
|
57
|
+
>>> [sign(n) for n in (-2, 0, 5)]
|
|
58
|
+
['negative', 'zero', 'positive']
|
|
59
|
+
>>> sign.explain(-2), sign.explain(5)
|
|
60
|
+
('below zero', 'otherwise')
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
branches: tuple[Branch[T, V], ...]
|
|
67
|
+
fallback: Value[T, V]
|
|
68
|
+
|
|
69
|
+
def __call__(self, subject: T, /) -> V:
|
|
70
|
+
return self.decide(subject).value(subject)
|
|
71
|
+
|
|
72
|
+
def decide(self, subject: T, /) -> "Branch[T, V] | Otherwise[T, V]":
|
|
73
|
+
"""Return the branch that decides for `subject`, or the fallback."""
|
|
74
|
+
return next(
|
|
75
|
+
(branch for branch in self.branches if branch.condition(subject)),
|
|
76
|
+
Otherwise(self.fallback),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def explain(self, subject: T, /) -> str:
|
|
80
|
+
"""Name the branch that decides for `subject`, e.g. for an audit log."""
|
|
81
|
+
match self.decide(subject):
|
|
82
|
+
case Branch() as branch:
|
|
83
|
+
return str(branch)
|
|
84
|
+
case _:
|
|
85
|
+
return FALLBACK_LABEL
|
|
86
|
+
|
|
87
|
+
def __repr__(self) -> str:
|
|
88
|
+
return f"Ruleset({' | '.join([*map(str, self.branches), FALLBACK_LABEL])})"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True, slots=True)
|
|
92
|
+
class PartialRuleset[T, V]:
|
|
93
|
+
"""Branches without a fallback. It composes with `|`, but it cannot be called.
|
|
94
|
+
|
|
95
|
+
Example:
|
|
96
|
+
```python
|
|
97
|
+
>>> overrides = when(lambda n: n == 13, lambda _: "unlucky", label="13")
|
|
98
|
+
>>> overrides
|
|
99
|
+
PartialRuleset(13)
|
|
100
|
+
>>> callable(overrides)
|
|
101
|
+
False
|
|
102
|
+
>>> base = when(lambda n: n % 2 == 0, lambda _: "even", label="even") | otherwise(
|
|
103
|
+
... lambda _: "odd"
|
|
104
|
+
... )
|
|
105
|
+
>>> combined = overrides | base
|
|
106
|
+
>>> combined, combined(13), combined(4)
|
|
107
|
+
(Ruleset(13 | even | otherwise), 'unlucky', 'even')
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
branches: tuple[Branch[T, V], ...]
|
|
114
|
+
|
|
115
|
+
@overload
|
|
116
|
+
def __or__(self, other: "PartialRuleset[T, V]", /) -> "PartialRuleset[T, V]": ...
|
|
117
|
+
@overload
|
|
118
|
+
def __or__(self, other: Otherwise[T, V] | Ruleset[T, V], /) -> Ruleset[T, V]: ...
|
|
119
|
+
def __or__(
|
|
120
|
+
self,
|
|
121
|
+
other: "PartialRuleset[T, V] | Otherwise[T, V] | Ruleset[T, V]",
|
|
122
|
+
/,
|
|
123
|
+
) -> "PartialRuleset[T, V] | Ruleset[T, V]":
|
|
124
|
+
match other:
|
|
125
|
+
case PartialRuleset(branches=branches):
|
|
126
|
+
return PartialRuleset((*self.branches, *branches))
|
|
127
|
+
case Otherwise(value=value):
|
|
128
|
+
return Ruleset(self.branches, value)
|
|
129
|
+
case Ruleset(branches=branches, fallback=fallback):
|
|
130
|
+
return Ruleset((*self.branches, *branches), fallback)
|
|
131
|
+
case _:
|
|
132
|
+
return NotImplemented
|
|
133
|
+
|
|
134
|
+
def __repr__(self) -> str:
|
|
135
|
+
return f"PartialRuleset({' | '.join(map(str, self.branches))})"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def when[T, V](
|
|
139
|
+
condition: Condition[T],
|
|
140
|
+
value: Value[T, V],
|
|
141
|
+
/,
|
|
142
|
+
*,
|
|
143
|
+
label: str | None = None,
|
|
144
|
+
) -> PartialRuleset[T, V]:
|
|
145
|
+
"""Start a ruleset, or one more branch to chain with `|`.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
condition: Decides whether this branch applies, e.g. a pyspecification `Predicate`.
|
|
149
|
+
value: Computes the result, e.g. a pyformula `Formula` or a pyflowstep `Flow`.
|
|
150
|
+
label: Names the branch in `explain` and `repr`. Defaults to the condition's name.
|
|
151
|
+
|
|
152
|
+
"""
|
|
153
|
+
return PartialRuleset((Branch(condition, value, label),))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def otherwise[T, V](value: Value[T, V], /) -> Otherwise[T, V]:
|
|
157
|
+
"""Close a ruleset with the value used when no branch matches.
|
|
158
|
+
|
|
159
|
+
Example:
|
|
160
|
+
```python
|
|
161
|
+
>>> (when(bool, str.upper) | otherwise(str.lower))("Hi")
|
|
162
|
+
'HI'
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
"""
|
|
167
|
+
return Otherwise(value)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _name_of(function: Callable[..., Any]) -> str:
|
|
171
|
+
# Predicates and formulas print their own name; plain functions use `__name__`.
|
|
172
|
+
if type(function).__str__ is not object.__str__:
|
|
173
|
+
return str(function)
|
|
174
|
+
return getattr(function, "__name__", repr(function))
|