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