Skip to main content

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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

rulang-1.0.0.tar.gz (133.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

rulang-1.0.0-py3-none-any.whl (63.6 kB view details)

Uploaded Python 3

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

Hashes for rulang-1.0.0.tar.gz
Algorithm Hash digest
SHA256 05be01cc1334963bf4dcf5f29d4c1ecf9e67e1266a514974273c97f7dc00159d
MD5 ecd2f484ed162f36619495266602ceeb
BLAKE2b-256 4a7f4995dc63afd8bbc28c1a8bacaf387b986f999bc0d2faf8f7cb4a637ae5b3

See more details on using hashes here.

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

Hashes for rulang-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 84681da57f99c30902b53b3c7d9aa1b092a512bc200cfc4aa31a3409725ad02d
MD5 e3106048c10cd1ed675d87aa7c853f66
BLAKE2b-256 7a7817b7ac314b4b19de322379e9b8d73bc9e77bddda351a5e6153e419800aa2

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page