diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d98d12c..d3045520 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## v26.06.93 (2026-06-10) + +### Added (rule engine completeness — parity initiative SP-13) + +Brought the rule engine (the weakest subsystem, ~18% parity) to functional parity: + +- **Rich operators**: added `between`, `contains`, `not_contains`, `starts_with`, `ends_with`, `exists`, + `is_null`, `is_empty` (all None-safe) on top of the existing comparison/logical set. +- **Fluent builder DSL** (`pyfly.rule_engine.builder`): `field(...).()`, `all_of/any_of/not_`, + `set_action/increment_action/log_action`, and `rule(id)...build()` / `ruleset(...)...build()`. +- **Loading + validation**: `RuleSetLoader.from_json` (alongside YAML/dict) and a `RuleSetValidator` / + `validate_ruleset` / `RuleValidationError` that catches duplicate ids, unknown operators, missing action + targets, malformed `between`, empty `and`/`or`, and bad `not` arity. +- **Hexagonal port + service**: `RuleEnginePort` + `ActionHandler` SPI (`ports/outbound.py`) and a + `RuleEngineService` facade (`evaluate`, async `evaluate_by_name` over the repository, save/get/list) with + `pyfly_rule_*_total` metrics (evaluations/matched/actions-fired/errors) when a `MetricsRecorder` is present. +- **Pluggable action handlers**: `RuleEvaluator(action_handlers={...})` makes `call`/`calculate`/custom action + types pluggable without subclassing (built-in `set`/`increment`/`log` preserved, additive). +- **Evaluation modes**: `EvaluationMode.ALL` (default) and `FIRST_MATCH` on `RuleSetEvaluator`, wired via + `pyfly.rule-engine.mode`. +- End-to-end integration test + full `docs/modules/rule-engine.md` rewrite (operator reference, modes, + builder, validation, service, handlers, metrics). Stateful forward-chaining remains out of scope by design. + ## v26.06.92 (2026-06-10) ### Added / Fixed (config server backends — parity initiative SP-12) diff --git a/README.md b/README.md index eebf9a0b..c51a5a96 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Firefly Framework Python 3.12+ License: Apache 2.0 - Version: 26.06.92 + Version: 26.06.93 Type Checked: mypy strict Code Style: Ruff Async First diff --git a/docs/modules/rule-engine.md b/docs/modules/rule-engine.md index 89401f3d..8319eab1 100644 --- a/docs/modules/rule-engine.md +++ b/docs/modules/rule-engine.md @@ -1,48 +1,478 @@ # Rule Engine -`pyfly.rule_engine` is a YAML-based business rules engine: parse rules -into an AST, evaluate them against a context dict, take actions. +`pyfly.rule_engine` is a YAML-based business-rules engine: parse rules into an AST, +evaluate them in priority order against a context dict, and execute typed actions. +The engine is pure Python — no JVM, no external server, no forward-chaining loop. -## Defining rules in YAML +--- + +## Model + +Every object is an immutable (frozen) dataclass. Fields shown with their defaults. + +### `Condition` + +```python +@dataclass +class Condition: + operator: str # leaf operator OR "and" / "or" / "not" + field: str | None = None # dot-notation path into ctx, e.g. "order.amount" + value: Any = None # comparand for leaf operators + children: list[Condition] = [] # sub-conditions for compound operators +``` + +### `Action` + +```python +@dataclass +class Action: + type: str # "set" | "increment" | "log" | "call" | "calculate" + target: str | None = None # context path to write (required for set/increment) + value: Any = None # value to write or log message + expression: str | None = None # optional expression string (for custom handlers) + arguments: dict[str, Any] = {} # extra key/value arguments for custom handlers +``` + +### `Rule` + +```python +@dataclass +class Rule: + id: str # unique within a RuleSet + description: str = "" + when: Condition | None = None # condition; None means "always match" + then: list[Action] = [] # actions when condition is True + otherwise: list[Action] = [] # actions when condition is False + priority: int = 0 # higher priority = evaluated first + enabled: bool = True # disabled rules are skipped entirely +``` + +### `RuleSet` + +```python +@dataclass +class RuleSet: + id: str + name: str = "" + version: int = 1 + rules: list[Rule] = [] + + def sorted_rules(self) -> list[Rule]: ... # descending priority order +``` + +--- + +## Operator reference + +### Leaf operators (None-safe) + +All leaf operators are **None-safe**: if the field is absent or evaluates to `None` +the result is `False` (not an exception) unless the operator is specifically +designed to test for absence (`exists`, `is_null`, `is_empty`). + +| Operator | Semantics | +|---|---| +| `eq` | `actual == value` | +| `ne` | `actual != value` | +| `gt` | `actual > value`; `None` → `False` | +| `ge` | `actual >= value`; `None` → `False` | +| `lt` | `actual < value`; `None` → `False` | +| `le` | `actual <= value`; `None` → `False` | +| `in` | `actual in value` (value must be a list); `None` → `False` | +| `not_in` | `actual not in value`; `None` → `False` | +| `regex` | `re.search(value, str(actual))`; coerces both sides to str | +| `between` | `value[0] <= actual <= value[1]`; value must be `[lo, hi]`; `None` → `False` | +| `contains` | For strings: `value in actual`; for lists/collections: `value in actual`; `None` → `False` | +| `not_contains` | Inverse of `contains`; `None` → `False` | +| `starts_with` | `str(actual).startswith(str(value))`; `None` → `False` | +| `ends_with` | `str(actual).endswith(str(value))`; `None` → `False` | +| `exists` | `True` if field is present **and** not `None`; `value` is ignored | +| `is_null` | `True` if field is absent **or** `None`; `value` is ignored | +| `is_empty` | `True` if `None`, `""`, `[]`, or `{}`; `value` is ignored | + +### Compound operators + +| Operator | Semantics | +|---|---| +| `and` | All `children` conditions must be `True` (short-circuits) | +| `or` | At least one `children` condition must be `True` (short-circuits) | +| `not` | Negates **exactly one** child; providing 0 or 2+ children raises `ValueError` | + +In YAML compound conditions use `conditions:` (or `children:`) instead of `field:`/`value:`: ```yaml -id: order-rules -name: Order processing rules +op: and +conditions: + - { op: ge, field: order.amount, value: 1000 } + - { op: in, field: order.region, value: ["US", "CA"] } +``` + +--- + +## Loading + +### `RuleSetLoader` + +```python +from pyfly.rule_engine import RuleSetLoader + +rs = RuleSetLoader.from_yaml(yaml_text) # parse a YAML string +rs = RuleSetLoader.from_json(json_text) # parse a JSON string +rs = RuleSetLoader.from_dict(data_dict) # parse a plain dict +``` + +**YAML example** + +```yaml +id: order-processing +name: Order Processing Rules +version: 1 rules: - id: high-value - priority: 10 + description: Flag high-value orders + priority: 20 when: - op: ge + op: between field: order.amount - value: 1000 + value: [5000, 999999] then: - { type: set, target: flags.high_value, value: true } - - { type: log, value: "marking order as high-value" } + - { type: increment, target: score, value: 10 } + otherwise: + - { type: set, target: flags.high_value, value: false } + - id: blocked-region + priority: 15 when: op: in - field: order.shipping_country - value: ["XX", "YY"] + field: order.region + value: ["RU", "KP", "IR"] then: - { type: set, target: flags.blocked, value: true } + otherwise: + - { type: set, target: flags.blocked, value: false } + + - id: fraud-pattern + priority: 10 + when: + op: regex + field: order.email + value: ".*@temp.*\\..*" + then: + - { type: set, target: flags.fraud_suspected, value: true } + - type: call + target: fraud-audit + arguments: { event: fraud_pattern_matched } + otherwise: + - { type: set, target: flags.fraud_suspected, value: false } ``` -## Evaluating +### Validation + +Use `validate_ruleset` (function) or `RuleSetValidator` (class) before evaluating +untrusted or user-supplied rule documents. ```python -from pyfly.rule_engine import RuleSetLoader, RuleSetEvaluator +from pyfly.rule_engine import validate_ruleset +from pyfly.rule_engine.validation import RuleSetValidator, RuleValidationError + +# returns a list of human-readable strings; empty = valid +issues = validate_ruleset(rs) + +# OO interface +issues = RuleSetValidator.check(rs) + +# raises RuleValidationError if any issues found +RuleSetValidator.assert_valid(rs) +``` + +`RuleValidationError` carries `.ruleset_id` and `.issues` (list of strings). + +**What the validator catches** + +- Duplicate rule `id` values within a `RuleSet`. +- Unknown leaf operator (not in the supported set). +- `and`/`or` compound with no children. +- `not` compound with a child count other than 1. +- `set` or `increment` action missing `target`. +- `between` condition whose `value` is not a 2-element sequence. +- Unknown `action.type` (not one of `set`, `increment`, `log`, `call`, `calculate`). + +--- + +## Fluent builder + +Build rule objects in Python without writing YAML. + +```python +from pyfly.rule_engine.builder import ( + field, all_of, any_of, not_, + set_action, increment_action, log_action, + rule, ruleset, +) + +# --- conditions --- +cond = field("order.amount").ge(1000) +cond = field("order.region").in_(["US", "CA"]) +cond = field("order.email").regex(r".*@temp.*\..*") +cond = all_of(field("customer.tier").eq("gold"), field("order.total").ge(500)) +cond = any_of(field("flags.vip").eq(True), field("order.total").ge(2000)) +cond = not_(field("flags.blocked").eq(True)) + +# --- actions --- +a1 = set_action("flags.high_value", True) +a2 = increment_action("score", 10) # 'by' defaults to 1 +a3 = log_action("High-value order detected") + +# --- single rule --- +my_rule = ( + rule("high-value") + .describe("Flag high-value orders") + .priority(20) + .when(field("order.amount").between(5000, 999999)) + .then(set_action("flags.high_value", True), increment_action("score", 10)) + .otherwise(set_action("flags.high_value", False)) + .build() +) + +# --- ruleset --- +my_ruleset = ( + ruleset("order-processing", name="Order Processing Rules", version=1) + .add(my_rule) + .build() +) +``` + +**`_FieldBuilder` operator methods** (all return a `Condition`): +`eq`, `ne`, `gt`, `ge`, `lt`, `le`, `in_`, `not_in`, `regex`, `between`, +`contains`, `not_contains`, `starts_with`, `ends_with`, `exists`, `is_null`, `is_empty`. + +**`RuleBuilder` chain methods**: `describe(text)`, `priority(n)`, `enabled(flag)`, +`when(condition)`, `then(*actions)`, `otherwise(*actions)`, `build()` → `Rule`. + +**`RuleSetBuilder` chain methods**: `add(*rules)`, `build()` → `RuleSet`. + +--- + +## Evaluation + +### `RuleEvaluator` — single-rule evaluator + +```python +from pyfly.rule_engine import RuleEvaluator + +# default: only set / increment / log action types supported +evaluator = RuleEvaluator() -ruleset = RuleSetLoader.from_yaml(yaml_text) -evaluator = RuleSetEvaluator() -ctx = {"order": {"amount": 5000, "shipping_country": "US"}, "flags": {}} -results = evaluator.evaluate(ruleset, ctx) -print(ctx["flags"]) # {"high_value": True} +# with custom handler(s) merged on top of built-ins +evaluator = RuleEvaluator(action_handlers={"call": my_handler}) +``` + +`RuleEvaluator.evaluate(rule, ctx)` → `EvaluationResult` + +### `EvaluationResult` + +```python +@dataclass +class EvaluationResult: + rule_id: str + matched: bool + actions_executed: list[Action] = [] # successfully executed actions + error: str | None = None # semicolon-joined errors from isolated failures +``` + +### `EvaluationMode` + +```python +from pyfly.rule_engine import EvaluationMode + +EvaluationMode.ALL # evaluate every enabled rule; default +EvaluationMode.FIRST_MATCH # stop after the first rule whose condition matched +``` + +### `RuleSetEvaluator` — whole-ruleset evaluator + +```python +from pyfly.rule_engine import RuleSetEvaluator, EvaluationMode + +evaluator = RuleSetEvaluator( + rule_evaluator=RuleEvaluator(), # defaults to vanilla RuleEvaluator + mode=EvaluationMode.ALL, # default +) + +results: list[EvaluationResult] = evaluator.evaluate(ruleset, ctx) +``` + +**`ALL` mode** — every enabled rule is evaluated in descending `priority` order. +All matching rules execute their actions against the **shared** `ctx` dict, so +later rules (lower priority) observe mutations made by earlier ones. + +**`FIRST_MATCH` mode** — rules are evaluated in descending priority order and +evaluation **stops immediately** after the first rule whose condition is `True`. +The returned list contains every rule evaluated *up to and including* the first +match; rules with lower priority are never evaluated and their actions never fire. +Shared-context semantics are identical for the subset of rules that *are* evaluated. + +**Action isolation** — within a single rule, each action is executed in its own +`try/except`. If an action raises (e.g. an unregistered type), the error is +recorded in `EvaluationResult.error` and sibling actions still execute. + +--- + +## Action handlers + +### Built-in handlers + +| Type | Behaviour | +|---|---| +| `set` | Write `action.value` to the dot-notation `action.target` in `ctx` | +| `increment` | Add `action.value` (default 1) to the numeric value at `action.target` | +| `log` | `logging.info("rule action: %s", action.value or action.target)` | + +### Custom handlers via `ActionHandler` protocol + +```python +from pyfly.rule_engine.dsl import Action +from typing import Any + +# A handler is any callable (action, ctx) -> None — a plain function ... +def audit_handler(action: Action, ctx: dict[str, Any]) -> None: + # action.target, action.value, action.expression, action.arguments available + record_audit_event(action.arguments.get("event"), ctx.get("order_id")) + +# ... or an object implementing the ActionHandler __call__ protocol: +class AuditHandler: + def __call__(self, action: Action, ctx: dict[str, Any]) -> None: + record_audit_event(action.arguments.get("event"), ctx.get("order_id")) + +# register at RuleEvaluator construction time +evaluator = RuleEvaluator(action_handlers={"call": audit_handler}) +``` + +Any callable `(action: Action, ctx: dict[str, Any]) -> None` satisfies the +`ActionHandler` protocol (it declares `__call__`) — a plain function, a lambda, +or an object with `__call__` all work. + +Custom handlers are **additive**: built-in `set`/`increment`/`log` remain +available unless you explicitly override them with the same key. + +Any action type *not* present in the final handler registry raises +`NotImplementedError` at evaluation time (the error is isolated to that action +by the action-isolation guarantee). + +The `call` and `calculate` action types appear in the YAML DSL and pass validation +(`validate_ruleset` accepts them) but are **not** in the default handler registry +by design — they are extension points that application code wires up via custom +handlers. + +--- + +## Service and port + +### `RuleEnginePort` (protocol) + +```python +from pyfly.rule_engine.ports.outbound import RuleEnginePort + +class RuleEnginePort(Protocol): + def evaluate(self, ruleset: RuleSet, ctx: dict[str, Any]) -> list[EvaluationResult]: ... +``` + +Application code should depend on `RuleEnginePort` rather than +`RuleEngineService` directly to keep the dependency injectable. + +### `RuleSetRepository` (protocol) + `InMemoryRuleSetRepository` + +```python +from pyfly.rule_engine import RuleSetRepository, InMemoryRuleSetRepository + +class RuleSetRepository(Protocol): + async def save(self, ruleset: RuleSet) -> None: ... + async def get(self, ruleset_id: str) -> RuleSet | None: ... + async def list(self) -> list[RuleSet]: ... + async def delete(self, ruleset_id: str) -> bool: ... +``` + +`InMemoryRuleSetRepository` is the default adapter — backed by an in-memory +dict with an `asyncio.Lock`, suitable for tests and single-process deployments. + +### `RuleEngineService` + +```python +from pyfly.rule_engine import RuleEngineService, RuleSetNotFoundError + +svc = RuleEngineService( + repository=InMemoryRuleSetRepository(), + evaluator=RuleSetEvaluator(), # optional; defaults to ALL mode + metrics=recorder, # optional MetricsRecorder +) + +# synchronous — satisfies RuleEnginePort +results = svc.evaluate(ruleset, ctx) + +# async — load by id from repo then evaluate +results = await svc.evaluate_by_name("order-processing", ctx) # raises RuleSetNotFoundError if absent + +# async repository passthrough +await svc.save_ruleset(rs) +rs = await svc.get_ruleset("order-processing") # None if not found +rulesets = await svc.list_rulesets() +``` + +`RuleSetNotFoundError` (a `KeyError` subclass) is raised by `evaluate_by_name` +when the repository returns `None` for the given ID. + +--- + +## Metrics + +When a `MetricsRecorder` is passed to `RuleEngineService`, four counters are +created on construction and incremented after every `evaluate` / +`evaluate_by_name` call. All counters carry a `ruleset` label set to the +evaluated `RuleSet.id`. + +| Counter | Incremented | +|---|---| +| `pyfly_rule_evaluations_total` | Once per `evaluate` / `evaluate_by_name` call | +| `pyfly_rules_matched_total` | For each `EvaluationResult` where `matched is True` | +| `pyfly_rule_actions_fired_total` | By the number of successfully-executed actions across all results | +| `pyfly_rule_errors_total` | For each `EvaluationResult` with a non-`None` `error` field | + +Omitting the `metrics` argument (or passing `None`) disables all instrumentation +with no other effect on behaviour. + +--- + +## Auto-configuration + +When `pyfly.rule-engine.enabled=true` the `RuleEngineAutoConfiguration` bean +registers `InMemoryRuleSetRepository`, `RuleEvaluator`, `RuleSetEvaluator`, and +`RuleEngineService` in the application container. + +| Property | Values | Default | +|---|---|---| +| `pyfly.rule-engine.enabled` | `true` / `false` | (disabled) | +| `pyfly.rule-engine.mode` | `all` / `first-match` | `all` | + +Example `application.yaml`: + +```yaml +pyfly: + rule-engine: + enabled: true + mode: first-match ``` -## Operators +--- -Comparison: `eq`, `ne`, `gt`, `ge`, `lt`, `le`, `in`, `not_in`, `regex`. -Logical: `and`, `or`, `not` (with `conditions: [...]`). +## Out of scope / by design -Action types: `set` (write context path), `increment`, `log`. Subclass -`RuleEvaluator._execute_action` to support `call`, `calculate`, etc. +- **Stateful forward-chaining** is not implemented. Each `evaluate` call is a + single pass over the sorted rule list; the engine does not re-evaluate rules + after actions mutate the context. This is intentional — the simpler semantics + are sufficient for the majority of business-rule use cases and avoid the + complexity (and non-termination risk) of a Rete-style engine. +- **`call` and `calculate` action types** are defined in the DSL and pass + validation, but they are **not handled by default**. They are explicit + extension points: wire them up by injecting a custom `ActionHandler` via + `RuleEvaluator(action_handlers={"call": ...})`. diff --git a/pyproject.toml b/pyproject.toml index 05cb8ec4..2c107b1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "pyfly" # CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4); # git tag, GitHub release and human-readable display use leading-zero form # (v26.05.04) to match the Java/.NET/Go siblings. -version = "26.6.92" +version = "26.6.93" description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more." readme = "README.md" license = "Apache-2.0" diff --git a/src/pyfly/__init__.py b/src/pyfly/__init__.py index 1690f3b9..8bb5eb7a 100644 --- a/src/pyfly/__init__.py +++ b/src/pyfly/__init__.py @@ -13,4 +13,4 @@ # limitations under the License. """PyFly — Enterprise Python Framework.""" -__version__ = "26.06.92" +__version__ = "26.06.93" diff --git a/src/pyfly/rule_engine/__init__.py b/src/pyfly/rule_engine/__init__.py index 469ffacf..9ed884f7 100644 --- a/src/pyfly/rule_engine/__init__.py +++ b/src/pyfly/rule_engine/__init__.py @@ -4,6 +4,20 @@ from __future__ import annotations +from pyfly.rule_engine.builder import ( + RuleBuilder, + RuleSetBuilder, + _FieldBuilder, + all_of, + any_of, + field, + increment_action, + log_action, + not_, + rule, + ruleset, + set_action, +) from pyfly.rule_engine.dsl import ( Action, Condition, @@ -12,24 +26,52 @@ RuleSetLoader, ) from pyfly.rule_engine.evaluator import ( + EvaluationMode, EvaluationResult, RuleEvaluator, RuleSetEvaluator, ) +from pyfly.rule_engine.ports.outbound import ActionHandler, RuleEnginePort from pyfly.rule_engine.repository import ( InMemoryRuleSetRepository, RuleSetRepository, ) +from pyfly.rule_engine.service import RuleEngineService, RuleSetNotFoundError +from pyfly.rule_engine.validation import ( + RuleSetValidator, + RuleValidationError, + validate_ruleset, +) __all__ = [ "Action", + "ActionHandler", "Condition", + "EvaluationMode", "EvaluationResult", "InMemoryRuleSetRepository", "Rule", + "RuleBuilder", + "RuleEnginePort", + "RuleEngineService", "RuleEvaluator", "RuleSet", + "RuleSetBuilder", "RuleSetEvaluator", "RuleSetLoader", + "RuleSetNotFoundError", "RuleSetRepository", + "RuleSetValidator", + "RuleValidationError", + "_FieldBuilder", + "all_of", + "any_of", + "field", + "increment_action", + "log_action", + "not_", + "rule", + "ruleset", + "set_action", + "validate_ruleset", ] diff --git a/src/pyfly/rule_engine/auto_configuration.py b/src/pyfly/rule_engine/auto_configuration.py index bf3cc3bb..b907fbde 100644 --- a/src/pyfly/rule_engine/auto_configuration.py +++ b/src/pyfly/rule_engine/auto_configuration.py @@ -4,10 +4,14 @@ from __future__ import annotations +from typing import Any + from pyfly.container.bean import bean from pyfly.context.conditions import auto_configuration, conditional_on_property -from pyfly.rule_engine.evaluator import RuleEvaluator, RuleSetEvaluator +from pyfly.core.config import Config +from pyfly.rule_engine.evaluator import EvaluationMode, RuleEvaluator, RuleSetEvaluator from pyfly.rule_engine.repository import InMemoryRuleSetRepository +from pyfly.rule_engine.service import RuleEngineService @auto_configuration @@ -22,5 +26,20 @@ def rule_evaluator(self) -> RuleEvaluator: return RuleEvaluator() @bean - def rule_set_evaluator(self, rule_evaluator: RuleEvaluator) -> RuleSetEvaluator: - return RuleSetEvaluator(rule_evaluator=rule_evaluator) + def rule_set_evaluator(self, rule_evaluator: RuleEvaluator, config: Config) -> RuleSetEvaluator: + mode_str: str = config.get("pyfly.rule-engine.mode", "all") + mode = EvaluationMode.FIRST_MATCH if mode_str == "first-match" else EvaluationMode.ALL + return RuleSetEvaluator(rule_evaluator=rule_evaluator, mode=mode) + + @bean + def rule_engine_service( + self, + rule_set_repository: InMemoryRuleSetRepository, + rule_set_evaluator: RuleSetEvaluator, + metrics: Any | None = None, + ) -> RuleEngineService: + return RuleEngineService( + repository=rule_set_repository, + evaluator=rule_set_evaluator, + metrics=metrics, + ) diff --git a/src/pyfly/rule_engine/builder.py b/src/pyfly/rule_engine/builder.py new file mode 100644 index 00000000..d9a55b7d --- /dev/null +++ b/src/pyfly/rule_engine/builder.py @@ -0,0 +1,286 @@ +# Copyright 2026 Firefly Software Foundation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Fluent builder DSL for constructing :class:`~pyfly.rule_engine.dsl.Rule` objects. + +Usage example:: + + from pyfly.rule_engine.builder import field, all_of, any_of, not_ + from pyfly.rule_engine.builder import set_action, log_action, rule, ruleset + + my_rule = ( + rule("vip-discount") + .describe("Grant discount to VIP customers with high spend") + .priority(10) + .when( + all_of( + field("customer.tier").eq("gold"), + any_of( + field("order.total").ge(500), + field("customer.is_vip").eq(True), + ), + ) + ) + .then( + set_action("order.discount_pct", 20), + log_action("VIP discount applied"), + ) + .otherwise(set_action("order.discount_pct", 0)) + .build() + ) +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from pyfly.rule_engine.dsl import Action, Condition, Rule, RuleSet + +# --------------------------------------------------------------------------- +# Condition helpers +# --------------------------------------------------------------------------- + + +class _FieldBuilder: + """Intermediate builder returned by :func:`field`; produces a :class:`Condition`.""" + + __slots__ = ("_path",) + + def __init__(self, path: str) -> None: + self._path = path + + def _leaf(self, op: str, value: Any = None) -> Condition: + return Condition(operator=op, field=self._path, value=value) + + def eq(self, value: Any) -> Condition: # noqa: ANN401 + """Equal.""" + return self._leaf("eq", value) + + def ne(self, value: Any) -> Condition: # noqa: ANN401 + """Not equal.""" + return self._leaf("ne", value) + + def gt(self, value: Any) -> Condition: # noqa: ANN401 + """Greater-than.""" + return self._leaf("gt", value) + + def ge(self, value: Any) -> Condition: # noqa: ANN401 + """Greater-than-or-equal.""" + return self._leaf("ge", value) + + def lt(self, value: Any) -> Condition: # noqa: ANN401 + """Less-than.""" + return self._leaf("lt", value) + + def le(self, value: Any) -> Condition: # noqa: ANN401 + """Less-than-or-equal.""" + return self._leaf("le", value) + + def in_(self, seq: Sequence[Any]) -> Condition: + """Membership: ``actual in seq``.""" + return self._leaf("in", list(seq)) + + def not_in(self, seq: Sequence[Any]) -> Condition: + """Non-membership: ``actual not in seq``.""" + return self._leaf("not_in", list(seq)) + + def regex(self, pattern: str) -> Condition: + """Regex search against the field value.""" + return self._leaf("regex", pattern) + + def between(self, lo: Any, hi: Any) -> Condition: # noqa: ANN401 + """Range check: ``lo <= actual <= hi``.""" + return self._leaf("between", [lo, hi]) + + def contains(self, value: Any) -> Condition: # noqa: ANN401 + """Collection/string containment.""" + return self._leaf("contains", value) + + def not_contains(self, value: Any) -> Condition: # noqa: ANN401 + """Inverse of :meth:`contains`.""" + return self._leaf("not_contains", value) + + def starts_with(self, prefix: str) -> Condition: + """String prefix test.""" + return self._leaf("starts_with", prefix) + + def ends_with(self, suffix: str) -> Condition: + """String suffix test.""" + return self._leaf("ends_with", suffix) + + def exists(self) -> Condition: + """True if the field is present and not None.""" + return self._leaf("exists") + + def is_null(self) -> Condition: + """True if the field is absent or None.""" + return self._leaf("is_null") + + def is_empty(self) -> Condition: + """True if the field is None, '', [], or {}.""" + return self._leaf("is_empty") + + +def field(path: str) -> _FieldBuilder: + """Return a :class:`_FieldBuilder` for *path* (dot-notation context access). + + Example:: + + field("order.total").ge(100) + """ + return _FieldBuilder(path) + + +def all_of(*conditions: Condition) -> Condition: + """Return an ``and`` compound condition (all children must be true).""" + return Condition(operator="and", children=list(conditions)) + + +def any_of(*conditions: Condition) -> Condition: + """Return an ``or`` compound condition (at least one child must be true).""" + return Condition(operator="or", children=list(conditions)) + + +def not_(condition: Condition) -> Condition: + """Return a ``not`` compound condition (negates exactly one child).""" + return Condition(operator="not", children=[condition]) + + +# --------------------------------------------------------------------------- +# Action helpers +# --------------------------------------------------------------------------- + + +def set_action(target: str, value: Any) -> Action: # noqa: ANN401 + """Return a ``set`` action that writes *value* to *target*.""" + return Action(type="set", target=target, value=value) + + +def increment_action(target: str, by: int | float = 1) -> Action: + """Return an ``increment`` action that adds *by* (default 1) to *target*.""" + return Action(type="increment", target=target, value=by) + + +def log_action(message: str) -> Action: + """Return a ``log`` action that logs *message*.""" + return Action(type="log", value=message) + + +# --------------------------------------------------------------------------- +# Rule builder +# --------------------------------------------------------------------------- + + +class RuleBuilder: + """Fluent builder for a single :class:`~pyfly.rule_engine.dsl.Rule`. + + Create via :func:`rule`:: + + rule("my-id").describe("…").priority(5).when(cond).then(action).build() + """ + + def __init__(self, rule_id: str) -> None: + self._id = rule_id + self._description: str = "" + self._priority: int = 0 + self._enabled: bool = True + self._when: Condition | None = None + self._then: list[Action] = [] + self._otherwise: list[Action] = [] + + def describe(self, text: str) -> RuleBuilder: + """Set the human-readable description.""" + self._description = text + return self + + def priority(self, n: int) -> RuleBuilder: + """Set evaluation priority (higher = evaluated first).""" + self._priority = n + return self + + def enabled(self, flag: bool) -> RuleBuilder: + """Enable or disable the rule (disabled rules are skipped).""" + self._enabled = flag + return self + + def when(self, condition: Condition) -> RuleBuilder: + """Set the condition that must be true for *then* actions to run.""" + self._when = condition + return self + + def then(self, *actions: Action) -> RuleBuilder: + """Append actions to execute when the condition matches.""" + self._then.extend(actions) + return self + + def otherwise(self, *actions: Action) -> RuleBuilder: + """Append actions to execute when the condition does not match.""" + self._otherwise.extend(actions) + return self + + def build(self) -> Rule: + """Return the constructed :class:`Rule`.""" + return Rule( + id=self._id, + description=self._description, + when=self._when, + then=list(self._then), + otherwise=list(self._otherwise), + priority=self._priority, + enabled=self._enabled, + ) + + +def rule(rule_id: str) -> RuleBuilder: + """Return a :class:`RuleBuilder` for *rule_id*.""" + return RuleBuilder(rule_id) + + +# --------------------------------------------------------------------------- +# RuleSet builder +# --------------------------------------------------------------------------- + + +class RuleSetBuilder: + """Fluent builder for a :class:`~pyfly.rule_engine.dsl.RuleSet`. + + Create via :func:`ruleset`:: + + ruleset("my-rs", name="My Rules").add(my_rule).build() + """ + + def __init__(self, ruleset_id: str, name: str = "", version: int = 1) -> None: + self._id = ruleset_id + self._name = name + self._version = version + self._rules: list[Rule] = [] + + def add(self, *rules: Rule) -> RuleSetBuilder: + """Append one or more rules to the rule set.""" + self._rules.extend(rules) + return self + + def build(self) -> RuleSet: + """Return the constructed :class:`RuleSet`.""" + return RuleSet( + id=self._id, + name=self._name, + version=self._version, + rules=list(self._rules), + ) + + +def ruleset(ruleset_id: str, name: str = "", version: int = 1) -> RuleSetBuilder: + """Return a :class:`RuleSetBuilder` for *ruleset_id*.""" + return RuleSetBuilder(ruleset_id, name=name, version=version) diff --git a/src/pyfly/rule_engine/dsl.py b/src/pyfly/rule_engine/dsl.py index 9ffabb4c..037bd38d 100644 --- a/src/pyfly/rule_engine/dsl.py +++ b/src/pyfly/rule_engine/dsl.py @@ -14,8 +14,36 @@ class Condition: """One condition node — either a leaf comparison or a logical compound. *field* is the path into the evaluation context (``order.amount``); - *operator* is one of ``eq``, ``ne``, ``gt``, ``ge``, ``lt``, ``le``, - ``in``, ``not_in``, ``regex`` (leaf) or ``and`` / ``or`` / ``not`` (compound). + *operator* is one of the leaf operators below or ``and`` / ``or`` / ``not`` + (compound, using *children* instead of *field*/*value*). + + **Comparison operators** (None-safe — a missing field reads as None and + never raises; the result is False unless noted otherwise): + + ``eq`` / ``ne`` + Equality / inequality. + ``gt`` / ``ge`` / ``lt`` / ``le`` + Numeric/comparable ordering; None → False. + ``in`` / ``not_in`` + Membership test against a list *value*. + ``regex`` + ``re.search(value, actual)``; coerces both sides to str. + ``between`` + *value* must be ``[lo, hi]``; true if ``lo <= actual <= hi``. None → False. + ``contains`` + For strings: ``value in actual``; for lists/collections: ``value in actual``. + None → False. + ``not_contains`` + Inverse of ``contains``. None → False. + ``starts_with`` / ``ends_with`` + String prefix / suffix check; coerces actual to str. None → False. + ``exists`` + True if the field is present **and** not None. *value* is ignored. + ``is_null`` + True if the field is absent or None. *value* is ignored. + ``is_empty`` + True if the field is None, an empty string, an empty list, or an empty + dict. *value* is ignored. """ operator: str @@ -110,3 +138,10 @@ def from_yaml(text: str) -> RuleSet: import yaml # type: ignore[import-untyped] return RuleSetLoader.from_dict(yaml.safe_load(text)) + + @staticmethod + def from_json(text: str) -> RuleSet: + """Parse a JSON string into a :class:`RuleSet`.""" + import json + + return RuleSetLoader.from_dict(json.loads(text)) diff --git a/src/pyfly/rule_engine/evaluator.py b/src/pyfly/rule_engine/evaluator.py index 2d01169c..059ff6ec 100644 --- a/src/pyfly/rule_engine/evaluator.py +++ b/src/pyfly/rule_engine/evaluator.py @@ -4,12 +4,44 @@ from __future__ import annotations +import logging import re +from collections.abc import Callable from dataclasses import dataclass, field +from enum import Enum from typing import Any from pyfly.rule_engine.dsl import Action, Condition, Rule, RuleSet +#: Type alias for action-handler callables stored in the internal registry. +_HandlerFn = Callable[[Action, dict[str, Any]], None] + + +def _make_default_handlers() -> dict[str, _HandlerFn]: + """Build the default action-handler registry (``set``, ``increment``, ``log``).""" + + def _handle_set(action: Action, ctx: dict[str, Any]) -> None: + if action.target is None: + msg = "set action missing 'target'" + raise ValueError(msg) + RuleEvaluator._write(action.target, action.value, ctx) + + def _handle_increment(action: Action, ctx: dict[str, Any]) -> None: + if action.target is None: + msg = "increment action missing 'target'" + raise ValueError(msg) + current = RuleEvaluator._read(action.target, ctx) or 0 + RuleEvaluator._write(action.target, current + (action.value or 1), ctx) + + def _handle_log(action: Action, ctx: dict[str, Any]) -> None: + logging.getLogger(__name__).info("rule action: %s", action.value or action.target) + + return { + "set": _handle_set, + "increment": _handle_increment, + "log": _handle_log, + } + @dataclass class EvaluationResult: @@ -20,7 +52,26 @@ class EvaluationResult: class RuleEvaluator: - """Single-rule evaluator.""" + """Single-rule evaluator with a pluggable action-handler registry. + + Parameters + ---------- + action_handlers: + Optional mapping of action-type strings to handler callables. Values + are merged on top of the built-in ``set`` / ``increment`` / ``log`` + handlers, so you can override builtins or add entirely new types (e.g. + ``"call"``, ``"http"``) without subclassing. Any action type not found + in the final registry raises :exc:`NotImplementedError`, matching the + original loud-failure semantics (audit #215). + """ + + def __init__( + self, + action_handlers: dict[str, _HandlerFn] | None = None, + ) -> None: + self._handlers: dict[str, _HandlerFn] = _make_default_handlers() + if action_handlers: + self._handlers.update(action_handlers) def evaluate(self, rule: Rule, ctx: dict[str, Any]) -> EvaluationResult: if not rule.enabled: @@ -84,32 +135,52 @@ def _eval_condition(self, c: Condition | None, ctx: dict[str, Any]) -> bool: return actual not in (expected or []) if op == "regex": return bool(re.search(str(expected), str(actual or ""))) + if op == "between": + if actual is None: + return False + lo, hi = expected[0], expected[1] + return bool(lo <= actual <= hi) + if op == "contains": + if actual is None: + return False + if isinstance(actual, str): + return str(expected) in actual + return expected in actual + if op == "not_contains": + if actual is None: + return False + if isinstance(actual, str): + return str(expected) not in actual + return expected not in actual + if op == "starts_with": + if actual is None: + return False + return str(actual).startswith(str(expected)) + if op == "ends_with": + if actual is None: + return False + return str(actual).endswith(str(expected)) + if op == "exists": + return actual is not None + if op == "is_null": + return actual is None + if op == "is_empty": + if actual is None: + return True + return bool(actual == "" or actual == [] or actual == {}) msg = f"unknown operator: {op}" raise ValueError(msg) def _execute_action(self, action: Action, ctx: dict[str, Any]) -> None: - if action.type == "set": - if action.target is None: - msg = "set action missing 'target'" - raise ValueError(msg) - self._write(action.target, action.value, ctx) - elif action.type == "increment": - if action.target is None: - msg = "increment action missing 'target'" - raise ValueError(msg) - current = self._read(action.target, ctx) or 0 - self._write(action.target, current + (action.value or 1), ctx) - elif action.type == "log": - import logging - - logging.getLogger(__name__).info("rule action: %s", action.value or action.target) - else: - # 'call'/'calculate' and any unknown type are not implemented by the - # default evaluator — fail loudly so a typo or an unsupported action - # surfaces instead of silently doing nothing (audit #215). Real - # services override _execute_action to plug in HTTP calls, etc. + handler = self._handlers.get(action.type) + if handler is None: + # 'call'/'calculate' and any unknown type are not in the default + # registry — fail loudly so a typo or an unsupported action surfaces + # instead of silently doing nothing (audit #215). Callers can inject + # custom handlers via the constructor to handle these types. msg = f"unsupported action type '{action.type}'; override _execute_action to handle it" raise NotImplementedError(msg) + handler(action, ctx) @staticmethod def _read(path: str, ctx: dict[str, Any]) -> Any: @@ -138,11 +209,54 @@ def _write(path: str, value: Any, ctx: dict[str, Any]) -> None: setattr(cur, last, value) +class EvaluationMode(Enum): + """Controls how many rules in a :class:`RuleSet` are evaluated. + + ``ALL`` + Every enabled rule in the ruleset is evaluated in descending priority + order (the current default). All matching rules execute their actions + against the **shared** context dict, so later rules see mutations made + by earlier ones. + + ``FIRST_MATCH`` + Rules are evaluated in descending priority order and evaluation stops + immediately after the first rule whose condition matched. The returned + result list contains every rule that was evaluated *up to and including* + the first match; rules with lower priority are never evaluated and their + actions never fire. The shared-context semantics are identical to + ``ALL`` for the subset of rules that *are* evaluated. + """ + + ALL = "all" + FIRST_MATCH = "first_match" + + class RuleSetEvaluator: - """Evaluates an entire :class:`RuleSet` in priority order.""" + """Evaluates an entire :class:`RuleSet` in priority order. + + Parameters + ---------- + rule_evaluator: + The per-rule evaluator to delegate to. Defaults to a vanilla + :class:`RuleEvaluator` (default action-handler registry). + mode: + :attr:`EvaluationMode.ALL` (default) evaluates every rule; + :attr:`EvaluationMode.FIRST_MATCH` stops after the first match. + """ - def __init__(self, rule_evaluator: RuleEvaluator | None = None) -> None: + def __init__( + self, + rule_evaluator: RuleEvaluator | None = None, + mode: EvaluationMode = EvaluationMode.ALL, + ) -> None: self._evaluator = rule_evaluator or RuleEvaluator() + self._mode = mode def evaluate(self, ruleset: RuleSet, ctx: dict[str, Any]) -> list[EvaluationResult]: - return [self._evaluator.evaluate(rule, ctx) for rule in ruleset.sorted_rules()] + results: list[EvaluationResult] = [] + for rule in ruleset.sorted_rules(): + result = self._evaluator.evaluate(rule, ctx) + results.append(result) + if self._mode is EvaluationMode.FIRST_MATCH and result.matched: + break + return results diff --git a/src/pyfly/rule_engine/ports/__init__.py b/src/pyfly/rule_engine/ports/__init__.py new file mode 100644 index 00000000..9e19975e --- /dev/null +++ b/src/pyfly/rule_engine/ports/__init__.py @@ -0,0 +1,18 @@ +# Copyright 2026 Firefly Software Foundation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Rule-engine port definitions.""" + +from pyfly.rule_engine.ports.outbound import ActionHandler, RuleEnginePort + +__all__ = ["ActionHandler", "RuleEnginePort"] diff --git a/src/pyfly/rule_engine/ports/outbound.py b/src/pyfly/rule_engine/ports/outbound.py new file mode 100644 index 00000000..babe0d92 --- /dev/null +++ b/src/pyfly/rule_engine/ports/outbound.py @@ -0,0 +1,57 @@ +# Copyright 2026 Firefly Software Foundation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Rule-engine outbound port protocols. + +:class:`ActionHandler` is the pluggable action-execution SPI — implement it to +add custom action types (``call``, ``calculate``, HTTP invocations, etc.) without +subclassing :class:`~pyfly.rule_engine.evaluator.RuleEvaluator`. + +:class:`RuleEnginePort` is the inbound-facing port that application code +depends on; :class:`~pyfly.rule_engine.service.RuleEngineService` satisfies it. +""" + +from __future__ import annotations + +from typing import Any, Protocol, runtime_checkable + +from pyfly.rule_engine.dsl import Action, RuleSet +from pyfly.rule_engine.evaluator import EvaluationResult + + +@runtime_checkable +class ActionHandler(Protocol): + """SPI for executing a single rule action. + + A handler is any *callable* taking ``(action, ctx)`` — a plain function, a + lambda, or an object with ``__call__`` — so it matches the registry shape + used by :class:`~pyfly.rule_engine.evaluator.RuleEvaluator` exactly. Each + handler is registered under its action *type* string (e.g. ``"call"``, + ``"http"``) in that evaluator's action-handler registry. The handler + receives the full :class:`Action` (so it can inspect ``target``, ``value``, + ``expression``, ``arguments``) and the mutable evaluation *context* dict. + """ + + def __call__(self, action: Action, ctx: dict[str, Any]) -> None: ... + + +@runtime_checkable +class RuleEnginePort(Protocol): + """Primary port for rule-set evaluation. + + Application code that drives rule evaluation depends on this abstraction + rather than on :class:`~pyfly.rule_engine.service.RuleEngineService` + directly, enabling test-doubles and alternate implementations. + """ + + def evaluate(self, ruleset: RuleSet, ctx: dict[str, Any]) -> list[EvaluationResult]: ... diff --git a/src/pyfly/rule_engine/service.py b/src/pyfly/rule_engine/service.py new file mode 100644 index 00000000..3a7e3b57 --- /dev/null +++ b/src/pyfly/rule_engine/service.py @@ -0,0 +1,180 @@ +# Copyright 2026 Firefly Software Foundation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Rule-engine service facade. + +:class:`RuleEngineService` is the primary application-facing entry point for +rule evaluation. It satisfies :class:`~pyfly.rule_engine.ports.outbound.RuleEnginePort` +and composes a :class:`~pyfly.rule_engine.repository.RuleSetRepository` for +persistence with a :class:`~pyfly.rule_engine.evaluator.RuleSetEvaluator` for +evaluation, optionally emitting Prometheus-compatible metrics via a +:class:`~pyfly.observability.ports.MetricsRecorder`. + +Metrics counters (all labelled by ``ruleset``) +----------------------------------------------- +``pyfly_rule_evaluations_total`` + Incremented once per :meth:`evaluate` / :meth:`evaluate_by_name` call. +``pyfly_rules_matched_total`` + Incremented for every result whose ``matched`` flag is ``True``. +``pyfly_rule_actions_fired_total`` + Incremented by the number of successfully-executed actions across all + results. +``pyfly_rule_errors_total`` + Incremented for every result that carries a non-``None`` ``error`` field. +""" + +from __future__ import annotations + +from typing import Any + +from pyfly.observability.ports import MetricsRecorder +from pyfly.rule_engine.dsl import RuleSet +from pyfly.rule_engine.evaluator import EvaluationResult, RuleSetEvaluator +from pyfly.rule_engine.repository import RuleSetRepository + + +class RuleSetNotFoundError(KeyError): + """Raised by :meth:`RuleEngineService.evaluate_by_name` when no ruleset exists. + + Inherits from :exc:`KeyError` so callers that already handle ``KeyError`` + continue to work without changes. + """ + + def __init__(self, ruleset_id: str) -> None: + self.ruleset_id = ruleset_id + super().__init__(ruleset_id) + + def __str__(self) -> str: + return f"RuleSet '{self.ruleset_id}' not found in repository" + + +class RuleEngineService: + """Facade that wires a :class:`RuleSetRepository` and a :class:`RuleSetEvaluator`. + + Parameters + ---------- + repository: + The :class:`~pyfly.rule_engine.repository.RuleSetRepository` used by + :meth:`evaluate_by_name`, :meth:`save_ruleset`, :meth:`get_ruleset`, + and :meth:`list_rulesets`. + evaluator: + The :class:`RuleSetEvaluator` to use for all evaluation calls. + Defaults to a vanilla :class:`RuleSetEvaluator` (``ALL`` mode, default + action-handler registry) when omitted. + metrics: + An optional :class:`~pyfly.observability.ports.MetricsRecorder`. When + provided the service creates four counters on construction and + increments them after every evaluation. Omitting the recorder (or + passing ``None``) is fully supported — the service operates as a + no-op with regard to metrics. + """ + + def __init__( + self, + repository: RuleSetRepository, + evaluator: RuleSetEvaluator | None = None, + *, + metrics: MetricsRecorder | None = None, + ) -> None: + self._repository = repository + self._evaluator = evaluator or RuleSetEvaluator() + + if metrics is not None: + self._evaluations = metrics.counter( + "pyfly_rule_evaluations_total", + "Total number of rule-set evaluation calls", + labels=["ruleset"], + ) + self._matched = metrics.counter( + "pyfly_rules_matched_total", + "Total number of rules that matched across all evaluations", + labels=["ruleset"], + ) + self._actions_fired = metrics.counter( + "pyfly_rule_actions_fired_total", + "Total number of actions successfully executed across all evaluations", + labels=["ruleset"], + ) + self._errors = metrics.counter( + "pyfly_rule_errors_total", + "Total number of rule results carrying an error across all evaluations", + labels=["ruleset"], + ) + self._metrics_enabled = True + else: + self._metrics_enabled = False + + # ------------------------------------------------------------------ + # Synchronous evaluation (satisfies RuleEnginePort) + # ------------------------------------------------------------------ + + def evaluate(self, ruleset: RuleSet, ctx: dict[str, Any]) -> list[EvaluationResult]: + """Evaluate *ruleset* against *ctx* and return the list of results. + + This method satisfies :class:`~pyfly.rule_engine.ports.outbound.RuleEnginePort`. + It is synchronous and can be called from any context (sync or async). + """ + results = self._evaluator.evaluate(ruleset, ctx) + self._record_metrics(ruleset.id, results) + return results + + # ------------------------------------------------------------------ + # Async facade methods + # ------------------------------------------------------------------ + + async def evaluate_by_name(self, ruleset_id: str, ctx: dict[str, Any]) -> list[EvaluationResult]: + """Load a ruleset by ID from the repository and evaluate it. + + Parameters + ---------- + ruleset_id: + The :attr:`~pyfly.rule_engine.dsl.RuleSet.id` to load. + ctx: + The mutable evaluation context dict. + + Raises + ------ + RuleSetNotFoundError + If the repository returns ``None`` for *ruleset_id*. + """ + ruleset = await self._repository.get(ruleset_id) + if ruleset is None: + raise RuleSetNotFoundError(ruleset_id) + return self.evaluate(ruleset, ctx) + + async def save_ruleset(self, rs: RuleSet) -> None: + """Persist *rs* to the repository.""" + await self._repository.save(rs) + + async def get_ruleset(self, ruleset_id: str) -> RuleSet | None: + """Return the ruleset with *ruleset_id*, or ``None`` if absent.""" + return await self._repository.get(ruleset_id) + + async def list_rulesets(self) -> list[RuleSet]: + """Return all persisted rulesets.""" + return await self._repository.list() + + # ------------------------------------------------------------------ + # Metrics helpers + # ------------------------------------------------------------------ + + def _record_metrics(self, ruleset_id: str, results: list[EvaluationResult]) -> None: + if not self._metrics_enabled: + return + self._evaluations.labels(ruleset=ruleset_id).inc() + for result in results: + if result.matched: + self._matched.labels(ruleset=ruleset_id).inc() + self._actions_fired.labels(ruleset=ruleset_id).inc(len(result.actions_executed)) + if result.error is not None: + self._errors.labels(ruleset=ruleset_id).inc() diff --git a/src/pyfly/rule_engine/validation.py b/src/pyfly/rule_engine/validation.py new file mode 100644 index 00000000..fa8a90a0 --- /dev/null +++ b/src/pyfly/rule_engine/validation.py @@ -0,0 +1,169 @@ +# Copyright 2026 Firefly Software Foundation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Static validation for :class:`~pyfly.rule_engine.dsl.RuleSet` objects. + +Use :func:`validate_ruleset` to get a list of human-readable issues, or +:class:`RuleSetValidator` for an object-oriented interface that also provides +:meth:`~RuleSetValidator.assert_valid` (raises :class:`RuleValidationError`). + +Example:: + + from pyfly.rule_engine.validation import RuleSetValidator, RuleValidationError + + issues = RuleSetValidator.check(my_ruleset) + if issues: + raise RuleValidationError(my_ruleset.id, issues) +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from pyfly.rule_engine.dsl import Action, Condition, RuleSet + +# --------------------------------------------------------------------------- +# Known operator/action sets +# --------------------------------------------------------------------------- + +_LEAF_OPERATORS: frozenset[str] = frozenset( + { + "eq", + "ne", + "gt", + "ge", + "lt", + "le", + "in", + "not_in", + "regex", + "between", + "contains", + "not_contains", + "starts_with", + "ends_with", + "exists", + "is_null", + "is_empty", + } +) + +_COMPOUND_OPERATORS: frozenset[str] = frozenset({"and", "or", "not"}) + +_KNOWN_ACTION_TYPES: frozenset[str] = frozenset({"set", "increment", "log", "call", "calculate"}) + +_TARGET_REQUIRED: frozenset[str] = frozenset({"set", "increment"}) + + +# --------------------------------------------------------------------------- +# Public exception +# --------------------------------------------------------------------------- + + +class RuleValidationError(ValueError): + """Raised by :meth:`RuleSetValidator.assert_valid` when issues are found.""" + + def __init__(self, ruleset_id: str, issues: Sequence[str]) -> None: + self.ruleset_id = ruleset_id + self.issues = list(issues) + joined = "; ".join(issues) + super().__init__(f"RuleSet '{ruleset_id}' has {len(issues)} validation issue(s): {joined}") + + +# --------------------------------------------------------------------------- +# Validator +# --------------------------------------------------------------------------- + + +def validate_ruleset(ruleset: RuleSet) -> list[str]: + """Return a list of human-readable validation issues for *ruleset*. + + An empty list means the ruleset is valid. Issues include: + + * Duplicate rule IDs. + * An unknown leaf operator (not in the supported set). + * A compound op (``and`` / ``or``) with no children, or ``not`` with + a child count other than 1. + * A ``set`` or ``increment`` action missing *target*. + * A ``between`` condition whose *value* is not a 2-element sequence. + * An unknown action type. + """ + return RuleSetValidator.check(ruleset) + + +class RuleSetValidator: + """Object-oriented wrapper around :func:`validate_ruleset`.""" + + @staticmethod + def check(ruleset: RuleSet) -> list[str]: + """Return validation issues; empty list = valid.""" + issues: list[str] = [] + seen_ids: set[str] = set() + + for rule in ruleset.rules: + if rule.id in seen_ids: + issues.append(f"duplicate rule id '{rule.id}'") + else: + seen_ids.add(rule.id) + + if rule.when is not None: + _check_condition(rule.when, rule.id, issues) + + for action in rule.then: + _check_action(action, rule.id, "then", issues) + for action in rule.otherwise: + _check_action(action, rule.id, "otherwise", issues) + + return issues + + @staticmethod + def assert_valid(ruleset: RuleSet) -> None: + """Raise :class:`RuleValidationError` if there are any issues.""" + issues = RuleSetValidator.check(ruleset) + if issues: + raise RuleValidationError(ruleset.id, issues) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _check_condition(cond: Condition, rule_id: str, issues: list[str]) -> None: + op = cond.operator + if op in _COMPOUND_OPERATORS: + if op == "not": + if len(cond.children) != 1: + issues.append(f"rule '{rule_id}': 'not' requires exactly 1 child, got {len(cond.children)}") + else: + if len(cond.children) == 0: + issues.append(f"rule '{rule_id}': compound op '{op}' has no children") + for child in cond.children: + _check_condition(child, rule_id, issues) + elif op not in _LEAF_OPERATORS: + issues.append(f"rule '{rule_id}': unknown operator '{op}'") + else: + if op == "between": + v = cond.value + try: + if not hasattr(v, "__len__") or len(v) != 2: + issues.append(f"rule '{rule_id}': 'between' value must be a 2-element sequence, got {v!r}") + except TypeError: + issues.append(f"rule '{rule_id}': 'between' value must be a 2-element sequence, got {v!r}") + + +def _check_action(action: Action, rule_id: str, branch: str, issues: list[str]) -> None: + if action.type not in _KNOWN_ACTION_TYPES: + issues.append(f"rule '{rule_id}' ({branch}): unknown action type '{action.type}'") + if action.type in _TARGET_REQUIRED and not action.target: + issues.append(f"rule '{rule_id}' ({branch}): '{action.type}' action missing 'target'") diff --git a/tests/rule_engine/test_action_handler_protocol.py b/tests/rule_engine/test_action_handler_protocol.py new file mode 100644 index 00000000..5805ac3d --- /dev/null +++ b/tests/rule_engine/test_action_handler_protocol.py @@ -0,0 +1,55 @@ +# Copyright 2026 Firefly Software Foundation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The ActionHandler SPI is a __call__ protocol: a plain function AND a __call__ +object both satisfy it and work as registered handlers (review fix — the protocol +shape now matches the registry's Callable shape).""" + +from __future__ import annotations + +from typing import Any + +from pyfly.rule_engine.dsl import Action, Rule +from pyfly.rule_engine.evaluator import RuleEvaluator +from pyfly.rule_engine.ports.outbound import ActionHandler + + +def _plain_handler(action: Action, ctx: dict[str, Any]) -> None: + ctx["fired"] = action.target + + +class _CallableHandler: + def __call__(self, action: Action, ctx: dict[str, Any]) -> None: + ctx["fired"] = action.target + + +def test_plain_function_satisfies_action_handler_protocol() -> None: + # runtime_checkable __call__ protocol — any callable qualifies. + assert isinstance(_plain_handler, ActionHandler) + assert isinstance(_CallableHandler(), ActionHandler) + + +def test_plain_function_works_as_registered_handler() -> None: + evaluator = RuleEvaluator(action_handlers={"call": _plain_handler}) + ctx: dict[str, Any] = {} + result = evaluator.evaluate(Rule(id="r", then=[Action(type="call", target="audit")]), ctx) + assert result.error is None + assert ctx["fired"] == "audit" + + +def test_callable_object_works_as_registered_handler() -> None: + evaluator = RuleEvaluator(action_handlers={"call": _CallableHandler()}) + ctx: dict[str, Any] = {} + result = evaluator.evaluate(Rule(id="r", then=[Action(type="call", target="audit")]), ctx) + assert result.error is None + assert ctx["fired"] == "audit" diff --git a/tests/rule_engine/test_action_handlers.py b/tests/rule_engine/test_action_handlers.py new file mode 100644 index 00000000..8c2751ab --- /dev/null +++ b/tests/rule_engine/test_action_handlers.py @@ -0,0 +1,132 @@ +# Copyright 2026 Firefly Software Foundation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for the pluggable action-handler registry (SP-13 Part B Item 1).""" + +from __future__ import annotations + +from typing import Any + +from pyfly.rule_engine.dsl import Action, Rule +from pyfly.rule_engine.evaluator import RuleEvaluator + + +class TestCustomHandlerInjection: + def test_custom_call_handler_is_invoked_and_mutates_ctx(self) -> None: + """A ``call``-type action registered at construction is executed.""" + + def _call_handler(action: Action, ctx: dict[str, Any]) -> None: + # Simulate an RPC result being written back into context + ctx["call_result"] = f"called:{action.target}" + + evaluator = RuleEvaluator(action_handlers={"call": _call_handler}) + rule = Rule(id="r", then=[Action(type="call", target="my_service")]) + ctx: dict[str, Any] = {} + result = evaluator.evaluate(rule, ctx) + + assert ctx["call_result"] == "called:my_service" + assert result.error is None + assert [a.type for a in result.actions_executed] == ["call"] + + def test_custom_handler_receives_full_action(self) -> None: + """The handler receives the complete Action including arguments.""" + received: list[Action] = [] + + def _capture(action: Action, ctx: dict[str, Any]) -> None: + received.append(action) + + evaluator = RuleEvaluator(action_handlers={"http": _capture}) + action = Action( + type="http", + target="https://example.com", + value="POST", + arguments={"body": "hello"}, + ) + rule = Rule(id="r", then=[action]) + evaluator.evaluate(rule, {}) + + assert len(received) == 1 + assert received[0].target == "https://example.com" + assert received[0].arguments == {"body": "hello"} + + def test_builtin_set_still_works_with_custom_handlers(self) -> None: + """Custom handlers are additive — built-ins remain functional.""" + ran: list[str] = [] + + def _noop(action: Action, ctx: dict[str, Any]) -> None: + ran.append(action.type) + + evaluator = RuleEvaluator(action_handlers={"noop": _noop}) + rule = Rule( + id="r", + then=[ + Action(type="set", target="x", value=42), + Action(type="noop"), + ], + ) + ctx: dict[str, Any] = {} + result = evaluator.evaluate(rule, ctx) + + assert ctx["x"] == 42 + assert "noop" in ran + assert result.error is None + + +class TestUnregisteredActionType: + def test_unregistered_type_raises_not_implemented_and_is_isolated(self) -> None: + """An unregistered action type raises NotImplementedError (audit #215). + + The error is recorded in the result, and sibling actions still execute + (audit #216 — isolation preserved). + """ + rule = Rule( + id="r", + then=[ + Action(type="unknown_xyz", target="irrelevant"), + Action(type="set", target="ok", value=True), + ], + ) + ctx: dict[str, Any] = {} + result = RuleEvaluator().evaluate(rule, ctx) + + assert ctx.get("ok") is True, "sibling 'set' should still execute" + assert result.error is not None + assert "unknown_xyz" in result.error + assert [a.type for a in result.actions_executed] == ["set"] + + def test_custom_evaluator_unregistered_type_still_raises(self) -> None: + """Even with custom handlers loaded, unknown types still raise.""" + evaluator = RuleEvaluator(action_handlers={"call": lambda a, c: None}) + rule = Rule(id="r", then=[Action(type="calculate", target="x")]) + ctx: dict[str, Any] = {} + result = evaluator.evaluate(rule, ctx) + + assert result.error is not None + assert "calculate" in result.error + assert result.actions_executed == [] + + def test_custom_handler_override_replaces_builtin(self) -> None: + """A custom handler with the same key as a built-in overrides it.""" + shadow_called: list[bool] = [] + + def _shadow_set(action: Action, ctx: dict[str, Any]) -> None: + shadow_called.append(True) + # intentionally do NOT write to ctx — just prove override worked + + evaluator = RuleEvaluator(action_handlers={"set": _shadow_set}) + rule = Rule(id="r", then=[Action(type="set", target="x", value=99)]) + ctx: dict[str, Any] = {} + evaluator.evaluate(rule, ctx) + + assert shadow_called, "override should have been called" + assert "x" not in ctx, "original set logic should NOT have run" diff --git a/tests/rule_engine/test_builder.py b/tests/rule_engine/test_builder.py new file mode 100644 index 00000000..7005ede5 --- /dev/null +++ b/tests/rule_engine/test_builder.py @@ -0,0 +1,323 @@ +# Copyright 2026 Firefly Software Foundation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for the fluent builder DSL (SP-13 Part A, Item 2). + +Each builder-constructed rule is evaluated through RuleSetEvaluator and its +result is compared to an equivalent rule built directly from dataclasses to +confirm behavioural identity. +""" + +from __future__ import annotations + +from pyfly.rule_engine import ( + Action, + Condition, + EvaluationResult, + Rule, + RuleSet, + RuleSetEvaluator, +) +from pyfly.rule_engine.builder import ( + all_of, + any_of, + field, + increment_action, + log_action, + not_, + rule, + ruleset, + set_action, +) + + +def _run(r: Rule, ctx: dict) -> EvaluationResult: # type: ignore[type-arg] + rs = RuleSet(id="test-rs", rules=[r]) + results = RuleSetEvaluator().evaluate(rs, ctx) + return results[0] + + +# --------------------------------------------------------------------------- +# Leaf operator helpers +# --------------------------------------------------------------------------- + + +class TestFieldBuilderLeafOps: + """Each helper method on _FieldBuilder produces the right Condition.""" + + def test_eq(self) -> None: + c = field("x").eq(5) + assert c.operator == "eq" and c.field == "x" and c.value == 5 + + def test_ne(self) -> None: + c = field("x").ne(5) + assert c.operator == "ne" + + def test_gt(self) -> None: + c = field("x").gt(3) + assert c.operator == "gt" and c.value == 3 + + def test_ge(self) -> None: + assert field("x").ge(3).operator == "ge" + + def test_lt(self) -> None: + assert field("x").lt(3).operator == "lt" + + def test_le(self) -> None: + assert field("x").le(3).operator == "le" + + def test_in_(self) -> None: + c = field("x").in_(["a", "b"]) + assert c.operator == "in" and c.value == ["a", "b"] + + def test_not_in(self) -> None: + c = field("x").not_in(["a"]) + assert c.operator == "not_in" + + def test_regex(self) -> None: + c = field("x").regex("^A") + assert c.operator == "regex" and c.value == "^A" + + def test_between(self) -> None: + c = field("x").between(1, 10) + assert c.operator == "between" and c.value == [1, 10] + + def test_contains(self) -> None: + c = field("x").contains("hello") + assert c.operator == "contains" and c.value == "hello" + + def test_starts_with(self) -> None: + c = field("x").starts_with("pre-") + assert c.operator == "starts_with" + + def test_ends_with(self) -> None: + c = field("x").ends_with(".pdf") + assert c.operator == "ends_with" + + def test_exists(self) -> None: + c = field("x").exists() + assert c.operator == "exists" and c.value is None + + def test_is_null(self) -> None: + assert field("x").is_null().operator == "is_null" + + def test_is_empty(self) -> None: + assert field("x").is_empty().operator == "is_empty" + + +# --------------------------------------------------------------------------- +# Compound helpers +# --------------------------------------------------------------------------- + + +class TestCompoundHelpers: + def test_all_of_produces_and(self) -> None: + c = all_of(field("a").eq(1), field("b").eq(2)) + assert c.operator == "and" and len(c.children) == 2 + + def test_any_of_produces_or(self) -> None: + c = any_of(field("a").eq(1), field("b").eq(2)) + assert c.operator == "or" + + def test_not_produces_not(self) -> None: + c = not_(field("x").eq(True)) + assert c.operator == "not" and len(c.children) == 1 + + +# --------------------------------------------------------------------------- +# Action helpers +# --------------------------------------------------------------------------- + + +class TestActionHelpers: + def test_set_action(self) -> None: + a = set_action("flags.ok", True) + assert a.type == "set" and a.target == "flags.ok" and a.value is True + + def test_increment_action_default(self) -> None: + a = increment_action("count") + assert a.type == "increment" and a.target == "count" and a.value == 1 + + def test_increment_action_custom(self) -> None: + a = increment_action("count", by=5) + assert a.value == 5 + + def test_log_action(self) -> None: + a = log_action("hello") + assert a.type == "log" and a.value == "hello" + + +# --------------------------------------------------------------------------- +# RuleBuilder — basic build + evaluation +# --------------------------------------------------------------------------- + + +class TestRuleBuilder: + def test_build_returns_rule_with_correct_fields(self) -> None: + r = ( + rule("my-rule") + .describe("test rule") + .priority(5) + .enabled(True) + .when(field("x").gt(0)) + .then(set_action("result", "positive")) + .otherwise(set_action("result", "non-positive")) + .build() + ) + assert r.id == "my-rule" + assert r.description == "test rule" + assert r.priority == 5 + assert r.enabled is True + assert r.when is not None + assert len(r.then) == 1 + assert len(r.otherwise) == 1 + + def test_then_actions_run_on_match(self) -> None: + r = ( + rule("r") + .when(field("score").ge(100)) + .then(set_action("tier", "gold")) + .otherwise(set_action("tier", "silver")) + .build() + ) + ctx: dict = {"score": 150} + res = _run(r, ctx) + assert res.matched is True + assert ctx["tier"] == "gold" + + def test_otherwise_runs_on_no_match(self) -> None: + r = ( + rule("r") + .when(field("score").ge(100)) + .then(set_action("tier", "gold")) + .otherwise(set_action("tier", "silver")) + .build() + ) + ctx: dict = {"score": 50} + res = _run(r, ctx) + assert res.matched is False + assert ctx["tier"] == "silver" + + def test_disabled_rule_is_skipped(self) -> None: + r = rule("r").enabled(False).when(field("x").eq(1)).then(set_action("ran", True)).build() + ctx: dict = {"x": 1} + res = _run(r, ctx) + assert res.matched is False + assert "ran" not in ctx + + +# --------------------------------------------------------------------------- +# Complex nested all_of / any_of / not_ +# --------------------------------------------------------------------------- + + +class TestNestedConditions: + """Builder-constructed nested rule must behave identically to the equivalent + dataclass rule.""" + + def _dataclass_rule(self) -> Rule: + """Equivalent rule built from raw dataclasses (the reference).""" + return Rule( + id="dc-rule", + when=Condition( + operator="and", + children=[ + Condition(operator="ge", field="order.total", value=200), + Condition( + operator="or", + children=[ + Condition(operator="eq", field="customer.tier", value="gold"), + Condition( + operator="not", + children=[Condition(operator="eq", field="customer.blocked", value=True)], + ), + ], + ), + ], + ), + then=[Action(type="set", target="order.discount_pct", value=15)], + otherwise=[Action(type="set", target="order.discount_pct", value=0)], + ) + + def _builder_rule(self) -> Rule: + return ( + rule("builder-rule") + .when( + all_of( + field("order.total").ge(200), + any_of( + field("customer.tier").eq("gold"), + not_(field("customer.blocked").eq(True)), + ), + ) + ) + .then(set_action("order.discount_pct", 15)) + .otherwise(set_action("order.discount_pct", 0)) + .build() + ) + + def _run_both(self, ctx: dict) -> tuple[bool, bool]: # type: ignore[type-arg] + ev = RuleSetEvaluator() + dc_res = ev.evaluate(RuleSet(id="dc", rules=[self._dataclass_rule()]), ctx) + bu_res = ev.evaluate(RuleSet(id="bu", rules=[self._builder_rule()]), dict(ctx)) + return dc_res[0].matched, bu_res[0].matched + + def test_both_match_high_spend_gold(self) -> None: + ctx = {"order": {"total": 300}, "customer": {"tier": "gold", "blocked": False}} + dc_m, bu_m = self._run_both(ctx) + assert dc_m is True and bu_m is True + + def test_both_match_high_spend_not_blocked(self) -> None: + ctx = {"order": {"total": 250}, "customer": {"tier": "silver", "blocked": False}} + dc_m, bu_m = self._run_both(ctx) + assert dc_m is True and bu_m is True + + def test_both_no_match_low_spend(self) -> None: + ctx = {"order": {"total": 50}, "customer": {"tier": "gold", "blocked": False}} + dc_m, bu_m = self._run_both(ctx) + assert dc_m is False and bu_m is False + + def test_both_no_match_high_spend_blocked_non_gold(self) -> None: + ctx = {"order": {"total": 300}, "customer": {"tier": "silver", "blocked": True}} + dc_m, bu_m = self._run_both(ctx) + assert dc_m is False and bu_m is False + + def test_discount_written_identically(self) -> None: + """Actions mutate context the same way for both rules.""" + ctx_dc: dict = {"order": {"total": 300}, "customer": {"tier": "gold", "blocked": False}} + ctx_bu: dict = {"order": {"total": 300}, "customer": {"tier": "gold", "blocked": False}} + RuleSetEvaluator().evaluate(RuleSet(id="dc", rules=[self._dataclass_rule()]), ctx_dc) + RuleSetEvaluator().evaluate(RuleSet(id="bu", rules=[self._builder_rule()]), ctx_bu) + assert ctx_dc["order"]["discount_pct"] == ctx_bu["order"]["discount_pct"] == 15 + + +# --------------------------------------------------------------------------- +# RuleSetBuilder +# --------------------------------------------------------------------------- + + +class TestRuleSetBuilder: + def test_build_contains_rules_in_order(self) -> None: + r1 = rule("r1").priority(1).build() + r2 = rule("r2").priority(10).build() + rs = ruleset("my-rs", name="My Rules", version=2).add(r1, r2).build() + assert rs.id == "my-rs" + assert rs.name == "My Rules" + assert rs.version == 2 + assert [r.id for r in rs.rules] == ["r1", "r2"] + + def test_sorted_rules_from_builder(self) -> None: + r1 = rule("low").priority(1).build() + r2 = rule("high").priority(10).build() + rs = ruleset("rs").add(r1, r2).build() + assert [r.id for r in rs.sorted_rules()] == ["high", "low"] diff --git a/tests/rule_engine/test_end_to_end.py b/tests/rule_engine/test_end_to_end.py new file mode 100644 index 00000000..0933299c --- /dev/null +++ b/tests/rule_engine/test_end_to_end.py @@ -0,0 +1,771 @@ +# Copyright 2026 Firefly Software Foundation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""End-to-end integration test for the rule engine (SP-13 Part C Item 1). + +Exercises the full stack together — fluent builder, YAML DSL, loader, +validator, InMemoryRuleSetRepository, RuleEngineService, custom action +handlers, EvaluationMode.ALL / FIRST_MATCH, and MetricsRecorder — without +any external infrastructure (no Docker, no HTTP, no Prometheus server). + +Scenario: *order-processing* ruleset +------------------------------------- +Three rules evaluated against order-processing contexts: + +``high-value`` (priority 20) + Condition: ``order.amount >= 5000`` (between 5000–999999) + then: set ``flags.high_value=True`` + increment ``score`` by 10 + otherwise: set ``flags.high_value=False`` + +``region-blocked`` (priority 15) + Condition: ``order.region`` in ["RU", "KP", "IR"] + then: set ``flags.blocked=True`` + otherwise: set ``flags.blocked=False`` + +``fraud-pattern`` (priority 10) + Condition: ``order.email`` regex ``.*@temp.*\\..*`` + then: set ``flags.fraud_suspected=True`` + call ``audit`` handler + otherwise: set ``flags.fraud_suspected=False`` + +Both a fluent-builder ruleset and a YAML-loaded ruleset are constructed and +asserted equivalent before any evaluation. +""" + +from __future__ import annotations + +import textwrap +from typing import Any + +import pytest + +from pyfly.rule_engine.builder import ( + all_of, + field, + increment_action, + rule, + ruleset, + set_action, +) +from pyfly.rule_engine.dsl import Action, RuleSetLoader +from pyfly.rule_engine.evaluator import EvaluationMode, RuleEvaluator, RuleSetEvaluator +from pyfly.rule_engine.repository import InMemoryRuleSetRepository +from pyfly.rule_engine.service import RuleEngineService, RuleSetNotFoundError +from pyfly.rule_engine.validation import validate_ruleset + +# --------------------------------------------------------------------------- +# Test doubles +# --------------------------------------------------------------------------- + + +class _FakeCounter: + """Minimal counter double that records .labels(**kw).inc(amount) calls.""" + + def __init__(self) -> None: + self.calls: list[tuple[dict[str, Any], float]] = [] + self._current_labels: dict[str, Any] = {} + + def labels(self, **kwargs: Any) -> _FakeCounter: + self._current_labels = dict(kwargs) + return self + + def inc(self, amount: float = 1) -> None: + self.calls.append((dict(self._current_labels), amount)) + + @property + def total(self) -> float: + return sum(v for _, v in self.calls) + + +class _FakeMetricsRecorder: + """Minimal MetricsRecorder double that captures counter creations and increments.""" + + def __init__(self) -> None: + self.counters: dict[str, _FakeCounter] = {} + + def counter(self, name: str, description: str, labels: list[str] | None = None) -> _FakeCounter: + if name not in self.counters: + self.counters[name] = _FakeCounter() + return self.counters[name] + + def histogram(self, name: str, description: str, labels: list[str] | None = None, buckets: Any = None) -> Any: + return _FakeCounter() + + def gauge(self, name: str, description: str, labels: list[str] | None = None) -> Any: + return _FakeCounter() + + +# --------------------------------------------------------------------------- +# Audit handler (custom "call"-type action) +# --------------------------------------------------------------------------- + + +class _AuditLog: + """Captures 'call'-type audit side-effects during evaluation.""" + + def __init__(self) -> None: + self.events: list[dict[str, Any]] = [] + + def handle(self, action: Action, ctx: dict[str, Any]) -> None: + """Record the action arguments and the current order id as an audit event.""" + self.events.append( + { + "event": action.arguments.get("event", action.target), + "order_id": ctx.get("order", {}).get("id"), + } + ) + + +# --------------------------------------------------------------------------- +# YAML document +# --------------------------------------------------------------------------- + +_ORDER_PROCESSING_YAML = textwrap.dedent("""\ + id: order-processing + name: Order Processing Rules + version: 1 + rules: + - id: high-value + description: Flag high-value orders and boost risk score + priority: 20 + when: + op: between + field: order.amount + value: [5000, 999999] + then: + - { type: set, target: flags.high_value, value: true } + - { type: increment, target: score, value: 10 } + otherwise: + - { type: set, target: flags.high_value, value: false } + + - id: region-blocked + description: Block orders from sanctioned regions + priority: 15 + when: + op: in + field: order.region + value: ["RU", "KP", "IR"] + then: + - { type: set, target: flags.blocked, value: true } + otherwise: + - { type: set, target: flags.blocked, value: false } + + - id: fraud-pattern + description: Detect disposable-email fraud pattern + priority: 10 + when: + op: regex + field: order.email + value: ".*@temp.*\\\\..*" + then: + - { type: set, target: flags.fraud_suspected, value: true } + - type: call + target: fraud-audit + arguments: { event: fraud_pattern_matched } + otherwise: + - { type: set, target: flags.fraud_suspected, value: false } +""") + + +# --------------------------------------------------------------------------- +# Builder-based ruleset factory +# --------------------------------------------------------------------------- + + +def _build_ruleset_via_builder() -> Any: + """Build the order-processing ruleset using the fluent builder API.""" + audit_action = Action( + type="call", + target="fraud-audit", + arguments={"event": "fraud_pattern_matched"}, + ) + + high_value_rule = ( + rule("high-value") + .describe("Flag high-value orders and boost risk score") + .priority(20) + .when(field("order.amount").between(5000, 999999)) + .then(set_action("flags.high_value", True), increment_action("score", 10)) + .otherwise(set_action("flags.high_value", False)) + .build() + ) + + region_blocked_rule = ( + rule("region-blocked") + .describe("Block orders from sanctioned regions") + .priority(15) + .when(field("order.region").in_(["RU", "KP", "IR"])) + .then(set_action("flags.blocked", True)) + .otherwise(set_action("flags.blocked", False)) + .build() + ) + + fraud_rule = ( + rule("fraud-pattern") + .describe("Detect disposable-email fraud pattern") + .priority(10) + .when(field("order.email").regex(r".*@temp.*\..*")) + .then(set_action("flags.fraud_suspected", True), audit_action) + .otherwise(set_action("flags.fraud_suspected", False)) + .build() + ) + + return ( + ruleset("order-processing", name="Order Processing Rules", version=1) + .add(high_value_rule, region_blocked_rule, fraud_rule) + .build() + ) + + +# --------------------------------------------------------------------------- +# Helper: fresh service wired to InMemoryRuleSetRepository +# --------------------------------------------------------------------------- + + +def _make_service( + mode: EvaluationMode = EvaluationMode.ALL, + audit_log: _AuditLog | None = None, + metrics: _FakeMetricsRecorder | None = None, +) -> RuleEngineService: + """Create a fully-wired RuleEngineService for tests.""" + extra_handlers: dict[str, Any] = {} + if audit_log is not None: + extra_handlers["call"] = audit_log.handle + + evaluator = RuleEvaluator(action_handlers=extra_handlers if extra_handlers else None) + set_evaluator = RuleSetEvaluator(rule_evaluator=evaluator, mode=mode) + repo = InMemoryRuleSetRepository() + return RuleEngineService(repository=repo, evaluator=set_evaluator, metrics=metrics) + + +# =========================================================================== +# Test class 1 — Builder / YAML equivalence + validation +# =========================================================================== + + +class TestBuilderYamlEquivalence: + """The builder and the YAML loader produce equivalent RuleSets.""" + + def test_same_id_name_version(self) -> None: + builder_rs = _build_ruleset_via_builder() + yaml_rs = RuleSetLoader.from_yaml(_ORDER_PROCESSING_YAML) + + assert builder_rs.id == yaml_rs.id + assert builder_rs.name == yaml_rs.name + assert builder_rs.version == yaml_rs.version + + def test_same_rule_count_and_ids(self) -> None: + builder_rs = _build_ruleset_via_builder() + yaml_rs = RuleSetLoader.from_yaml(_ORDER_PROCESSING_YAML) + + builder_ids = [r.id for r in builder_rs.rules] + yaml_ids = [r.id for r in yaml_rs.rules] + assert builder_ids == yaml_ids + + def test_same_priorities(self) -> None: + builder_rs = _build_ruleset_via_builder() + yaml_rs = RuleSetLoader.from_yaml(_ORDER_PROCESSING_YAML) + + for br, yr in zip(builder_rs.rules, yaml_rs.rules, strict=True): + assert br.priority == yr.priority, f"priority mismatch for rule '{br.id}'" + + def test_same_condition_operators_and_fields(self) -> None: + builder_rs = _build_ruleset_via_builder() + yaml_rs = RuleSetLoader.from_yaml(_ORDER_PROCESSING_YAML) + + for br, yr in zip(builder_rs.rules, yaml_rs.rules, strict=True): + assert br.when is not None and yr.when is not None + assert br.when.operator == yr.when.operator, f"operator mismatch for rule '{br.id}'" + assert br.when.field == yr.when.field, f"field mismatch for rule '{br.id}'" + + def test_same_then_action_types(self) -> None: + builder_rs = _build_ruleset_via_builder() + yaml_rs = RuleSetLoader.from_yaml(_ORDER_PROCESSING_YAML) + + for br, yr in zip(builder_rs.rules, yaml_rs.rules, strict=True): + b_types = [a.type for a in br.then] + y_types = [a.type for a in yr.then] + assert b_types == y_types, f"then-action types mismatch for rule '{br.id}'" + + def test_validation_yields_no_issues(self) -> None: + """Both representations pass validation cleanly.""" + builder_rs = _build_ruleset_via_builder() + yaml_rs = RuleSetLoader.from_yaml(_ORDER_PROCESSING_YAML) + + assert validate_ruleset(builder_rs) == [] + assert validate_ruleset(yaml_rs) == [] + + +# =========================================================================== +# Test class 2 — EvaluationMode.ALL full-stack scenario +# =========================================================================== + + +class TestAllModeFullStack: + """save_ruleset + evaluate_by_name with ALL mode across varied order contexts.""" + + @pytest.mark.asyncio + async def test_high_value_order_sets_flag_and_increments_score(self) -> None: + audit_log = _AuditLog() + svc = _make_service(mode=EvaluationMode.ALL, audit_log=audit_log) + rs = _build_ruleset_via_builder() + await svc.save_ruleset(rs) + + ctx: dict[str, Any] = { + "order": {"id": "ORD-001", "amount": 9000, "region": "US", "email": "alice@example.com"}, + "score": 0, + "flags": {}, + } + results = await svc.evaluate_by_name("order-processing", ctx) + + # Three rules evaluated in ALL mode + assert len(results) == 3 + + # high-value rule matched — flag + score + assert ctx["flags"]["high_value"] is True + assert ctx["score"] == 10 + + # region not blocked + assert ctx["flags"]["blocked"] is False + + # email not fraud + assert ctx["flags"]["fraud_suspected"] is False + + # audit handler did NOT fire (no fraud match) + assert len(audit_log.events) == 0 + + @pytest.mark.asyncio + async def test_low_value_order_does_not_set_high_value_flag(self) -> None: + svc = _make_service(mode=EvaluationMode.ALL) + rs = _build_ruleset_via_builder() + await svc.save_ruleset(rs) + + ctx: dict[str, Any] = { + "order": {"id": "ORD-002", "amount": 100, "region": "US", "email": "bob@example.com"}, + "score": 0, + "flags": {}, + } + await svc.evaluate_by_name("order-processing", ctx) + + assert ctx["flags"]["high_value"] is False + assert ctx["score"] == 0 + + @pytest.mark.asyncio + async def test_blocked_region_sets_flag(self) -> None: + svc = _make_service(mode=EvaluationMode.ALL) + rs = _build_ruleset_via_builder() + await svc.save_ruleset(rs) + + ctx: dict[str, Any] = { + "order": {"id": "ORD-003", "amount": 200, "region": "KP", "email": "x@example.com"}, + "score": 0, + "flags": {}, + } + await svc.evaluate_by_name("order-processing", ctx) + + assert ctx["flags"]["blocked"] is True + + @pytest.mark.asyncio + async def test_fraud_email_fires_custom_audit_handler(self) -> None: + """A ``call``-type action triggers the injected audit handler.""" + audit_log = _AuditLog() + svc = _make_service(mode=EvaluationMode.ALL, audit_log=audit_log) + rs = _build_ruleset_via_builder() + await svc.save_ruleset(rs) + + ctx: dict[str, Any] = { + "order": {"id": "ORD-004", "amount": 50, "region": "US", "email": "hacker@temp.mail.org"}, + "score": 0, + "flags": {}, + } + results = await svc.evaluate_by_name("order-processing", ctx) + + # fraud-pattern rule should have matched + fraud_result = next(r for r in results if r.rule_id == "fraud-pattern") + assert fraud_result.matched is True + + # audit handler recorded one event + assert len(audit_log.events) == 1 + assert audit_log.events[0]["event"] == "fraud_pattern_matched" + assert audit_log.events[0]["order_id"] == "ORD-004" + + # context flag set + assert ctx["flags"]["fraud_suspected"] is True + + @pytest.mark.asyncio + async def test_all_three_rules_match_simultaneously(self) -> None: + """A single order can match all three rules at once (ALL mode).""" + audit_log = _AuditLog() + svc = _make_service(mode=EvaluationMode.ALL, audit_log=audit_log) + rs = _build_ruleset_via_builder() + await svc.save_ruleset(rs) + + ctx: dict[str, Any] = { + "order": {"id": "ORD-005", "amount": 7500, "region": "RU", "email": "spy@temp.io.ru"}, + "score": 0, + "flags": {}, + } + results = await svc.evaluate_by_name("order-processing", ctx) + + assert len(results) == 3 + assert all(r.matched for r in results) + + assert ctx["flags"]["high_value"] is True + assert ctx["score"] == 10 + assert ctx["flags"]["blocked"] is True + assert ctx["flags"]["fraud_suspected"] is True + assert len(audit_log.events) == 1 + + @pytest.mark.asyncio + async def test_matched_rules_in_results(self) -> None: + """result.matched reflects per-rule match status in ALL mode.""" + svc = _make_service(mode=EvaluationMode.ALL) + rs = _build_ruleset_via_builder() + await svc.save_ruleset(rs) + + # amount triggers high-value; region is not blocked; email is clean + ctx: dict[str, Any] = { + "order": {"id": "ORD-006", "amount": 6000, "region": "DE", "email": "clean@company.com"}, + "score": 0, + "flags": {}, + } + results = await svc.evaluate_by_name("order-processing", ctx) + + by_id = {r.rule_id: r for r in results} + assert by_id["high-value"].matched is True + assert by_id["region-blocked"].matched is False + assert by_id["fraud-pattern"].matched is False + + @pytest.mark.asyncio + async def test_actions_executed_listed_in_result(self) -> None: + """EvaluationResult.actions_executed lists only the actually-run actions.""" + audit_log = _AuditLog() + svc = _make_service(mode=EvaluationMode.ALL, audit_log=audit_log) + rs = _build_ruleset_via_builder() + await svc.save_ruleset(rs) + + ctx: dict[str, Any] = { + "order": {"id": "ORD-007", "amount": 8000, "region": "US", "email": "ok@example.com"}, + "score": 0, + "flags": {}, + } + results = await svc.evaluate_by_name("order-processing", ctx) + + high_result = next(r for r in results if r.rule_id == "high-value") + # then-branch has set + increment → 2 actions executed + assert len(high_result.actions_executed) == 2 + assert [a.type for a in high_result.actions_executed] == ["set", "increment"] + + +# =========================================================================== +# Test class 3 — EvaluationMode.FIRST_MATCH full-stack scenario +# =========================================================================== + + +class TestFirstMatchModeFullStack: + """FIRST_MATCH stops after the highest-priority matching rule fires.""" + + @pytest.mark.asyncio + async def test_first_match_stops_at_high_value_rule(self) -> None: + """When high-value matches, only that rule's actions fire.""" + audit_log = _AuditLog() + svc = _make_service(mode=EvaluationMode.FIRST_MATCH, audit_log=audit_log) + rs = _build_ruleset_via_builder() + await svc.save_ruleset(rs) + + ctx: dict[str, Any] = { + "order": {"id": "ORD-010", "amount": 9999, "region": "RU", "email": "fraud@temp.io"}, + "score": 0, + "flags": {}, + } + results = await svc.evaluate_by_name("order-processing", ctx) + + # Only one result — high-value (priority 20) matched first + assert len(results) == 1 + assert results[0].rule_id == "high-value" + assert results[0].matched is True + + # high-value actions ran + assert ctx["flags"]["high_value"] is True + assert ctx["score"] == 10 + + # region-blocked and fraud-pattern actions did NOT run + assert "blocked" not in ctx["flags"] + assert "fraud_suspected" not in ctx["flags"] + + # audit handler did NOT fire + assert len(audit_log.events) == 0 + + @pytest.mark.asyncio + async def test_first_match_skips_to_second_rule_when_first_misses(self) -> None: + """If high-value does not match, evaluation continues until a match is found.""" + audit_log = _AuditLog() + svc = _make_service(mode=EvaluationMode.FIRST_MATCH, audit_log=audit_log) + rs = _build_ruleset_via_builder() + await svc.save_ruleset(rs) + + # amount is low (< 5000) → high-value does NOT match + # region is blocked → region-blocked MATCHES → stops + ctx: dict[str, Any] = { + "order": {"id": "ORD-011", "amount": 100, "region": "IR", "email": "ok@example.com"}, + "score": 0, + "flags": {}, + } + results = await svc.evaluate_by_name("order-processing", ctx) + + # Two results: high-value (no match), region-blocked (match → stop) + assert len(results) == 2 + assert results[0].rule_id == "high-value" + assert results[0].matched is False + assert results[1].rule_id == "region-blocked" + assert results[1].matched is True + + # Only region-blocked's then-branch ran + assert ctx["flags"]["blocked"] is True + # fraud-pattern was never evaluated + assert "fraud_suspected" not in ctx["flags"] + assert len(audit_log.events) == 0 + + @pytest.mark.asyncio + async def test_first_match_all_rules_evaluated_when_no_match(self) -> None: + """When no rule matches in FIRST_MATCH all rules are still evaluated.""" + svc = _make_service(mode=EvaluationMode.FIRST_MATCH) + rs = _build_ruleset_via_builder() + await svc.save_ruleset(rs) + + # amount < 5000, region not blocked, email clean → 0 matches + ctx: dict[str, Any] = { + "order": {"id": "ORD-012", "amount": 50, "region": "US", "email": "ok@example.com"}, + "score": 0, + "flags": {}, + } + results = await svc.evaluate_by_name("order-processing", ctx) + + # All three rules evaluated, none matched + assert len(results) == 3 + assert not any(r.matched for r in results) + + +# =========================================================================== +# Test class 4 — Metrics counter integration +# =========================================================================== + + +class TestMetricsIntegration: + """Counters are incremented correctly through the service → evaluator pipeline.""" + + @pytest.mark.asyncio + async def test_evaluations_counter_incremented_per_call(self) -> None: + recorder = _FakeMetricsRecorder() + svc = _make_service(mode=EvaluationMode.ALL, metrics=recorder) + rs = _build_ruleset_via_builder() + await svc.save_ruleset(rs) + + ctx1: dict[str, Any] = { + "order": {"id": "M-001", "amount": 200, "region": "US", "email": "a@b.com"}, + "score": 0, + "flags": {}, + } + ctx2: dict[str, Any] = { + "order": {"id": "M-002", "amount": 200, "region": "US", "email": "a@b.com"}, + "score": 0, + "flags": {}, + } + await svc.evaluate_by_name("order-processing", ctx1) + await svc.evaluate_by_name("order-processing", ctx2) + + evals = recorder.counters["pyfly_rule_evaluations_total"] + assert evals.total == 2 + assert all(labels.get("ruleset") == "order-processing" for labels, _ in evals.calls) + + @pytest.mark.asyncio + async def test_matched_counter_reflects_matching_rules(self) -> None: + recorder = _FakeMetricsRecorder() + svc = _make_service(mode=EvaluationMode.ALL, metrics=recorder) + rs = _build_ruleset_via_builder() + await svc.save_ruleset(rs) + + # Only high-value matches + ctx: dict[str, Any] = { + "order": {"id": "M-003", "amount": 6000, "region": "DE", "email": "clean@co.com"}, + "score": 0, + "flags": {}, + } + await svc.evaluate_by_name("order-processing", ctx) + + matched = recorder.counters["pyfly_rules_matched_total"] + assert matched.total == 1 + + @pytest.mark.asyncio + async def test_actions_fired_counter_counts_all_executed_actions(self) -> None: + recorder = _FakeMetricsRecorder() + audit_log = _AuditLog() + svc = _make_service(mode=EvaluationMode.ALL, audit_log=audit_log, metrics=recorder) + rs = _build_ruleset_via_builder() + await svc.save_ruleset(rs) + + # high-value matches (2 actions: set + increment) + # region not blocked (1 otherwise action: set) + # fraud not suspected (1 otherwise action: set) + # total executed actions: 2 + 1 + 1 = 4 + ctx: dict[str, Any] = { + "order": {"id": "M-004", "amount": 7000, "region": "US", "email": "safe@company.com"}, + "score": 0, + "flags": {}, + } + await svc.evaluate_by_name("order-processing", ctx) + + actions_fired = recorder.counters["pyfly_rule_actions_fired_total"] + assert actions_fired.total == 4 + + @pytest.mark.asyncio + async def test_errors_counter_incremented_on_unregistered_action(self) -> None: + """When a 'call' action has no handler, the error counter increments.""" + recorder = _FakeMetricsRecorder() + # No audit_log → 'call' handler NOT registered → NotImplementedError + svc = _make_service(mode=EvaluationMode.ALL, metrics=recorder) + rs = _build_ruleset_via_builder() + await svc.save_ruleset(rs) + + ctx: dict[str, Any] = { + "order": {"id": "M-005", "amount": 50, "region": "US", "email": "x@temp.spam.io"}, + "score": 0, + "flags": {}, + } + await svc.evaluate_by_name("order-processing", ctx) + + errors = recorder.counters["pyfly_rule_errors_total"] + # fraud-pattern matched → 'call' action fails → 1 error + assert errors.total == 1 + + +# =========================================================================== +# Test class 5 — Repository round-trip and error handling +# =========================================================================== + + +class TestRepositoryAndErrorHandling: + @pytest.mark.asyncio + async def test_evaluate_by_name_not_found_raises(self) -> None: + """evaluate_by_name raises RuleSetNotFoundError when the ID is unknown.""" + svc = _make_service() + with pytest.raises(RuleSetNotFoundError) as exc_info: + await svc.evaluate_by_name("does-not-exist", {}) + assert "does-not-exist" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_list_rulesets_returns_saved(self) -> None: + svc = _make_service() + rs = _build_ruleset_via_builder() + await svc.save_ruleset(rs) + all_rs = await svc.list_rulesets() + assert len(all_rs) == 1 + assert all_rs[0].id == "order-processing" + + @pytest.mark.asyncio + async def test_get_ruleset_round_trip(self) -> None: + svc = _make_service() + rs = _build_ruleset_via_builder() + await svc.save_ruleset(rs) + retrieved = await svc.get_ruleset("order-processing") + assert retrieved is not None + assert retrieved.id == "order-processing" + assert len(retrieved.rules) == 3 + + @pytest.mark.asyncio + async def test_shared_context_mutations_in_all_mode(self) -> None: + """In ALL mode earlier rules (higher priority) mutate ctx visible to later ones.""" + svc = _make_service(mode=EvaluationMode.ALL) + rs = _build_ruleset_via_builder() + await svc.save_ruleset(rs) + + # high-value matches first (p=20), sets flags.high_value=True and score=10 + # later rules can observe those mutations + ctx: dict[str, Any] = { + "order": {"id": "CTX-001", "amount": 6000, "region": "US", "email": "ok@example.com"}, + "score": 0, + "flags": {}, + } + await svc.evaluate_by_name("order-processing", ctx) + + # Verify mutations accumulated across all rules + assert ctx["score"] == 10 # set by high-value + assert ctx["flags"]["high_value"] is True + assert ctx["flags"]["blocked"] is False + assert ctx["flags"]["fraud_suspected"] is False + + @pytest.mark.asyncio + async def test_action_isolation_bad_call_does_not_prevent_sibling_actions(self) -> None: + """An unregistered 'call' action fails in isolation; sibling 'set' still runs.""" + # No custom 'call' handler registered + svc = _make_service(mode=EvaluationMode.ALL) + rs = _build_ruleset_via_builder() + await svc.save_ruleset(rs) + + ctx: dict[str, Any] = { + "order": {"id": "ISO-001", "amount": 50, "region": "US", "email": "hacker@temp.mail.net"}, + "score": 0, + "flags": {}, + } + results = await svc.evaluate_by_name("order-processing", ctx) + + fraud_result = next(r for r in results if r.rule_id == "fraud-pattern") + # The 'set' action executed successfully + assert ctx["flags"]["fraud_suspected"] is True + # The 'call' action failed, but error is isolated to the result + assert fraud_result.error is not None + assert "call" in fraud_result.error + # The 'set' action still appears in actions_executed + executed_types = [a.type for a in fraud_result.actions_executed] + assert "set" in executed_types + + +# =========================================================================== +# Test class 6 — compound conditions (and/or/not) in full-stack context +# =========================================================================== + + +class TestCompoundConditionsEndToEnd: + """Verify and/or/not compound operators work end-to-end through the service.""" + + @pytest.mark.asyncio + async def test_all_of_compound_condition(self) -> None: + """A rule with all_of fires only when ALL conditions are met.""" + compound_rule = ( + rule("vip") + .priority(5) + .when( + all_of( + field("customer.tier").eq("gold"), + field("order.total").ge(500), + ) + ) + .then(set_action("discount", 20)) + .otherwise(set_action("discount", 0)) + .build() + ) + rs = ruleset("compound-rs").add(compound_rule).build() + assert validate_ruleset(rs) == [] + + repo = InMemoryRuleSetRepository() + svc = RuleEngineService(repository=repo) + await svc.save_ruleset(rs) + + # both conditions met + ctx_match: dict[str, Any] = {"customer": {"tier": "gold"}, "order": {"total": 600}} + await svc.evaluate_by_name("compound-rs", ctx_match) + assert ctx_match["discount"] == 20 + + # only one condition met + ctx_no_match: dict[str, Any] = {"customer": {"tier": "gold"}, "order": {"total": 100}} + await svc.evaluate_by_name("compound-rs", ctx_no_match) + assert ctx_no_match["discount"] == 0 diff --git a/tests/rule_engine/test_evaluator_coverage.py b/tests/rule_engine/test_evaluator_coverage.py index 38ca36fd..67303ecd 100644 --- a/tests/rule_engine/test_evaluator_coverage.py +++ b/tests/rule_engine/test_evaluator_coverage.py @@ -187,9 +187,9 @@ def test_unsupported_action_is_isolated(self) -> None: assert [a.type for a in res.actions_executed] == ["set"] def test_unknown_operator_is_surfaced(self) -> None: - res = _evaluate(Condition(operator="between", field="x", value=[1, 5]), {"x": 3}) + res = _evaluate(Condition(operator="fuzzy_match", field="x", value="abc"), {"x": "abc"}) assert res.matched is False - assert "unknown operator: between" in (res.error or "") + assert "unknown operator: fuzzy_match" in (res.error or "") class TestRuleSet: diff --git a/tests/rule_engine/test_loading_and_validation.py b/tests/rule_engine/test_loading_and_validation.py new file mode 100644 index 00000000..341e0013 --- /dev/null +++ b/tests/rule_engine/test_loading_and_validation.py @@ -0,0 +1,280 @@ +# Copyright 2026 Firefly Software Foundation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for JSON loading and RuleSet validation (SP-13 Part A, Item 3).""" + +from __future__ import annotations + +import json + +import pytest + +from pyfly.rule_engine import ( + Action, + Condition, + Rule, + RuleSet, + RuleSetLoader, +) +from pyfly.rule_engine.validation import ( + RuleSetValidator, + RuleValidationError, + validate_ruleset, +) + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +_RULESET_YAML = """\ +id: order-rules +name: Order processing +version: 2 +rules: + - id: high-value + priority: 10 + when: + op: ge + field: order.amount + value: 1000 + then: + - type: set + target: flags.high_value + value: true + - id: cheap + priority: 5 + when: + op: lt + field: order.amount + value: 100 + then: + - type: set + target: flags.cheap + value: true +""" + +_RULESET_DICT = { + "id": "order-rules", + "name": "Order processing", + "version": 2, + "rules": [ + { + "id": "high-value", + "priority": 10, + "when": {"op": "ge", "field": "order.amount", "value": 1000}, + "then": [{"type": "set", "target": "flags.high_value", "value": True}], + }, + { + "id": "cheap", + "priority": 5, + "when": {"op": "lt", "field": "order.amount", "value": 100}, + "then": [{"type": "set", "target": "flags.cheap", "value": True}], + }, + ], +} + + +# --------------------------------------------------------------------------- +# from_json +# --------------------------------------------------------------------------- + + +class TestFromJson: + def test_parses_json_string(self) -> None: + text = json.dumps(_RULESET_DICT) + rs = RuleSetLoader.from_json(text) + assert rs.id == "order-rules" + assert rs.name == "Order processing" + assert rs.version == 2 + assert len(rs.rules) == 2 + + def test_round_trips_equal_to_from_yaml(self) -> None: + """JSON and YAML representations of the same ruleset produce equal objects.""" + from_yaml = RuleSetLoader.from_yaml(_RULESET_YAML) + from_json = RuleSetLoader.from_json(json.dumps(_RULESET_DICT)) + + assert from_yaml.id == from_json.id + assert from_yaml.name == from_json.name + assert from_yaml.version == from_json.version + assert len(from_yaml.rules) == len(from_json.rules) + for yr, jr in zip(from_yaml.rules, from_json.rules, strict=True): + assert yr.id == jr.id + assert yr.priority == jr.priority + assert yr.enabled == jr.enabled + + def test_rule_conditions_equal(self) -> None: + from_yaml = RuleSetLoader.from_yaml(_RULESET_YAML) + from_json = RuleSetLoader.from_json(json.dumps(_RULESET_DICT)) + for yr, jr in zip(from_yaml.rules, from_json.rules, strict=True): + assert yr.when is not None and jr.when is not None + assert yr.when.operator == jr.when.operator + assert yr.when.field == jr.when.field + assert yr.when.value == jr.when.value + + def test_invalid_json_raises(self) -> None: + with pytest.raises(ValueError): + RuleSetLoader.from_json("not valid json {{{") + + +# --------------------------------------------------------------------------- +# validate_ruleset / RuleSetValidator — valid case +# --------------------------------------------------------------------------- + + +class TestValidatorValidRuleset: + def test_empty_issues_for_valid_ruleset(self) -> None: + rs = RuleSet( + id="rs", + rules=[ + Rule( + id="r1", + when=Condition(operator="gt", field="x", value=5), + then=[Action(type="set", target="y", value=1)], + ), + Rule( + id="r2", + when=Condition(operator="between", field="x", value=[1, 10]), + ), + ], + ) + assert validate_ruleset(rs) == [] + + def test_assert_valid_does_not_raise_for_valid(self) -> None: + rs = RuleSet(id="rs", rules=[Rule(id="r1")]) + RuleSetValidator.assert_valid(rs) # must not raise + + +# --------------------------------------------------------------------------- +# validate_ruleset — invalid cases +# --------------------------------------------------------------------------- + + +class TestValidatorInvalidCases: + def test_duplicate_rule_ids(self) -> None: + rs = RuleSet(id="rs", rules=[Rule(id="dup"), Rule(id="dup")]) + issues = validate_ruleset(rs) + assert any("duplicate" in i and "dup" in i for i in issues) + + def test_unknown_operator(self) -> None: + rs = RuleSet( + id="rs", + rules=[ + Rule( + id="r1", + when=Condition(operator="fuzzy_match", field="x", value="abc"), + ) + ], + ) + issues = validate_ruleset(rs) + assert any("fuzzy_match" in i for i in issues) + + def test_missing_target_on_set(self) -> None: + rs = RuleSet( + id="rs", + rules=[Rule(id="r1", then=[Action(type="set", target=None, value=1)])], + ) + issues = validate_ruleset(rs) + assert any("target" in i and "set" in i for i in issues) + + def test_missing_target_on_increment(self) -> None: + rs = RuleSet( + id="rs", + rules=[Rule(id="r1", then=[Action(type="increment", target=None)])], + ) + issues = validate_ruleset(rs) + assert any("target" in i and "increment" in i for i in issues) + + def test_bad_between_value_not_two_elements(self) -> None: + rs = RuleSet( + id="rs", + rules=[ + Rule( + id="r1", + when=Condition(operator="between", field="x", value=[1, 2, 3]), + ) + ], + ) + issues = validate_ruleset(rs) + assert any("between" in i for i in issues) + + def test_bad_between_value_scalar(self) -> None: + rs = RuleSet( + id="rs", + rules=[ + Rule( + id="r1", + when=Condition(operator="between", field="x", value=5), + ) + ], + ) + issues = validate_ruleset(rs) + assert any("between" in i for i in issues) + + def test_unknown_action_type(self) -> None: + rs = RuleSet( + id="rs", + rules=[Rule(id="r1", then=[Action(type="teleport", target="x")])], + ) + issues = validate_ruleset(rs) + assert any("teleport" in i for i in issues) + + def test_compound_and_with_no_children(self) -> None: + rs = RuleSet( + id="rs", + rules=[Rule(id="r1", when=Condition(operator="and", children=[]))], + ) + issues = validate_ruleset(rs) + assert any("and" in i for i in issues) + + def test_not_with_two_children(self) -> None: + rs = RuleSet( + id="rs", + rules=[ + Rule( + id="r1", + when=Condition( + operator="not", + children=[ + Condition(operator="eq", field="a", value=1), + Condition(operator="eq", field="b", value=2), + ], + ), + ) + ], + ) + issues = validate_ruleset(rs) + assert any("not" in i for i in issues) + + def test_multiple_issues_all_reported(self) -> None: + rs = RuleSet( + id="rs", + rules=[ + Rule(id="dup"), + Rule( + id="dup", + when=Condition(operator="bad_op", field="x"), + then=[Action(type="set")], + ), + ], + ) + issues = validate_ruleset(rs) + assert len(issues) >= 3 # duplicate + unknown op + missing target + + def test_assert_valid_raises_rule_validation_error(self) -> None: + rs = RuleSet(id="rs", rules=[Rule(id="dup"), Rule(id="dup")]) + with pytest.raises(RuleValidationError) as exc_info: + RuleSetValidator.assert_valid(rs) + err = exc_info.value + assert err.ruleset_id == "rs" + assert len(err.issues) >= 1 + assert "dup" in str(err) diff --git a/tests/rule_engine/test_modes.py b/tests/rule_engine/test_modes.py new file mode 100644 index 00000000..a4bf3d57 --- /dev/null +++ b/tests/rule_engine/test_modes.py @@ -0,0 +1,181 @@ +# Copyright 2026 Firefly Software Foundation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for EvaluationMode (SP-13 Part B Item 2).""" + +from __future__ import annotations + +from pyfly.rule_engine.dsl import Action, Condition, Rule, RuleSet +from pyfly.rule_engine.evaluator import EvaluationMode, RuleSetEvaluator + + +def _make_ruleset() -> RuleSet: + """Two rules: high-priority (p=10) matches, low-priority (p=1) also matches.""" + high = Rule( + id="high", + priority=10, + when=Condition(operator="eq", field="tier", value="gold"), + then=[Action(type="set", target="high_ran", value=True)], + ) + low = Rule( + id="low", + priority=1, + when=Condition(operator="eq", field="tier", value="gold"), + then=[Action(type="set", target="low_ran", value=True)], + ) + return RuleSet(id="rs", rules=[high, low]) + + +def _make_ruleset_no_match_high() -> RuleSet: + """High rule does NOT match; low rule matches.""" + high = Rule( + id="high", + priority=10, + when=Condition(operator="eq", field="tier", value="platinum"), + then=[Action(type="set", target="high_ran", value=True)], + ) + low = Rule( + id="low", + priority=1, + when=Condition(operator="eq", field="tier", value="gold"), + then=[Action(type="set", target="low_ran", value=True)], + ) + return RuleSet(id="rs", rules=[high, low]) + + +class TestAllMode: + def test_all_evaluates_every_rule(self) -> None: + evaluator = RuleSetEvaluator(mode=EvaluationMode.ALL) + ctx: dict = {"tier": "gold"} + results = evaluator.evaluate(_make_ruleset(), ctx) + + assert len(results) == 2 + assert [r.rule_id for r in results] == ["high", "low"] + + def test_all_fires_actions_for_all_matching_rules(self) -> None: + evaluator = RuleSetEvaluator(mode=EvaluationMode.ALL) + ctx: dict = {"tier": "gold"} + evaluator.evaluate(_make_ruleset(), ctx) + + assert ctx.get("high_ran") is True + assert ctx.get("low_ran") is True + + def test_all_is_default_mode(self) -> None: + """RuleSetEvaluator() defaults to ALL.""" + evaluator = RuleSetEvaluator() + ctx: dict = {"tier": "gold"} + results = evaluator.evaluate(_make_ruleset(), ctx) + + assert len(results) == 2 + assert ctx.get("low_ran") is True + + def test_all_context_mutations_are_visible_to_later_rules(self) -> None: + """Earlier rules (higher priority) mutate ctx before later rules see it.""" + set_rule = Rule( + id="setter", + priority=10, + then=[Action(type="set", target="x", value=1)], + ) + read_rule = Rule( + id="reader", + priority=1, + when=Condition(operator="eq", field="x", value=1), + then=[Action(type="set", target="read_ok", value=True)], + ) + ruleset = RuleSet(id="rs", rules=[set_rule, read_rule]) + ctx: dict = {} + RuleSetEvaluator(mode=EvaluationMode.ALL).evaluate(ruleset, ctx) + assert ctx.get("read_ok") is True + + +class TestFirstMatchMode: + def test_first_match_stops_after_first_matching_rule(self) -> None: + evaluator = RuleSetEvaluator(mode=EvaluationMode.FIRST_MATCH) + ctx: dict = {"tier": "gold"} + results = evaluator.evaluate(_make_ruleset(), ctx) + + # Only the high-priority rule should appear in results + assert len(results) == 1 + assert results[0].rule_id == "high" + assert results[0].matched is True + + def test_first_match_lower_priority_actions_do_not_fire(self) -> None: + evaluator = RuleSetEvaluator(mode=EvaluationMode.FIRST_MATCH) + ctx: dict = {"tier": "gold"} + evaluator.evaluate(_make_ruleset(), ctx) + + assert ctx.get("high_ran") is True + assert "low_ran" not in ctx, "low-priority rule must NOT have executed" + + def test_first_match_continues_past_non_matching_rules(self) -> None: + """In FIRST_MATCH, a rule that does NOT match is included in results + but does not stop iteration — only a *match* stops it. + """ + evaluator = RuleSetEvaluator(mode=EvaluationMode.FIRST_MATCH) + ctx: dict = {"tier": "gold"} + results = evaluator.evaluate(_make_ruleset_no_match_high(), ctx) + + # high did NOT match → evaluation continues; low DOES match → stops + assert len(results) == 2 + assert results[0].rule_id == "high" + assert results[0].matched is False + assert results[1].rule_id == "low" + assert results[1].matched is True + assert ctx.get("low_ran") is True + + def test_first_match_returns_all_when_no_rule_matches(self) -> None: + """When nothing matches in FIRST_MATCH the full list is returned.""" + evaluator = RuleSetEvaluator(mode=EvaluationMode.FIRST_MATCH) + ctx: dict = {"tier": "bronze"} + results = evaluator.evaluate(_make_ruleset(), ctx) + + # Neither high nor low matches bronze — all rules evaluated, nothing fired + assert len(results) == 2 + assert not any(r.matched for r in results) + assert "high_ran" not in ctx + assert "low_ran" not in ctx + + def test_first_match_single_rule_match(self) -> None: + """A one-rule ruleset that matches stops immediately.""" + ruleset = RuleSet( + id="rs", + rules=[Rule(id="only", then=[Action(type="set", target="fired", value=True)])], + ) + evaluator = RuleSetEvaluator(mode=EvaluationMode.FIRST_MATCH) + ctx: dict = {} + results = evaluator.evaluate(ruleset, ctx) + + assert len(results) == 1 + assert ctx.get("fired") is True + + def test_first_match_shared_context_mutations_visible(self) -> None: + """FIRST_MATCH shares context with ALL semantics for evaluated rules.""" + high = Rule( + id="high", + priority=10, + then=[Action(type="set", target="x", value=1)], + ) + low = Rule( + id="low", + priority=1, + when=Condition(operator="eq", field="x", value=1), + then=[Action(type="set", target="low_ran", value=True)], + ) + ruleset = RuleSet(id="rs", rules=[high, low]) + ctx: dict = {} + # high matches (no condition), so FIRST_MATCH stops after high + results = RuleSetEvaluator(mode=EvaluationMode.FIRST_MATCH).evaluate(ruleset, ctx) + + assert len(results) == 1 + assert ctx.get("x") == 1 + assert "low_ran" not in ctx diff --git a/tests/rule_engine/test_operators.py b/tests/rule_engine/test_operators.py new file mode 100644 index 00000000..60750de1 --- /dev/null +++ b/tests/rule_engine/test_operators.py @@ -0,0 +1,235 @@ +# Copyright 2026 Firefly Software Foundation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for all new rich operators added in SP-13 Part A. + +Each operator is covered with: + - a "true" case, + - a "false" case, + - a None/missing-field case (must return False without crashing). +""" + +from __future__ import annotations + +import pytest + +from pyfly.rule_engine import Condition, Rule, RuleEvaluator + + +def _eval(cond: Condition, ctx: dict) -> bool: # type: ignore[type-arg] + return RuleEvaluator().evaluate(Rule(id="t", when=cond), ctx).matched + + +# --------------------------------------------------------------------------- +# between +# --------------------------------------------------------------------------- + + +class TestBetween: + def test_within_range(self) -> None: + cond = Condition(operator="between", field="x", value=[1, 10]) + assert _eval(cond, {"x": 5}) is True + + def test_at_lower_boundary(self) -> None: + cond = Condition(operator="between", field="x", value=[1, 10]) + assert _eval(cond, {"x": 1}) is True + + def test_at_upper_boundary(self) -> None: + cond = Condition(operator="between", field="x", value=[1, 10]) + assert _eval(cond, {"x": 10}) is True + + def test_below_range(self) -> None: + cond = Condition(operator="between", field="x", value=[5, 10]) + assert _eval(cond, {"x": 4}) is False + + def test_above_range(self) -> None: + cond = Condition(operator="between", field="x", value=[5, 10]) + assert _eval(cond, {"x": 11}) is False + + def test_none_field_is_false(self) -> None: + cond = Condition(operator="between", field="missing", value=[1, 10]) + assert _eval(cond, {}) is False + + +# --------------------------------------------------------------------------- +# contains +# --------------------------------------------------------------------------- + + +class TestContains: + def test_substring_true(self) -> None: + cond = Condition(operator="contains", field="s", value="hello") + assert _eval(cond, {"s": "say hello world"}) is True + + def test_substring_false(self) -> None: + cond = Condition(operator="contains", field="s", value="hello") + assert _eval(cond, {"s": "no greeting here"}) is False + + def test_list_member_true(self) -> None: + cond = Condition(operator="contains", field="tags", value="vip") + assert _eval(cond, {"tags": ["standard", "vip", "new"]}) is True + + def test_list_member_false(self) -> None: + cond = Condition(operator="contains", field="tags", value="vip") + assert _eval(cond, {"tags": ["standard", "new"]}) is False + + def test_none_field_is_false(self) -> None: + cond = Condition(operator="contains", field="missing", value="x") + assert _eval(cond, {}) is False + + +# --------------------------------------------------------------------------- +# not_contains +# --------------------------------------------------------------------------- + + +class TestNotContains: + def test_substring_absent_true(self) -> None: + cond = Condition(operator="not_contains", field="s", value="bad") + assert _eval(cond, {"s": "good text"}) is True + + def test_substring_present_false(self) -> None: + cond = Condition(operator="not_contains", field="s", value="bad") + assert _eval(cond, {"s": "this is bad"}) is False + + def test_list_absent_true(self) -> None: + cond = Condition(operator="not_contains", field="tags", value="blocked") + assert _eval(cond, {"tags": ["active", "vip"]}) is True + + def test_list_present_false(self) -> None: + cond = Condition(operator="not_contains", field="tags", value="blocked") + assert _eval(cond, {"tags": ["active", "blocked"]}) is False + + def test_none_field_is_false(self) -> None: + cond = Condition(operator="not_contains", field="missing", value="x") + assert _eval(cond, {}) is False + + +# --------------------------------------------------------------------------- +# starts_with +# --------------------------------------------------------------------------- + + +class TestStartsWith: + def test_true(self) -> None: + cond = Condition(operator="starts_with", field="code", value="ACME-") + assert _eval(cond, {"code": "ACME-001"}) is True + + def test_false(self) -> None: + cond = Condition(operator="starts_with", field="code", value="ACME-") + assert _eval(cond, {"code": "OTHER-001"}) is False + + def test_none_field_is_false(self) -> None: + cond = Condition(operator="starts_with", field="missing", value="ACME-") + assert _eval(cond, {}) is False + + +# --------------------------------------------------------------------------- +# ends_with +# --------------------------------------------------------------------------- + + +class TestEndsWith: + def test_true(self) -> None: + cond = Condition(operator="ends_with", field="filename", value=".pdf") + assert _eval(cond, {"filename": "report.pdf"}) is True + + def test_false(self) -> None: + cond = Condition(operator="ends_with", field="filename", value=".pdf") + assert _eval(cond, {"filename": "report.docx"}) is False + + def test_none_field_is_false(self) -> None: + cond = Condition(operator="ends_with", field="missing", value=".pdf") + assert _eval(cond, {}) is False + + +# --------------------------------------------------------------------------- +# exists +# --------------------------------------------------------------------------- + + +class TestExists: + def test_field_present_and_non_none(self) -> None: + cond = Condition(operator="exists", field="name") + assert _eval(cond, {"name": "Alice"}) is True + + def test_field_absent(self) -> None: + cond = Condition(operator="exists", field="name") + assert _eval(cond, {}) is False + + def test_field_explicitly_none(self) -> None: + cond = Condition(operator="exists", field="name") + assert _eval(cond, {"name": None}) is False + + def test_value_is_ignored(self) -> None: + # exists ignores the value key entirely + cond = Condition(operator="exists", field="x", value="anything") + assert _eval(cond, {"x": 0}) is True # falsy but present + + def test_from_dict_tolerates_missing_value(self) -> None: + cond = Condition.from_dict({"op": "exists", "field": "x"}) + assert _eval(cond, {"x": 42}) is True + + +# --------------------------------------------------------------------------- +# is_null +# --------------------------------------------------------------------------- + + +class TestIsNull: + def test_none_value(self) -> None: + cond = Condition(operator="is_null", field="x") + assert _eval(cond, {"x": None}) is True + + def test_absent_field(self) -> None: + cond = Condition(operator="is_null", field="x") + assert _eval(cond, {}) is True + + def test_non_null_value(self) -> None: + cond = Condition(operator="is_null", field="x") + assert _eval(cond, {"x": 0}) is False + + def test_from_dict_tolerates_missing_value(self) -> None: + cond = Condition.from_dict({"op": "is_null", "field": "x"}) + assert _eval(cond, {}) is True + + +# --------------------------------------------------------------------------- +# is_empty +# --------------------------------------------------------------------------- + + +class TestIsEmpty: + @pytest.mark.parametrize( + "value", + [None, "", [], {}], + ) + def test_empty_variants(self, value: object) -> None: + cond = Condition(operator="is_empty", field="x") + assert _eval(cond, {"x": value}) is True + + def test_absent_field(self) -> None: + cond = Condition(operator="is_empty", field="x") + assert _eval(cond, {}) is True # absent → None → empty + + @pytest.mark.parametrize( + "value", + ["hello", [1], {"a": 1}, 0, False], + ) + def test_non_empty_variants(self, value: object) -> None: + cond = Condition(operator="is_empty", field="x") + assert _eval(cond, {"x": value}) is False + + def test_from_dict_tolerates_missing_value(self) -> None: + cond = Condition.from_dict({"op": "is_empty", "field": "x"}) + assert _eval(cond, {"x": []}) is True diff --git a/tests/rule_engine/test_service.py b/tests/rule_engine/test_service.py new file mode 100644 index 00000000..bd7602aa --- /dev/null +++ b/tests/rule_engine/test_service.py @@ -0,0 +1,303 @@ +# Copyright 2026 Firefly Software Foundation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for RuleEngineService facade (SP-13 Part B Item 3).""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from pyfly.rule_engine.dsl import Action, Condition, Rule, RuleSet +from pyfly.rule_engine.repository import InMemoryRuleSetRepository +from pyfly.rule_engine.service import RuleEngineService, RuleSetNotFoundError + +# --------------------------------------------------------------------------- +# Helpers / fixtures +# --------------------------------------------------------------------------- + + +def _simple_ruleset(ruleset_id: str = "test-rs") -> RuleSet: + """A ruleset with one rule that always matches and increments a counter.""" + return RuleSet( + id=ruleset_id, + name="Test", + rules=[ + Rule( + id="r1", + when=Condition(operator="eq", field="active", value=True), + then=[Action(type="set", target="result", value="matched")], + otherwise=[Action(type="set", target="result", value="not_matched")], + ) + ], + ) + + +def _error_ruleset(ruleset_id: str = "err-rs") -> RuleSet: + """A ruleset with one rule that always fires a bad (unregistered) action.""" + return RuleSet( + id=ruleset_id, + rules=[ + Rule( + id="bad", + then=[Action(type="nonexistent_action")], + ) + ], + ) + + +class _FakeCounter: + """Records all .labels(...).inc(amount) calls.""" + + def __init__(self) -> None: + self.calls: list[tuple[dict[str, Any], float]] = [] + self._current_labels: dict[str, Any] = {} + + def labels(self, **kwargs: Any) -> _FakeCounter: + self._current_labels = dict(kwargs) + return self + + def inc(self, amount: float = 1) -> None: + self.calls.append((dict(self._current_labels), amount)) + + @property + def total(self) -> float: + return sum(v for _, v in self.calls) + + def total_for(self, **kwargs: Any) -> float: + return sum(v for labels, v in self.calls if all(labels.get(k) == v2 for k, v2 in kwargs.items())) + + +class _FakeMetricsRecorder: + """Minimal MetricsRecorder that tracks counter creations and increments.""" + + def __init__(self) -> None: + self.counters: dict[str, _FakeCounter] = {} + + def counter(self, name: str, description: str, labels: list[str] | None = None) -> _FakeCounter: + if name not in self.counters: + self.counters[name] = _FakeCounter() + return self.counters[name] + + def histogram(self, name: str, description: str, labels: list[str] | None = None, buckets: Any = None) -> Any: + return _FakeCounter() + + def gauge(self, name: str, description: str, labels: list[str] | None = None) -> Any: + return _FakeCounter() + + +# --------------------------------------------------------------------------- +# Round-trip tests (repository + evaluation) +# --------------------------------------------------------------------------- + + +class TestEvaluateByName: + @pytest.mark.asyncio + async def test_round_trip_save_and_evaluate(self) -> None: + """save_ruleset + evaluate_by_name evaluates the stored ruleset.""" + repo = InMemoryRuleSetRepository() + svc = RuleEngineService(repository=repo) + rs = _simple_ruleset() + await svc.save_ruleset(rs) + + ctx: dict[str, Any] = {"active": True} + results = await svc.evaluate_by_name(rs.id, ctx) + + assert len(results) == 1 + assert results[0].matched is True + assert ctx["result"] == "matched" + + @pytest.mark.asyncio + async def test_evaluate_by_name_otherwise_branch(self) -> None: + repo = InMemoryRuleSetRepository() + svc = RuleEngineService(repository=repo) + rs = _simple_ruleset() + await svc.save_ruleset(rs) + + ctx: dict[str, Any] = {"active": False} + results = await svc.evaluate_by_name(rs.id, ctx) + + assert results[0].matched is False + assert ctx["result"] == "not_matched" + + @pytest.mark.asyncio + async def test_evaluate_by_name_not_found_raises(self) -> None: + repo = InMemoryRuleSetRepository() + svc = RuleEngineService(repository=repo) + + with pytest.raises(RuleSetNotFoundError) as exc_info: + await svc.evaluate_by_name("does-not-exist", {}) + + assert "does-not-exist" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_evaluate_by_name_not_found_is_key_error(self) -> None: + """RuleSetNotFoundError is a KeyError subclass for backward compat.""" + repo = InMemoryRuleSetRepository() + svc = RuleEngineService(repository=repo) + + with pytest.raises(KeyError): + await svc.evaluate_by_name("missing", {}) + + +# --------------------------------------------------------------------------- +# Synchronous evaluate() — satisfies RuleEnginePort +# --------------------------------------------------------------------------- + + +class TestSyncEvaluate: + def test_sync_evaluate_returns_results(self) -> None: + repo = InMemoryRuleSetRepository() + svc = RuleEngineService(repository=repo) + rs = _simple_ruleset() + ctx: dict[str, Any] = {"active": True} + results = svc.evaluate(rs, ctx) + assert len(results) == 1 + assert results[0].matched is True + + +# --------------------------------------------------------------------------- +# Repository passthrough methods +# --------------------------------------------------------------------------- + + +class TestRepositoryPassthrough: + @pytest.mark.asyncio + async def test_get_ruleset_returns_saved(self) -> None: + repo = InMemoryRuleSetRepository() + svc = RuleEngineService(repository=repo) + rs = _simple_ruleset("x") + await svc.save_ruleset(rs) + found = await svc.get_ruleset("x") + assert found is rs + + @pytest.mark.asyncio + async def test_get_ruleset_returns_none_when_absent(self) -> None: + repo = InMemoryRuleSetRepository() + svc = RuleEngineService(repository=repo) + assert await svc.get_ruleset("nope") is None + + @pytest.mark.asyncio + async def test_list_rulesets_returns_all(self) -> None: + repo = InMemoryRuleSetRepository() + svc = RuleEngineService(repository=repo) + rs1 = _simple_ruleset("a") + rs2 = _simple_ruleset("b") + await svc.save_ruleset(rs1) + await svc.save_ruleset(rs2) + all_rs = await svc.list_rulesets() + ids = {r.id for r in all_rs} + assert ids == {"a", "b"} + + +# --------------------------------------------------------------------------- +# Metrics +# --------------------------------------------------------------------------- + + +class TestMetrics: + @pytest.fixture + def recorder(self) -> _FakeMetricsRecorder: + return _FakeMetricsRecorder() + + def test_counters_created_on_init(self, recorder: _FakeMetricsRecorder) -> None: + repo = InMemoryRuleSetRepository() + RuleEngineService(repository=repo, metrics=recorder) + assert "pyfly_rule_evaluations_total" in recorder.counters + assert "pyfly_rules_matched_total" in recorder.counters + assert "pyfly_rule_actions_fired_total" in recorder.counters + assert "pyfly_rule_errors_total" in recorder.counters + + def test_evaluations_counter_incremented(self, recorder: _FakeMetricsRecorder) -> None: + repo = InMemoryRuleSetRepository() + svc = RuleEngineService(repository=repo, metrics=recorder) + rs = _simple_ruleset("rs1") + svc.evaluate(rs, {"active": True}) + + evals = recorder.counters["pyfly_rule_evaluations_total"] + assert evals.total == 1 + # label value preserved + assert any(labels.get("ruleset") == "rs1" for labels, _ in evals.calls) + + def test_matched_counter_incremented(self, recorder: _FakeMetricsRecorder) -> None: + repo = InMemoryRuleSetRepository() + svc = RuleEngineService(repository=repo, metrics=recorder) + rs = _simple_ruleset("rs1") + svc.evaluate(rs, {"active": True}) + + matched = recorder.counters["pyfly_rules_matched_total"] + assert matched.total == 1 + + def test_no_match_does_not_increment_matched_counter(self, recorder: _FakeMetricsRecorder) -> None: + repo = InMemoryRuleSetRepository() + svc = RuleEngineService(repository=repo, metrics=recorder) + rs = _simple_ruleset("rs1") + # active=False → rule condition is False → no match + svc.evaluate(rs, {"active": False}) + + matched = recorder.counters["pyfly_rules_matched_total"] + assert matched.total == 0 + + def test_actions_fired_counter_incremented(self, recorder: _FakeMetricsRecorder) -> None: + repo = InMemoryRuleSetRepository() + svc = RuleEngineService(repository=repo, metrics=recorder) + rs = _simple_ruleset("rs1") + svc.evaluate(rs, {"active": True}) + + actions = recorder.counters["pyfly_rule_actions_fired_total"] + # one action executed (the "set" in the then branch) + assert actions.total == 1 + + def test_errors_counter_incremented_on_action_error(self, recorder: _FakeMetricsRecorder) -> None: + repo = InMemoryRuleSetRepository() + svc = RuleEngineService(repository=repo, metrics=recorder) + rs = _error_ruleset("err-rs") + svc.evaluate(rs, {}) + + errors = recorder.counters["pyfly_rule_errors_total"] + assert errors.total == 1 + assert any(labels.get("ruleset") == "err-rs" for labels, _ in errors.calls) + + @pytest.mark.asyncio + async def test_evaluate_by_name_increments_counters(self, recorder: _FakeMetricsRecorder) -> None: + repo = InMemoryRuleSetRepository() + svc = RuleEngineService(repository=repo, metrics=recorder) + rs = _simple_ruleset("rs2") + await svc.save_ruleset(rs) + await svc.evaluate_by_name("rs2", {"active": True}) + + evals = recorder.counters["pyfly_rule_evaluations_total"] + assert evals.total == 1 + assert any(labels.get("ruleset") == "rs2" for labels, _ in evals.calls) + + def test_no_metrics_recorder_no_error(self) -> None: + """Service works fine without a metrics recorder.""" + repo = InMemoryRuleSetRepository() + svc = RuleEngineService(repository=repo) + rs = _simple_ruleset() + results = svc.evaluate(rs, {"active": True}) + assert len(results) == 1 + + def test_multiple_evaluations_accumulate_counters(self, recorder: _FakeMetricsRecorder) -> None: + repo = InMemoryRuleSetRepository() + svc = RuleEngineService(repository=repo, metrics=recorder) + rs = _simple_ruleset("rs3") + svc.evaluate(rs, {"active": True}) + svc.evaluate(rs, {"active": False}) + svc.evaluate(rs, {"active": True}) + + evals = recorder.counters["pyfly_rule_evaluations_total"] + assert evals.total == 3 + matched = recorder.counters["pyfly_rules_matched_total"] + assert matched.total == 2 diff --git a/uv.lock b/uv.lock index 292fbd55..51a50b8f 100644 --- a/uv.lock +++ b/uv.lock @@ -2160,7 +2160,7 @@ wheels = [ [[package]] name = "pyfly" -version = "26.6.92" +version = "26.6.93" source = { editable = "." } dependencies = [ { name = "pydantic" },