Skip to content

Commit d56b36a

Browse files
authored
Adds RuleBook serialization (JSON/YAML) (#1)
* Adds RuleBook serialization (JSON/YAML) Adds serialization support for rule books, enabling export/import as dict, JSON, and optional YAML with schema versioning and validation to support safe persistence and future migrations. Exposes canonical dict-based API with validation by default and convenience JSON/YAML helpers; JSON is always available while YAML is optional via an extra dependency and emits a clear serialization error when missing. Introduces explicit serialization/validation/version error classes and preserves rule source to improve round‑tripping and diagnostics. Updates packaging metadata to declare the optional YAML dependency. * Improve typing and validate inline rule sources
1 parent 38d1ef8 commit d56b36a

File tree

7 files changed

+779
-2
lines changed

7 files changed

+779
-2
lines changed

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ A tiny, safe **boolean expression** engine: like Jinja for logic.
99
- **RuleBook**: name your rules and evaluate them later
1010
- **RuleGroup**: compose rules with `all`/`any` semantics and nested groups
1111
- **Missing policy**: choose to **raise** or substitute **None/False/custom default**
12+
- **Serialization**: export/import rule books as JSON or (optionally) YAML
1213

1314
```py
1415
from boolia import evaluate, RuleBook, DEFAULT_FUNCTIONS
@@ -146,6 +147,34 @@ print(rules.evaluate("eligible", context={"user": {"age": 17, "country": "Chile"
146147

147148
`RuleGroup` members can be rule names, already compiled `Rule` objects, or other `RuleGroup` instances. Nested groups short-circuit according to their mode (`all`/`any`), empty groups are vacuously `True`/`False`, and cycles raise a helpful error. Add groups with `RuleBook.add_group` or register existing ones with `RuleBook.register`.
148149

150+
#### RuleBook serialization
151+
152+
```py
153+
from boolia import RuleBook
154+
155+
rules = RuleBook()
156+
rules.add("adult", "user.age >= 18")
157+
rules.add_group("gate", members=["adult"])
158+
159+
payload = rules.to_dict()
160+
clone = RuleBook.from_dict(payload)
161+
assert clone.evaluate("gate", context={"user": {"age": 21}})
162+
163+
json_blob = rules.to_json(indent=2)
164+
loaded = RuleBook.from_json(json_blob)
165+
166+
# Optional YAML helpers (requires: pip install boolia[yaml])
167+
yaml_blob = rules.to_yaml()
168+
RuleBook.from_yaml(yaml_blob)
169+
```
170+
171+
- `RuleBook.to_dict` / `RuleBook.from_dict` are the canonical API and perform schema validation by default.
172+
- `to_json` / `from_json` are always available via the standard library.
173+
- `to_yaml` / `from_yaml` lazily import PyYAML; missing dependencies raise a clear `RulebookSerializationError`.
174+
- Pass custom JSON encoders/decoders (e.g. `orjson.dumps`) via the `encoder=` / `decoder=` keyword arguments.
175+
176+
Payloads include a schema version to enable future migrations. Inline rules or groups are supported when importing by default; pass `allow_inline=False` to reject them.
177+
149178
### Missing policy
150179

151180
```py

boolia/__init__.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@
99
RuleGroup,
1010
)
1111
from .resolver import default_resolver_factory, MissingPolicy
12-
from .errors import MissingVariableError
12+
from .errors import (
13+
MissingVariableError,
14+
RulebookSerializationError,
15+
RulebookValidationError,
16+
RulebookVersionError,
17+
)
1318
from .functions import FunctionRegistry, DEFAULT_FUNCTIONS
1419
from .operators import OperatorRegistry, DEFAULT_OPERATORS
1520

@@ -25,6 +30,9 @@
2530
"default_resolver_factory",
2631
"MissingPolicy",
2732
"MissingVariableError",
33+
"RulebookSerializationError",
34+
"RulebookValidationError",
35+
"RulebookVersionError",
2836
"FunctionRegistry",
2937
"DEFAULT_FUNCTIONS",
3038
"OperatorRegistry",

boolia/api.py

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22
from dataclasses import dataclass
33
from typing import Any, Callable, Dict, Iterable, Literal, Optional, Set, Union
44

