A lightweight DSL for business rules with dependency analysis
Project description
Rulang
A lightweight DSL for business rules in Python, built with ANTLR4.
Features
- 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
Installation
uv add rulang
Quick Start
from 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}
DSL Syntax
Basic Rule Structure
condition => action [; action]* [; ret expression]?
Conditions (Left Side)
Comparison Operators
# 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
String Operators
# 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]+$"
Existence Operators
# 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
List Operators
# 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"]
Path Access
# 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"
Built-in Functions
String Functions
# Case conversion
lower(entity.name) == "john"
upper(entity.code) == "ABC"
# Whitespace removal
trim(entity.input) != ""
strip(entity.text) == "hello" # alias for trim
Collection Functions
# 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)
Type Coercion
# 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
Math Functions
# 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
Type Checking
# Check types
is_list(entity.items) == true
is_string(entity.name) == true
is_number(entity.count) == true
is_none(entity.value) == true
List Any-Satisfies Semantics
When 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"]
Actions (Right Side)
# 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 entity
Workflows
Workflows 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.
Evaluation Modes
First Match (default)
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'
All Match
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 order
Dependency Graph
The 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 1
Real-World Examples
Email Classification
engine = 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'"
])
Order Processing
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"
])
Data Validation
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']
Error Handling
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}")
Development
# 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.g4
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file rulang-1.0.0.tar.gz.
File metadata
- Download URL: rulang-1.0.0.tar.gz
- Upload date:
- Size: 133.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.24 {"installer":{"name":"uv","version":"0.9.24","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
05be01cc1334963bf4dcf5f29d4c1ecf9e67e1266a514974273c97f7dc00159d
|
|
| MD5 |
ecd2f484ed162f36619495266602ceeb
|
|
| BLAKE2b-256 |
4a7f4995dc63afd8bbc28c1a8bacaf387b986f999bc0d2faf8f7cb4a637ae5b3
|
File details
Details for the file rulang-1.0.0-py3-none-any.whl.
File metadata
- Download URL: rulang-1.0.0-py3-none-any.whl
- Upload date:
- Size: 63.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.24 {"installer":{"name":"uv","version":"0.9.24","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
84681da57f99c30902b53b3c7d9aa1b092a512bc200cfc4aa31a3409725ad02d
|
|
| MD5 |
e3106048c10cd1ed675d87aa7c853f66
|
|
| BLAKE2b-256 |
7a7817b7ac314b4b19de322379e9b8d73bc9e77bddda351a5e6153e419800aa2
|