A lightweight DSL for business rules in Python, built with ANTLR4.
- Expressive DSL: Define business rules with a clean, readable syntax
- Flexible Entity Support: Works with dictionaries, dataclasses, and any Python object
- Dependency Analysis: Automatic read/write tracking and execution ordering
- Cycle Detection: Warns about circular dependencies and handles them gracefully
- Workflow Integration: Call custom Python functions from rules with dependency declarations
- Multiple Evaluation Modes: First-match or all-match execution
- String Operations: Contains, startswith, endswith, regex matching
- Null-Safe Access: Optional chaining (
?.) and null coalescing (??) - Built-in Functions: String manipulation, type coercion, collection utilities
uv add rulangfrom rulang import RuleEngine
# Create an engine
engine = RuleEngine(mode="all_match")
# Add business rules
engine.add_rules([
"entity.age >= 18 => entity.is_adult = true",
"entity.is_adult == true => entity.discount += 0.1",
])
# Evaluate against an entity
entity = {"age": 25, "is_adult": False, "discount": 0.0}
engine.evaluate(entity)
print(entity) # {'age': 25, 'is_adult': True, 'discount': 0.1}condition => action [; action]* [; ret expression]?
# Basic comparisons
entity.value == 10
entity.value != 10
entity.value < 10
entity.value > 10
entity.value <= 10
entity.value >= 10
# Logical operators
entity.a > 0 and entity.b < 10
entity.a > 0 or entity.b < 10
not entity.disabled
# Membership
entity.status in ['active', 'pending']
entity.status not in ['deleted']
# Arithmetic
entity.price * entity.quantity >= 100# Substring check
entity.subject contains "invoice"
entity.subject not contains "spam"
# Prefix/suffix matching
entity.email startswith "admin@"
entity.filename endswith ".pdf"
# Alternative syntax
entity.email starts_with "admin@"
entity.filename ends_with ".pdf"
# Regex matching
entity.code matches "INV-\\d{4}"
entity.email matches "^[a-z]+@[a-z]+\\.[a-z]+$"# Check if value is not None
entity.customer_id exists
# Check if value is None, empty string, or empty collection
entity.notes is_empty
# Negated forms
not entity.email exists # same as checking for None
not entity.items is_empty # has content# Check if collection contains ANY of the values
entity.recipients contains_any ["admin@co.com", "support@co.com"]
# Check if collection contains ALL of the values
entity.tags contains_all ["reviewed", "approved"]# Nested paths
entity.user.profile.age >= 18
# List indexing (including negative indices)
entity.items[0].value > 10
entity.items[-1].total >= 100
# Null-safe access (returns None instead of error if parent is None)
entity.user?.profile?.name == "John"
# Null coalescing (provide default for None values)
entity.nickname ?? "Anonymous"
entity.config?.timeout ?? 30
# Combined null-safe access with coalescing
entity.user?.name ?? "Unknown"# Case conversion
lower(entity.name) == "john"
upper(entity.code) == "ABC"
# Whitespace removal
trim(entity.input) != ""
strip(entity.text) == "hello" # alias for trim# Length
len(entity.items) > 0
len(entity.name) >= 3
# First/last element
first(entity.items) == "apple"
last(entity.items) == "banana"
# Dictionary operations
"name" in keys(entity.data)
"John" in values(entity.data)# Convert to integer
int(entity.quantity) > 100
int(entity.price) == 99 # truncates decimals
# Convert to float
float(entity.amount) >= 99.99
# Convert to string
str(entity.code) == "123"
# Convert to boolean
bool(entity.value) == true# Absolute value
abs(entity.difference) < 10
# Rounding
round(entity.price, 2) == 99.99
round(entity.total) == 100 # rounds to integer
# Min/max
min(entity.a, entity.b, entity.c) > 0
max(entity.x, entity.y) < 100# Check types
is_list(entity.items) == true
is_string(entity.name) == true
is_number(entity.count) == true
is_none(entity.value) == trueWhen comparing a list field with a scalar value using ==, !=, in, contains, startswith, endswith, or matches, the comparison returns true if ANY element in the list satisfies the condition:
# If entity.tags = ["urgent", "finance"]
entity.tags == "urgent" # True (any element matches)
entity.tags contains "urg" # True (any element contains substring)
entity.tags startswith "fin" # True (any element starts with prefix)
# If entity.domains = ["admin.example.com", "user.example.com"]
entity.domains startswith "admin" # True (first domain matches)
entity.domains endswith ".com" # True (all domains match, but only one needed)When comparing two lists, exact equality is used:
entity.tags == ["a", "b", "c"] # True only if tags is exactly ["a", "b", "c"]# Simple assignment
entity.status = 'processed'
# Compound assignments
entity.counter += 1
entity.total -= 10
entity.price *= 0.9
entity.value /= 2
# Workflow calls
workflow('process_order')
workflow('send_notification', entity.user_id)
# Return values
ret true
ret entity
ret entity.total * 1.2
# Multiple actions
entity.processed = true; workflow('notify'); ret entityWorkflows allow you to call Python functions from rules:
from rulang import RuleEngine, Workflow
# Using the decorator
@RuleEngine.workflow("calculate_tax", reads=["entity.subtotal"], writes=["entity.tax"])
def calculate_tax(entity):
entity["tax"] = entity["subtotal"] * 0.2
# Using the Workflow wrapper
def apply_discount(entity):
entity["total"] = entity["subtotal"] * 0.9
workflows = {
"apply_discount": Workflow(
fn=apply_discount,
reads=["entity.subtotal"],
writes=["entity.total"]
)
}
engine = RuleEngine()
engine.add_rules("entity.eligible == true => workflow('apply_discount')")
engine.evaluate(entity, workflows=workflows)The reads and writes declarations enable accurate dependency analysis even though the rule engine can't inspect the workflow's internal logic.
Stops after the first matching rule:
engine = RuleEngine(mode="first_match")
engine.add_rules([
"entity.value > 100 => ret 'high'",
"entity.value > 50 => ret 'medium'",
"entity.value > 0 => ret 'low'",
])
result = engine.evaluate({"value": 75}) # Returns 'medium'Executes all matching rules in dependency order:
engine = RuleEngine(mode="all_match")
engine.add_rules([
"entity.age >= 18 => entity.is_adult = true",
"entity.is_adult == true => entity.can_vote = true",
])
entity = {"age": 25, "is_adult": False, "can_vote": False}
engine.evaluate(entity)
# Both rules execute in correct orderThe engine automatically analyzes read/write patterns to determine execution order:
engine = RuleEngine(mode="all_match")
engine.add_rules([
"entity.b > 0 => entity.c = 1", # Rule 0: reads b, writes c
"entity.a > 0 => entity.b = 1", # Rule 1: reads a, writes b
])
# Rule 1 executes before Rule 0 (Rule 0 depends on Rule 1's output)
print(engine.get_execution_order()) # [1, 0]
# Inspect the dependency graph
print(engine.get_dependency_graph()) # {1: {0}} # Rule 0 depends on Rule 1engine = RuleEngine(mode="first_match")
engine.add_rules([
# High priority: urgent emails from known domains
"lower(entity.subject) contains 'urgent' and entity.from_domain in ['company.com', 'partner.com'] => ret 'high'",
# Invoice detection
"entity.subject matches 'INV-\\d+' or entity.has_attachments and entity.attachments endswith '.pdf' => ret 'invoice'",
# Spam detection
"entity.from_domain not in ['company.com'] and lower(entity.subject) contains 'winner' => ret 'spam'",
# Default
"true => ret 'normal'"
])engine = RuleEngine(mode="all_match")
engine.add_rules([
# Apply member discount
"entity.customer?.membership exists and entity.customer.membership != 'none' => entity.discount = 0.1",
# Free shipping for large orders
"entity.subtotal >= 100 => entity.shipping = 0",
# Calculate total
"true => entity.total = (entity.subtotal * (1 - (entity.discount ?? 0))) + (entity.shipping ?? 5.99)",
# Flag for review if total is high
"entity.total > 1000 => entity.needs_review = true"
])engine = RuleEngine(mode="all_match")
engine.add_rules([
# Required fields
"entity.email is_empty => entity.errors += ['Email is required']",
"entity.name is_empty => entity.errors += ['Name is required']",
# Format validation
"not entity.email is_empty and not entity.email matches '^[^@]+@[^@]+\\.[^@]+$' => entity.errors += ['Invalid email format']",
# Length validation
"len(entity.name ?? '') > 100 => entity.errors += ['Name too long']",
# Set validity flag
"len(entity.errors) == 0 => entity.is_valid = true"
])
entity = {"email": "", "name": "John", "errors": [], "is_valid": False}
engine.evaluate(entity)
print(entity["errors"]) # ['Email is required']from rulang import (
RuleEngine,
RuleSyntaxError,
PathResolutionError,
WorkflowNotFoundError,
)
engine = RuleEngine()
# Syntax errors
try:
engine.add_rules("invalid syntax")
except RuleSyntaxError as e:
print(f"Parse error: {e}")
# Missing attributes
try:
engine.add_rules("entity.missing.path > 0 => ret true")
engine.evaluate({"name": "test"})
except PathResolutionError as e:
print(f"Path error: {e}")
# Missing workflows
try:
engine.add_rules("entity.x > 0 => workflow('unknown')")
engine.evaluate({"x": 1})
except WorkflowNotFoundError as e:
print(f"Workflow error: {e}")# Install dev dependencies
uv sync --all-extras
# Run tests
uv run pytest tests/ -v
# Regenerate parser (after grammar changes)
uv run antlr4 -Dlanguage=Python3 -visitor -o src/rule_interpreter/grammar/generated src/rule_interpreter/grammar/BusinessRules.g4MIT