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.
@@ -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))