5+
from .serialization import (
6+
rulebook_from_dict,
7+
rulebook_from_json,
8+
rulebook_from_yaml,
9+
rulebook_to_dict,
10+
rulebook_to_json,
11+
rulebook_to_yaml,
12+
)
13+
514
from .parser import parse
615
from .ast import Node
716
from .resolver import default_resolver_factory, MissingPolicy
@@ -42,6 +51,7 @@ def evaluate(
4251
class Rule:
4352
ast: Node
4453
operators: OperatorRegistry = DEFAULT_OPERATORS
54+
source: Optional[str] = None
4555

4656
def evaluate(self, *, operators: Optional[OperatorRegistry] = None, **kwargs) -> bool:
4757
local_kwargs = kwargs.copy()
@@ -52,7 +62,7 @@ def evaluate(self, *, operators: Optional[OperatorRegistry] = None, **kwargs) ->
5262

5363
def compile_rule(source: str, *, operators: Optional[OperatorRegistry] = None) -> Rule:
5464
ops = operators or DEFAULT_OPERATORS
55-
return Rule(compile_expr(source, operators=ops), ops)
65+
return Rule(compile_expr(source, operators=ops), ops, source)
5666

5767

5868
class RuleGroup:
@@ -192,3 +202,74 @@ def _store(self, name: str, rule: RuleEntry) -> None:
192202
self._rules[name] = rule
193203
if isinstance(rule, RuleGroup):
194204
rule.bind_lookup(self.get)
205+
206+
def to_dict(
207+
self,
208+
*,
209+
version: str = "1.0",
210+
include_metadata: bool = True,
211+
validate: bool = True,
212+
) -> Dict[str, Any]:
213+
return rulebook_to_dict(
214+
self,
215+
version=version,
216+
include_metadata=include_metadata,
217+
validate=validate,
218+
)
219+
220+
@classmethod
221+
def from_dict(
222+
cls,
223+
payload: Dict[str, Any],
224+
*,
225+
validate: bool = True,
226+
allow_inline: bool = True,
227+
) -> "RuleBook":
228+
return rulebook_from_dict(
229+
cls,
230+
payload,
231+
validate=validate,
232+
allow_inline=allow_inline,
233+
)
234+
235+
def to_json(self, target=None, *, encoder=None, **json_kwargs):
236+
return rulebook_to_json(self, target=target, encoder=encoder, **json_kwargs)
237+
238+
@classmethod
239+
def from_json(
240+
cls,
241+
source,
242+
*,
243+
validate: bool = True,
244+
allow_inline: bool = True,
245+
decoder=None,
246+
**json_kwargs,
247+
) -> "RuleBook":
248+
return rulebook_from_json(
249+
cls,
250+
source,
251+
validate=validate,
252+
allow_inline=allow_inline,
253+
decoder=decoder,
254+
**json_kwargs,
255+
)
256+
257+
def to_yaml(self, target=None, **yaml_kwargs):
258+
return rulebook_to_yaml(self, target=target, **yaml_kwargs)
259+
260+
@classmethod
261+
def from_yaml(
262+
cls,
263+
source,
264+
*,
265+
validate: bool = True,
266+
allow_inline: bool = True,
267+
**yaml_kwargs,
268+
) -> "RuleBook":
269+
return rulebook_from_yaml(
270+
cls,
271+
source,
272+
validate=validate,
273+
allow_inline=allow_inline,
274+
**yaml_kwargs,
275+
)

boolia/errors.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,19 @@ class MissingVariableError(NameError):
44
def __init__(self, parts):
55
super().__init__(f"Missing variable/path: {'.'.join(parts)}")
66
self.parts = parts
7+
8+
9+
class RulebookError(Exception):
10+
"""Base class for rulebook serialization errors."""
11+
12+
13+
class RulebookSerializationError(RulebookError):
14+
"""Raised when serialization fails (e.g. missing dependencies)."""
15+
16+
17+
class RulebookValidationError(RulebookError):
18+
"""Raised when a serialized payload does not match the expected schema."""
19+
20+
21+
class RulebookVersionError(RulebookError):
22+
"""Raised when a serialized payload uses an unsupported schema version."""

0 commit comments

Comments
 (0)