Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Using WCL as a Python Library

WCL has native Python bindings via PyO3. The pywcl package provides the full 11-phase parsing pipeline with Pythonic types — values come back as native dict, list, int, str, etc.

Installation

Install from PyPI:

pip install pywcl

Or install from source (requires a Rust toolchain and maturin):

cd bindings/python
pip install -e .

Parsing a WCL String

Use wcl.parse() to run the full pipeline and get a Document:

import wcl

doc = wcl.parse("""
    server web-prod {
        host = "0.0.0.0"
        port = 8080
        debug = false
    }
""")

if doc.has_errors:
    for e in doc.errors:
        print(f"error: {e.message}")
else:
    print("Document parsed successfully")

Parsing a WCL File

parse_file() reads and parses a file. It automatically sets the root directory to the file’s parent so imports resolve correctly:

doc = wcl.parse_file("config/main.wcl")

if doc.has_errors:
    for e in doc.errors:
        print(f"error: {e.message}")

Raises IOError if the file doesn’t exist.

Accessing Evaluated Values

After parsing, doc.values is a Python dict with all evaluated top-level attributes and blocks. Values are converted to native Python types:

doc = wcl.parse("""
    name = "my-app"
    port = 8080
    tags = ["web", "prod"]
    debug = false
""")

print(doc.values["name"])   # "my-app" (str)
print(doc.values["port"])   # 8080 (int)
print(doc.values["tags"])   # ["web", "prod"] (list)
print(doc.values["debug"])  # False (bool)

WCL types map to Python types as follows:

WCL TypePython Type
stringstr
intint
floatfloat
boolbool
nullNone
listlist
mapdict
setset (or list if items are unhashable)

Working with Blocks

Use blocks() and blocks_of_type() to access parsed blocks with resolved attributes:

doc = wcl.parse("""
    server web-prod {
        host = "0.0.0.0"
        port = 8080
    }

    server web-staging {
        host = "staging.internal"
        port = 8081
    }

    database main-db {
        host = "db.internal"
        port = 5432
    }
""")

# Get all blocks
blocks = doc.blocks()
print(f"Total blocks: {len(blocks)}")  # 3

# Get blocks of a specific type
servers = doc.blocks_of_type("server")
for s in servers:
    print(f"server id={s.id} host={s.get('host')} port={s.get('port')}")

Each BlockRef has the following properties:

block.kind        # str — block type name (e.g. "server")
block.id          # str | None — inline ID (e.g. "web-prod")
block.attributes  # dict — evaluated attribute values (includes _args if inline args present)
block.children    # list[BlockRef] — nested child blocks
block.decorators  # list[Decorator] — decorators on this block

And these methods:

block.get("port")              # attribute value, or None if missing
block.has_decorator("deprecated")  # True/False

Working with Tables

Tables evaluate to a list of row dicts. Each row is a dict mapping column names to cell values:

doc = wcl.parse("""
    table users {
        name : string
        age  : int
        | "alice" | 25 |
        | "bob"   | 30 |
    }
""")

users = doc.values["users"]
print(users)
# [{"name": "alice", "age": 25}, {"name": "bob", "age": 30}]

# Access individual rows
print(users[0]["name"])  # "alice"
print(users[1]["age"])   # 30

Tables inside blocks appear in the block’s attributes:

doc = wcl.parse("""
    service main {
        table config {
            key   : string
            value : int
            | "port" | 8080 |
        }
    }
""")

block = doc.blocks_of_type("service")[0]
print(block.get("config"))  # [{"key": "port", "value": 8080}]

Running Queries

doc.query() accepts the same query syntax as the wcl query CLI command:

doc = wcl.parse("""
    server svc-api {
        port = 8080
        env = "prod"
    }

    server svc-admin {
        port = 9090
        env = "prod"
    }

    server svc-debug {
        port = 3000
        env = "dev"
    }
""")

# Select all server blocks
all_servers = doc.query("server")

# Filter by attribute
prod = doc.query('server | .env == "prod"')

# Project a single attribute
ports = doc.query("server | .port")
print(ports)  # [8080, 9090, 3000]

# Filter and project
prod_ports = doc.query('server | .env == "prod" | .port')
print(prod_ports)  # [8080, 9090]

