verdict-rules 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.
- verdict_rules-0.1.0/PKG-INFO +112 -0
- verdict_rules-0.1.0/README.md +85 -0
- verdict_rules-0.1.0/pyproject.toml +58 -0
- verdict_rules-0.1.0/pyproject.toml.orig +65 -0
- verdict_rules-0.1.0/src/verdict/__init__.py +28 -0
- verdict_rules-0.1.0/src/verdict/engine.py +95 -0
- verdict_rules-0.1.0/src/verdict/py.typed +0 -0
- verdict_rules-0.1.0/src/verdict/result.py +58 -0
- verdict_rules-0.1.0/src/verdict/rule.py +195 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: verdict-rules
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A small, zero-dependency, async-native rule-evaluation engine for Python.
|
|
5
|
+
Keywords: rules-engine,rule-evaluation,eligibility,policy-evaluation,decision-engine,async
|
|
6
|
+
Author: Pawan Vyas
|
|
7
|
+
Author-email: Pawan Vyas <pawan.vyas.self@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
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.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Project-URL: Homepage, https://github.com/pawan-vyas/verdict-rules
|
|
23
|
+
Project-URL: Repository, https://github.com/pawan-vyas/verdict-rules
|
|
24
|
+
Project-URL: Issues, https://github.com/pawan-vyas/verdict-rules/issues
|
|
25
|
+
Project-URL: Changelog, https://github.com/pawan-vyas/verdict-rules/blob/main/CHANGELOG.md
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# Verdict — Python
|
|
29
|
+
|
|
30
|
+
> The Python implementation of Verdict — a small, zero-dependency,
|
|
31
|
+
> async-native rule-evaluation engine. See the [top-level
|
|
32
|
+
> `README.md`](https://github.com/pawan-vyas/verdict-rules#readme) for
|
|
33
|
+
> what Verdict is and why it's shaped this way in narrative form; this
|
|
34
|
+
> doc is just "how do I install it and write my first rule" for Python
|
|
35
|
+
> specifically.
|
|
36
|
+
>
|
|
37
|
+
> This exact file is also what PyPI renders as the package description
|
|
38
|
+
> — none of its sibling files travel with a `pip install`, which is why
|
|
39
|
+
> every link below is an absolute GitHub URL rather than a relative
|
|
40
|
+
> path; on GitHub itself they work exactly the same way.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install verdict-rules
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The distribution on PyPI is named `verdict-rules` (the name `verdict`
|
|
49
|
+
was already taken by an unrelated package), but the import name is
|
|
50
|
+
plain `verdict`:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from verdict import Rule, FunctionRule, AndRule, OrRule, RulesEngine
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## A first rule
|
|
57
|
+
|
|
58
|
+
A `FunctionRule`'s predicate returns a full `RuleResult`, not a bare
|
|
59
|
+
boolean — that keeps it in control of `detail`/`data`, not just pass/fail:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
import asyncio
|
|
63
|
+
from verdict import FunctionRule, AndRule, RuleResult, RulesEngine
|
|
64
|
+
|
|
65
|
+
async def under_limit(context: dict) -> RuleResult:
|
|
66
|
+
return RuleResult(
|
|
67
|
+
rule_name="under_limit",
|
|
68
|
+
passed=context["requests_this_minute"] < context["limit"],
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
async def in_good_standing(context: dict) -> RuleResult:
|
|
72
|
+
return RuleResult(
|
|
73
|
+
rule_name="in_good_standing",
|
|
74
|
+
passed=context["account_status"] == "active",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
async def main() -> None:
|
|
78
|
+
can_proceed = AndRule(
|
|
79
|
+
"can_proceed",
|
|
80
|
+
[
|
|
81
|
+
FunctionRule("under_limit", under_limit),
|
|
82
|
+
FunctionRule("in_good_standing", in_good_standing),
|
|
83
|
+
],
|
|
84
|
+
)
|
|
85
|
+
engine = RulesEngine([can_proceed])
|
|
86
|
+
result = await engine.run_named(
|
|
87
|
+
"can_proceed",
|
|
88
|
+
{"requests_this_minute": 3, "limit": 10, "account_status": "active"},
|
|
89
|
+
)
|
|
90
|
+
print(result.passed) # True
|
|
91
|
+
|
|
92
|
+
asyncio.run(main())
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Where to go next
|
|
96
|
+
|
|
97
|
+
| Doc | For |
|
|
98
|
+
|---|---|
|
|
99
|
+
| [`docs/quickstart.md`](https://github.com/pawan-vyas/verdict-rules/blob/main/python/docs/quickstart.md) | The quickstart — core concepts and a full worked example |
|
|
100
|
+
| [`docs/architecture.md`](https://github.com/pawan-vyas/verdict-rules/blob/main/docs/architecture.md) | Why it's shaped this way, in depth — type structure, the execution model |
|
|
101
|
+
| [`docs/extension.md`](https://github.com/pawan-vyas/verdict-rules/blob/main/docs/extension.md) | Building on top of it from your own code, with no changes here |
|
|
102
|
+
| [`docs/maintenance.md`](https://github.com/pawan-vyas/verdict-rules/blob/main/docs/maintenance.md) | Changing this package itself |
|
|
103
|
+
| [`docs/testing.md`](https://github.com/pawan-vyas/verdict-rules/blob/main/docs/testing.md) | How the test suite is organized, and what a change needs to prove |
|
|
104
|
+
| [`docs/samples/`](https://github.com/pawan-vyas/verdict-rules/blob/main/python/docs/samples/1_README.md) | Worked examples — dynamic discounts, fee waivers, tier promotions, moderation routing, data-driven rule sets |
|
|
105
|
+
| [`examples/`](https://github.com/pawan-vyas/verdict-rules/blob/main/python/examples/README.md) | Full, tested mini-projects behind the more comprehensive samples — real code, real tests, real docs |
|
|
106
|
+
|
|
107
|
+
## Development
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
uv sync
|
|
111
|
+
uv run pytest
|
|
112
|
+
```
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Verdict — Python
|
|
2
|
+
|
|
3
|
+
> The Python implementation of Verdict — a small, zero-dependency,
|
|
4
|
+
> async-native rule-evaluation engine. See the [top-level
|
|
5
|
+
> `README.md`](https://github.com/pawan-vyas/verdict-rules#readme) for
|
|
6
|
+
> what Verdict is and why it's shaped this way in narrative form; this
|
|
7
|
+
> doc is just "how do I install it and write my first rule" for Python
|
|
8
|
+
> specifically.
|
|
9
|
+
>
|
|
10
|
+
> This exact file is also what PyPI renders as the package description
|
|
11
|
+
> — none of its sibling files travel with a `pip install`, which is why
|
|
12
|
+
> every link below is an absolute GitHub URL rather than a relative
|
|
13
|
+
> path; on GitHub itself they work exactly the same way.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install verdict-rules
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The distribution on PyPI is named `verdict-rules` (the name `verdict`
|
|
22
|
+
was already taken by an unrelated package), but the import name is
|
|
23
|
+
plain `verdict`:
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from verdict import Rule, FunctionRule, AndRule, OrRule, RulesEngine
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## A first rule
|
|
30
|
+
|
|
31
|
+
A `FunctionRule`'s predicate returns a full `RuleResult`, not a bare
|
|
32
|
+
boolean — that keeps it in control of `detail`/`data`, not just pass/fail:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
import asyncio
|
|
36
|
+
from verdict import FunctionRule, AndRule, RuleResult, RulesEngine
|
|
37
|
+
|
|
38
|
+
async def under_limit(context: dict) -> RuleResult:
|
|
39
|
+
return RuleResult(
|
|
40
|
+
rule_name="under_limit",
|
|
41
|
+
passed=context["requests_this_minute"] < context["limit"],
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
async def in_good_standing(context: dict) -> RuleResult:
|
|
45
|
+
return RuleResult(
|
|
46
|
+
rule_name="in_good_standing",
|
|
47
|
+
passed=context["account_status"] == "active",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
async def main() -> None:
|
|
51
|
+
can_proceed = AndRule(
|
|
52
|
+
"can_proceed",
|
|
53
|
+
[
|
|
54
|
+
FunctionRule("under_limit", under_limit),
|
|
55
|
+
FunctionRule("in_good_standing", in_good_standing),
|
|
56
|
+
],
|
|
57
|
+
)
|
|
58
|
+
engine = RulesEngine([can_proceed])
|
|
59
|
+
result = await engine.run_named(
|
|
60
|
+
"can_proceed",
|
|
61
|
+
{"requests_this_minute": 3, "limit": 10, "account_status": "active"},
|
|
62
|
+
)
|
|
63
|
+
print(result.passed) # True
|
|
64
|
+
|
|
65
|
+
asyncio.run(main())
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Where to go next
|
|
69
|
+
|
|
70
|
+
| Doc | For |
|
|
71
|
+
|---|---|
|
|
72
|
+
| [`docs/quickstart.md`](https://github.com/pawan-vyas/verdict-rules/blob/main/python/docs/quickstart.md) | The quickstart — core concepts and a full worked example |
|
|
73
|
+
| [`docs/architecture.md`](https://github.com/pawan-vyas/verdict-rules/blob/main/docs/architecture.md) | Why it's shaped this way, in depth — type structure, the execution model |
|
|
74
|
+
| [`docs/extension.md`](https://github.com/pawan-vyas/verdict-rules/blob/main/docs/extension.md) | Building on top of it from your own code, with no changes here |
|
|
75
|
+
| [`docs/maintenance.md`](https://github.com/pawan-vyas/verdict-rules/blob/main/docs/maintenance.md) | Changing this package itself |
|
|
76
|
+
| [`docs/testing.md`](https://github.com/pawan-vyas/verdict-rules/blob/main/docs/testing.md) | How the test suite is organized, and what a change needs to prove |
|
|
77
|
+
| [`docs/samples/`](https://github.com/pawan-vyas/verdict-rules/blob/main/python/docs/samples/1_README.md) | Worked examples — dynamic discounts, fee waivers, tier promotions, moderation routing, data-driven rule sets |
|
|
78
|
+
| [`examples/`](https://github.com/pawan-vyas/verdict-rules/blob/main/python/examples/README.md) | Full, tested mini-projects behind the more comprehensive samples — real code, real tests, real docs |
|
|
79
|
+
|
|
80
|
+
## Development
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
uv sync
|
|
84
|
+
uv run pytest
|
|
85
|
+
```
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "verdict-rules"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A small, zero-dependency, async-native rule-evaluation engine for Python."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
dependencies = []
|
|
9
|
+
keywords = [
|
|
10
|
+
"rules-engine",
|
|
11
|
+
"rule-evaluation",
|
|
12
|
+
"eligibility",
|
|
13
|
+
"policy-evaluation",
|
|
14
|
+
"decision-engine",
|
|
15
|
+
"async",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Programming Language :: Python :: 3.14",
|
|
28
|
+
"Typing :: Typed",
|
|
29
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[[project.authors]]
|
|
33
|
+
name = "Pawan Vyas"
|
|
34
|
+
email = "pawan.vyas.self@gmail.com"
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/pawan-vyas/verdict-rules"
|
|
38
|
+
Repository = "https://github.com/pawan-vyas/verdict-rules"
|
|
39
|
+
Issues = "https://github.com/pawan-vyas/verdict-rules/issues"
|
|
40
|
+
Changelog = "https://github.com/pawan-vyas/verdict-rules/blob/main/CHANGELOG.md"
|
|
41
|
+
|
|
42
|
+
[build-system]
|
|
43
|
+
requires = ["uv_build>=0.11.14,<0.13.0"]
|
|
44
|
+
build-backend = "uv_build"
|
|
45
|
+
|
|
46
|
+
[tool.uv.build-backend]
|
|
47
|
+
module-name = "verdict"
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
asyncio_mode = "auto"
|
|
51
|
+
|
|
52
|
+
[dependency-groups]
|
|
53
|
+
dev = [
|
|
54
|
+
"anyio>=4.0",
|
|
55
|
+
"pytest>=8.0",
|
|
56
|
+
"pytest-asyncio>=0.24",
|
|
57
|
+
"pytest-cov>=6.0",
|
|
58
|
+
]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "verdict-rules"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A small, zero-dependency, async-native rule-evaluation engine for Python."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Pawan Vyas", email = "pawan.vyas.self@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
# SPDX expression, not a file reference -- LICENSE lives at the repo root
|
|
10
|
+
# (it covers the whole polyglot repo, not just this Python package), and a
|
|
11
|
+
# plain SPDX string needs no file to be bundled into the sdist to be valid.
|
|
12
|
+
license = "MIT"
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
dependencies = []
|
|
15
|
+
keywords = [
|
|
16
|
+
"rules-engine",
|
|
17
|
+
"rule-evaluation",
|
|
18
|
+
"eligibility",
|
|
19
|
+
"policy-evaluation",
|
|
20
|
+
"decision-engine",
|
|
21
|
+
"async",
|
|
22
|
+
]
|
|
23
|
+
classifiers = [
|
|
24
|
+
"Development Status :: 3 - Alpha",
|
|
25
|
+
"Intended Audience :: Developers",
|
|
26
|
+
"Operating System :: OS Independent",
|
|
27
|
+
"Programming Language :: Python :: 3",
|
|
28
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
29
|
+
"Programming Language :: Python :: 3.10",
|
|
30
|
+
"Programming Language :: Python :: 3.11",
|
|
31
|
+
"Programming Language :: Python :: 3.12",
|
|
32
|
+
"Programming Language :: Python :: 3.13",
|
|
33
|
+
"Programming Language :: Python :: 3.14",
|
|
34
|
+
"Typing :: Typed",
|
|
35
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://github.com/pawan-vyas/verdict-rules"
|
|
40
|
+
Repository = "https://github.com/pawan-vyas/verdict-rules"
|
|
41
|
+
Issues = "https://github.com/pawan-vyas/verdict-rules/issues"
|
|
42
|
+
Changelog = "https://github.com/pawan-vyas/verdict-rules/blob/main/CHANGELOG.md"
|
|
43
|
+
|
|
44
|
+
[build-system]
|
|
45
|
+
requires = ["uv_build>=0.11.14,<0.13.0"]
|
|
46
|
+
build-backend = "uv_build"
|
|
47
|
+
|
|
48
|
+
[tool.uv.build-backend]
|
|
49
|
+
# The distribution name is "verdict-rules" (PyPI's own "verdict" name is
|
|
50
|
+
# already taken by an unrelated package), but the import name stays plain
|
|
51
|
+
# "verdict" -- the same distribution-name/import-name split as
|
|
52
|
+
# beautifulsoup4 -> bs4. Without this, uv_build looks for
|
|
53
|
+
# src/verdict_rules/ (the normalized project name) instead of src/verdict/.
|
|
54
|
+
module-name = "verdict"
|
|
55
|
+
|
|
56
|
+
[dependency-groups]
|
|
57
|
+
dev = [
|
|
58
|
+
"anyio>=4.0",
|
|
59
|
+
"pytest>=8.0",
|
|
60
|
+
"pytest-asyncio>=0.24",
|
|
61
|
+
"pytest-cov>=6.0",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
[tool.pytest.ini_options]
|
|
65
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Verdict — a small, zero-dependency, async-native rule-evaluation engine.
|
|
2
|
+
|
|
3
|
+
A Rule / Engine / Result design — genuinely standalone, general-purpose
|
|
4
|
+
infrastructure with no dependency on or knowledge of any particular
|
|
5
|
+
consumer's domain. Rules are named,
|
|
6
|
+
composable units (:class:`FunctionRule` for a plain predicate,
|
|
7
|
+
:class:`AndRule`/:class:`OrRule` for short-circuiting composition); a
|
|
8
|
+
:class:`RulesEngine` runs a set of them against a plain ``dict``
|
|
9
|
+
context, by any of three modes (all, one named rule, one group).
|
|
10
|
+
|
|
11
|
+
See ``README.md`` for a quickstart and a worked example, and
|
|
12
|
+
``docs/architecture.md`` for the full architecture write-up (type
|
|
13
|
+
structure, the execution model, and why it's shaped this way).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from verdict.engine import RulesEngine
|
|
17
|
+
from verdict.result import RuleResult, RunResult
|
|
18
|
+
from verdict.rule import AndRule, FunctionRule, OrRule, Rule
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"AndRule",
|
|
22
|
+
"FunctionRule",
|
|
23
|
+
"OrRule",
|
|
24
|
+
"Rule",
|
|
25
|
+
"RuleResult",
|
|
26
|
+
"RulesEngine",
|
|
27
|
+
"RunResult",
|
|
28
|
+
]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""The engine that runs rules against a context.
|
|
2
|
+
|
|
3
|
+
Holds a rule collection and offers three execution modes (all, named,
|
|
4
|
+
grouped), resolved via plain dict lookups rather than an if/elif chain.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
|
|
11
|
+
from verdict.result import RuleResult, RunResult
|
|
12
|
+
from verdict.rule import Rule
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RulesEngine:
|
|
16
|
+
"""Holds a set of rules and runs them against a context.
|
|
17
|
+
|
|
18
|
+
Unlike :class:`~verdict.rule.AndRule`/:class:`~verdict.rule.OrRule`
|
|
19
|
+
(which short-circuit to reach a single composite verdict
|
|
20
|
+
efficiently), every ``run_*`` method here evaluates every matching
|
|
21
|
+
rule unconditionally — the point of the engine's own run methods is
|
|
22
|
+
a full diagnostic picture (every rule's outcome), not the fastest
|
|
23
|
+
path to one boolean. Compose rules with ``AndRule``/``OrRule``
|
|
24
|
+
first if short-circuiting is what a particular call site wants.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, rules: list[Rule]) -> None:
|
|
28
|
+
"""Initialise with the full rule set this engine will serve.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
rules: Every rule this engine can run, by any of its three
|
|
32
|
+
execution modes. Names must be unique within this list —
|
|
33
|
+
a duplicate name silently shadows the earlier one in
|
|
34
|
+
:meth:`run_named`'s lookup, same as an ordinary dict
|
|
35
|
+
literal would.
|
|
36
|
+
"""
|
|
37
|
+
self._rules = rules
|
|
38
|
+
self._by_name: dict[str, Rule] = {r.name: r for r in rules}
|
|
39
|
+
self._by_group: dict[str, list[Rule]] = defaultdict(list)
|
|
40
|
+
for rule in rules:
|
|
41
|
+
if rule.group:
|
|
42
|
+
self._by_group[rule.group].append(rule)
|
|
43
|
+
|
|
44
|
+
async def run_all(self, context: dict) -> RunResult:
|
|
45
|
+
"""Evaluate every rule in this engine against ``context``.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
context: Passed through unchanged to every rule's own
|
|
49
|
+
``evaluate()``.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
A :class:`~verdict.result.RunResult` — ``passed`` is
|
|
53
|
+
``True`` only if every rule passed; ``results`` holds one
|
|
54
|
+
entry per rule, in the order the engine was constructed
|
|
55
|
+
with.
|
|
56
|
+
"""
|
|
57
|
+
results = [await rule.evaluate(context) for rule in self._rules]
|
|
58
|
+
return RunResult(passed=all(r.passed for r in results), results=results)
|
|
59
|
+
|
|
60
|
+
async def run_named(self, name: str, context: dict) -> RuleResult:
|
|
61
|
+
"""Evaluate exactly one rule, looked up by name.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
name: The rule's own ``name`` attribute.
|
|
65
|
+
context: Passed through unchanged to the rule's ``evaluate()``.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
That rule's own :class:`~verdict.result.RuleResult`.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
KeyError: No rule with this name exists in this engine.
|
|
72
|
+
"""
|
|
73
|
+
rule = self._by_name.get(name)
|
|
74
|
+
if rule is None:
|
|
75
|
+
raise KeyError(f"No rule named {name!r} in this engine")
|
|
76
|
+
return await rule.evaluate(context)
|
|
77
|
+
|
|
78
|
+
async def run_group(self, group: str, context: dict) -> RunResult:
|
|
79
|
+
"""Evaluate every rule sharing a given group label.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
group: The group label to match against each rule's own
|
|
83
|
+
``group`` attribute.
|
|
84
|
+
context: Passed through unchanged to every matching rule's
|
|
85
|
+
``evaluate()``.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
A :class:`~verdict.result.RunResult` scoped to just this
|
|
89
|
+
group — ``passed`` is ``True`` (vacuously) if the group is
|
|
90
|
+
empty or doesn't exist, matching Python's own ``all([])``
|
|
91
|
+
semantics; ``results`` holds one entry per matching rule.
|
|
92
|
+
"""
|
|
93
|
+
rules = self._by_group.get(group, [])
|
|
94
|
+
results = [await rule.evaluate(context) for rule in rules]
|
|
95
|
+
return RunResult(passed=all(r.passed for r in results), results=results)
|
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Result types returned by rule evaluation.
|
|
2
|
+
|
|
3
|
+
Deliberately plain, immutable data — a :class:`Rule` reports what
|
|
4
|
+
happened, nothing more. Any domain-specific payload a caller wants to
|
|
5
|
+
carry alongside the pass/fail outcome rides in :attr:`RuleResult.data`,
|
|
6
|
+
which this module (and every other module in this package) treats as
|
|
7
|
+
fully opaque — Verdict itself never inspects or depends on its shape,
|
|
8
|
+
which is what keeps the engine reusable across unrelated domains.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class RuleResult:
|
|
18
|
+
"""Outcome of evaluating a single :class:`~verdict.rule.Rule`.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
rule_name: The name of the rule this result came from — matches
|
|
22
|
+
the evaluated rule's own ``name`` attribute, so a caller
|
|
23
|
+
walking a :class:`RunResult` can attribute each outcome
|
|
24
|
+
back to the rule that produced it.
|
|
25
|
+
passed: Whether the rule's condition was satisfied.
|
|
26
|
+
detail: Optional human-readable explanation of the outcome —
|
|
27
|
+
e.g. why a rule failed. Empty string when there's nothing
|
|
28
|
+
worth saying beyond the boolean.
|
|
29
|
+
data: Optional, fully opaque payload a caller can attach to
|
|
30
|
+
carry its own domain object through the evaluation (e.g. a
|
|
31
|
+
computed status object) — Verdict never reads or depends on
|
|
32
|
+
its shape.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
rule_name: str
|
|
36
|
+
passed: bool
|
|
37
|
+
detail: str = ""
|
|
38
|
+
data: object | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class RunResult:
|
|
43
|
+
"""Aggregate outcome of running a whole set of rules
|
|
44
|
+
(:meth:`~verdict.engine.RulesEngine.run_all` or
|
|
45
|
+
:meth:`~verdict.engine.RulesEngine.run_group`).
|
|
46
|
+
|
|
47
|
+
Attributes:
|
|
48
|
+
passed: ``True`` only if every rule in :attr:`results` passed.
|
|
49
|
+
results: One :class:`RuleResult` per rule that was evaluated,
|
|
50
|
+
in evaluation order. A short-circuited composite rule (see
|
|
51
|
+
:class:`~verdict.rule.AndRule`/:class:`~verdict.rule.OrRule`)
|
|
52
|
+
still contributes exactly one entry here for itself — its
|
|
53
|
+
own sub-rule results are nested inside its own
|
|
54
|
+
:attr:`RuleResult.data`, not flattened into this list.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
passed: bool
|
|
58
|
+
results: list[RuleResult] = field(default_factory=list)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Rule definitions — the unit of evaluation this package is built around.
|
|
2
|
+
|
|
3
|
+
A :class:`Rule` is anything with a ``name``, an optional ``group``, and an
|
|
4
|
+
``evaluate(context)`` coroutine returning a :class:`~verdict.result.RuleResult` —
|
|
5
|
+
:class:`FunctionRule` is the common case (wrap a plain predicate),
|
|
6
|
+
:class:`AndRule`/:class:`OrRule` compose other rules into one, short-
|
|
7
|
+
circuiting the same way a boolean ``and``/``or`` expression would.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Awaitable, Callable, Protocol, runtime_checkable
|
|
13
|
+
|
|
14
|
+
from verdict.result import RuleResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@runtime_checkable
|
|
18
|
+
class Rule(Protocol):
|
|
19
|
+
"""Structural interface every rule (plain or composite) satisfies.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
name: Unique identifier for this rule within a
|
|
23
|
+
:class:`~verdict.engine.RulesEngine` — used for
|
|
24
|
+
:meth:`~verdict.engine.RulesEngine.run_named` lookups and to
|
|
25
|
+
label this rule's own entry in a
|
|
26
|
+
:class:`~verdict.result.RunResult`.
|
|
27
|
+
group: Optional group label — rules sharing a group can be run
|
|
28
|
+
together via :meth:`~verdict.engine.RulesEngine.run_group`.
|
|
29
|
+
``None`` if this rule doesn't belong to any group.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
group: str | None
|
|
34
|
+
|
|
35
|
+
async def evaluate(self, context: dict) -> RuleResult:
|
|
36
|
+
"""Evaluate this rule against ``context``.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
context: Arbitrary key-value data the rule's condition reads
|
|
40
|
+
from — Verdict never inspects or constrains its shape,
|
|
41
|
+
the caller and its rules agree on it privately.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
The outcome of evaluating this rule.
|
|
45
|
+
"""
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class FunctionRule:
|
|
50
|
+
"""Wraps a plain async predicate as a :class:`Rule`.
|
|
51
|
+
|
|
52
|
+
The common case: most rules are just "run this function against the
|
|
53
|
+
context and see what it says," without needing a dedicated class.
|
|
54
|
+
|
|
55
|
+
Attributes:
|
|
56
|
+
name: See :class:`Rule`.
|
|
57
|
+
group: See :class:`Rule`.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
name: str,
|
|
63
|
+
predicate: Callable[[dict], Awaitable[RuleResult]],
|
|
64
|
+
group: str | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Initialise with a name and the predicate to run.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
name: Unique identifier for this rule.
|
|
70
|
+
predicate: Async callable that inspects ``context`` and
|
|
71
|
+
returns the full :class:`~verdict.result.RuleResult` —
|
|
72
|
+
not just a bare boolean, so the predicate keeps full
|
|
73
|
+
control over ``detail``/``data``.
|
|
74
|
+
group: Optional group label, see :class:`Rule`.
|
|
75
|
+
"""
|
|
76
|
+
self.name = name
|
|
77
|
+
self.group = group
|
|
78
|
+
self._predicate = predicate
|
|
79
|
+
|
|
80
|
+
async def evaluate(self, context: dict) -> RuleResult:
|
|
81
|
+
"""Run the wrapped predicate against ``context``.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
context: See :meth:`Rule.evaluate`.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Whatever the wrapped predicate returns, unchanged.
|
|
88
|
+
"""
|
|
89
|
+
return await self._predicate(context)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class AndRule:
|
|
93
|
+
"""Composite rule that passes only if every sub-rule passes.
|
|
94
|
+
|
|
95
|
+
Short-circuits on the first failing sub-rule — later sub-rules are
|
|
96
|
+
never evaluated once one has already failed, so a caller can rely on
|
|
97
|
+
:class:`AndRule` never doing more work (or having more side effects,
|
|
98
|
+
for a sub-rule whose predicate writes something) than the minimum
|
|
99
|
+
needed to reach a verdict.
|
|
100
|
+
|
|
101
|
+
Attributes:
|
|
102
|
+
name: See :class:`Rule`.
|
|
103
|
+
group: See :class:`Rule`.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(self, name: str, rules: list[Rule], group: str | None = None) -> None:
|
|
107
|
+
"""Initialise with the ordered sub-rules to combine.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
name: Unique identifier for this composite rule.
|
|
111
|
+
rules: Sub-rules evaluated in order until one fails (or all
|
|
112
|
+
pass). An empty list vacuously passes — no sub-rule
|
|
113
|
+
means nothing to fail on.
|
|
114
|
+
group: Optional group label, see :class:`Rule`.
|
|
115
|
+
"""
|
|
116
|
+
self.name = name
|
|
117
|
+
self.group = group
|
|
118
|
+
self._rules = rules
|
|
119
|
+
|
|
120
|
+
async def evaluate(self, context: dict) -> RuleResult:
|
|
121
|
+
"""Evaluate sub-rules in order, stopping at the first failure.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
context: See :meth:`Rule.evaluate`.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
A :class:`~verdict.result.RuleResult` for this composite
|
|
128
|
+
rule itself — ``passed`` is ``True`` only if every sub-rule
|
|
129
|
+
evaluated passed; ``data`` carries the list of per-sub-rule
|
|
130
|
+
results gathered so far (the full list on success, the
|
|
131
|
+
results up to and including the first failure otherwise).
|
|
132
|
+
"""
|
|
133
|
+
sub_results: list[RuleResult] = []
|
|
134
|
+
for rule in self._rules:
|
|
135
|
+
result = await rule.evaluate(context)
|
|
136
|
+
sub_results.append(result)
|
|
137
|
+
if not result.passed:
|
|
138
|
+
return RuleResult(
|
|
139
|
+
rule_name=self.name,
|
|
140
|
+
passed=False,
|
|
141
|
+
detail=f"'{rule.name}' failed: {result.detail}".rstrip(": "),
|
|
142
|
+
data=sub_results,
|
|
143
|
+
)
|
|
144
|
+
return RuleResult(rule_name=self.name, passed=True, data=sub_results)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class OrRule:
|
|
148
|
+
"""Composite rule that passes if any sub-rule passes.
|
|
149
|
+
|
|
150
|
+
Short-circuits on the first passing sub-rule — the mirror image of
|
|
151
|
+
:class:`AndRule`.
|
|
152
|
+
|
|
153
|
+
Attributes:
|
|
154
|
+
name: See :class:`Rule`.
|
|
155
|
+
group: See :class:`Rule`.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
def __init__(self, name: str, rules: list[Rule], group: str | None = None) -> None:
|
|
159
|
+
"""Initialise with the ordered sub-rules to combine.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
name: Unique identifier for this composite rule.
|
|
163
|
+
rules: Sub-rules evaluated in order until one passes (or all
|
|
164
|
+
fail). An empty list vacuously fails — no sub-rule means
|
|
165
|
+
nothing to pass on.
|
|
166
|
+
group: Optional group label, see :class:`Rule`.
|
|
167
|
+
"""
|
|
168
|
+
self.name = name
|
|
169
|
+
self.group = group
|
|
170
|
+
self._rules = rules
|
|
171
|
+
|
|
172
|
+
async def evaluate(self, context: dict) -> RuleResult:
|
|
173
|
+
"""Evaluate sub-rules in order, stopping at the first pass.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
context: See :meth:`Rule.evaluate`.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
A :class:`~verdict.result.RuleResult` for this composite
|
|
180
|
+
rule itself — ``passed`` is ``True`` as soon as any sub-rule
|
|
181
|
+
passes; ``data`` carries the list of per-sub-rule results
|
|
182
|
+
gathered so far.
|
|
183
|
+
"""
|
|
184
|
+
sub_results: list[RuleResult] = []
|
|
185
|
+
for rule in self._rules:
|
|
186
|
+
result = await rule.evaluate(context)
|
|
187
|
+
sub_results.append(result)
|
|
188
|
+
if result.passed:
|
|
189
|
+
return RuleResult(rule_name=self.name, passed=True, data=sub_results)
|
|
190
|
+
return RuleResult(
|
|
191
|
+
rule_name=self.name,
|
|
192
|
+
passed=False,
|
|
193
|
+
detail="no sub-rule passed",
|
|
194
|
+
data=sub_results,
|
|
195
|
+
)
|