# Filter by comparison
high_ports = doc.query("server | .port > 8500")
print(high_ports)  # [BlockRef for svc-admin, BlockRef for svc-debug]

Raises ValueError if the query is invalid.

Custom Functions

Register Python functions callable from WCL expressions by passing a functions dict:

def double(args):
    return args[0] * 2

def greet(args):
    return f"Hello, {args[0]}!"

doc = wcl.parse("""
    result = double(21)
    message = greet("World")
""", functions={"double": double, "greet": greet})

print(doc.values["result"])   # 42
print(doc.values["message"])  # "Hello, World!"

Functions receive a single args list with native Python values and should return a native Python value. Errors propagate as diagnostics:

def safe_div(args):
    if args[1] == 0:
        raise ValueError("division by zero")
    return args[0] / args[1]

doc = wcl.parse("result = safe_div(10, 0)", functions={"safe_div": safe_div})
assert doc.has_errors  # The ValueError becomes a diagnostic

Functions can return any supported type:

def make_list(args):
    return [1, 2, 3]

def is_even(args):
    return args[0] % 2 == 0

def noop(args):
    return None

Custom functions also work in control flow expressions:

def items(args):
    return [1, 2, 3]

doc = wcl.parse(
    "for item in items() { entry { value = item } }",
    functions={"items": items},
)

Parse Options

All options are passed as keyword arguments to parse():

doc = wcl.parse(source,
    root_dir="./config",         # root directory for import resolution
    allow_imports=True,          # enable/disable imports (default: True)
    max_import_depth=32,         # max nested import depth (default: 32)
    max_macro_depth=64,          # max macro expansion depth (default: 64)
    max_loop_depth=32,           # max for-loop nesting (default: 32)
    max_iterations=10000,        # max total loop iterations (default: 10,000)
    functions={"my_fn": my_fn},  # custom functions
)

When processing untrusted input, disable imports to prevent file system access:

doc = wcl.parse(untrusted_input, allow_imports=False)

Library Files

Create .wcl library files manually and place them in ~/.local/share/wcl/lib/. Use wcl.list_libraries() to list installed libraries. See the Libraries guide for details.

Error Handling

The Document collects all diagnostics from every pipeline phase. Each Diagnostic has a severity, message, and optional error code:

doc = wcl.parse("""
    server web {
        port = "not_a_number"
    }

    schema "server" {
        port: int
    }
""")

# Check for errors
if doc.has_errors:
    for e in doc.errors:
        code = f"[{e.code}] " if e.code else ""
        print(f"{e.severity}: {code}{e.message}")

# All diagnostics (errors + warnings)
for d in doc.diagnostics:
    print(f"{d.severity}: {d.message}")

The Diagnostic type:

d.severity  # "error", "warning", "info", or "hint"
d.message   # str — the diagnostic message
d.code      # str | None — e.g. "E071" for type mismatch
repr(d)     # "Diagnostic(error: [E071] type mismatch: ...)"

Use doc.has_errors (bool) as a quick check, doc.errors for only errors, and doc.diagnostics for everything including warnings.

Complete Example

Putting it all together — parse a configuration, validate it, query it, and extract values:

import wcl

doc = wcl.parse("""
    schema "server" {
        port: int
        host: string @optional
    }

    server svc-api {
        port = 8080
        host = "api.internal"
    }

    server svc-admin {
        port = 9090
        host = "admin.internal"
    }
""")

# 1. Check for errors
if doc.has_errors:
    for e in doc.errors:
        print(f"{e.severity}: {e.message}")
    exit(1)

# 2. Query for all server ports
ports = doc.query("server | .port")
print(f"All ports: {ports}")  # [8080, 9090]

# 3. Iterate resolved blocks
for server in doc.blocks_of_type("server"):
    id = server.id or "(no id)"
    host = server.get("host")
    port = server.get("port")
    print(f"{id}: {host}:{port}")

# 4. Custom functions
def double(args):
    return args[0] * 2

doc2 = wcl.parse("result = double(21)", functions={"double": double})
print(f"result = {doc2.values['result']}")  # 42

Building from Source

# Install development dependencies
cd wcl_python
python -m venv .venv
source .venv/bin/activate
pip install maturin pytest

# Build and install in development mode
maturin develop

# Run tests
pytest tests/ -v

# Or via just
just test-python

This requires the Rust toolchain and Python 3.8+.