Skip to main content

lclang

lclang is a small, async-first configuration language for Python applications. It lets you describe related values as expressions, provide environment-specific inputs from Python, and evaluate only the values a particular run needs.

Use it when configuration is more than static data but should remain explicit, inspectable, and separate from application code—for example, derived service settings, deployment policy, command defaults, or request-scoped calculations.

lclang is pure Python, requires Python 3.14 or newer, has no third-party runtime dependencies, and is distributed under the MIT license.

LCL is intended for trusted application configuration. It is not a sandbox for expressions supplied by an attacker. Values and callables provided by the host application retain their ordinary Python capabilities.

Installation

python -m pip install lclang

The mental model

Most applications need only two concepts:

  • A Module is an immutable set of named, unevaluated definitions. It answers: what can be calculated?
  • A Frame combines a Module with host inputs, a lookup hierarchy, lazy result snapshots, and owned asynchronous work. It answers: what do those definitions mean for this run?
          parse once                              create per run

  expression source ──> Module       Module + host inputs ──> Frame
                         │                                      │
                  reusable definitions                 lazy value snapshots

Modules are safe to reuse across requests, tenants, commands, and tests because they contain no evaluation state. Frames are intentionally stateful and belong to one event loop. Create a fresh Frame for each independent run and close it when that run finishes.

This separation is the central lclang usage pattern: define once, evaluate in a short-lived context.

Preferred application pattern

Define the Module when the application loads. Supply narrow Python values when creating the Frame, request the outputs you need, and close the Frame in a finally block.

import asyncio

import lclang


INVOICE = lclang.define_module(
    "invoice",
    {
        "subtotal": "unit_price * quantity",
        "total": "subtotal + tax",
        "label": 'f"Total: {total:.2f}"',
    },
)


async def price_invoice(*, unit_price: float, quantity: int, tax: float) -> str:
    frame = lclang.define_frame(
        INVOICE,
        preset={
            "unit_price": unit_price,
            "quantity": quantity,
            "tax": tax,
        },
    )
    try:
        return await frame.get("label")
    finally:
        await frame.close()


async def main() -> None:
    label = await price_invoice(unit_price=6.5, quantity=4, tax=2.0)
    assert label == "Total: 28.00"


asyncio.run(main())

Definition order is not evaluation order. Here total may refer to subtotal regardless of where either appears in the mapping. Parsing validates every expression up front; evaluation follows name lookups only when frame.get() is called.

define_module() and define_frame() are the preferred high-level APIs. A Frame created this way also receives lclang's reviewed pure builtins and the iter, text, data, and json namespaces.

Choose the smallest entry point

Need Preferred API Ownership model
Evaluate one expression in synchronous code evaluate_sync(source, values) lclang owns the temporary event loop
Evaluate related named definitions define_module() + define_frame() reuse the Module; close each Frame
Load a .lclcfg file and read one value load_config() + evaluate_config() evaluate_config() closes its temporary Frame
Evaluate one parsed expression asynchronously parse_expression() + await evaluate() caller supplies the resolver values
Parse, print, or analyze syntax without running it parse_expression() + to_source() or runtime analysis APIs no evaluation state is created
Create many runs with the same policy FrameFactory or Config.frame_factory() close every created Frame

For a small synchronous script, keep things direct:

import lclang

total = lclang.evaluate_sync(
    "unit_price * quantity",
    {"unit_price": 6, "quantity": 4},
)
assert total == 24

Do not call evaluate_sync() from a running event loop. Async applications should use Frames or await evaluate().

Values are snapshots, not reactive cells

The first await frame.get("name") evaluates the selected definition and caches either its value or its ordinary failure. Concurrent callers in the same event loop share that work. Later reads return the same snapshot.

Changing a host input does not automatically invalidate cached definitions or their dependants. This is deliberate: recalculation is explicit and local.

frame.mixin({"unit_price": 10})
await frame.recalculate("subtotal")
await frame.recalculate("total")

Think of a Frame as one reproducible calculation run, not as a spreadsheet. If many inputs change together, creating a new Frame is often clearer than refreshing an existing one. Use mixin() and recalculate() when retaining the run's other snapshots is intentional.

Configuration files are Modules with provenance

Use .lclcfg when definitions should live outside Python, be composed from multiple files, or retain file and line information in diagnostics.

__LCL_VERSION__: 1

scheme: "https"
host: f"api.{environment}.example.com"
endpoint: f"{scheme}://{host}"

Load files asynchronously. Use evaluate_config() when you need one result and do not need to retain a Frame:

from pathlib import Path

from lclang.config import evaluate_config, load_config


async def endpoint_for(environment: str) -> str:
    config = await load_config(Path("settings.lclcfg"))
    result = await evaluate_config(
        config,
        "endpoint",
        values={"environment": environment},
    )
    assert isinstance(result, str)
    return result

If several values must share one cache, convert the loaded configuration into a Module or use config.frame_factory(), then create and close a Frame in the usual way. using declarations expand other configuration sources in source order; later definitions win while origin and history remain available for diagnostics.

Keep the Python boundary narrow

Names resolve through the user Module, supplied application values, runtime values, reviewed builtins, and standard namespaces. Prefer passing plain values or purpose-built callables rather than exposing broad service objects.

Host callables may be synchronous or asynchronous. lclang awaits resolver values, call results, iterator operations, and context-manager protocols when needed. Application code remains responsible for the behavior and authority of anything it provides.

The language is expression-only and intentionally familiar:

profile?.display_name ?? "anonymous"
[item * 2 for item in values if item > 0]
f"{service}: {port}"
value -> value * 2
(left, right=10) -> left + right
try primary() except ServiceError: fallback()

Arrow functions use () -> expression, name -> expression, or (parameters) -> expression. Definitions are immutable syntax; lexical closures retain the definition context in which they were created.

Diagnostics and inspection

Expected library failures derive from lclang.LclError. Syntax, name, evaluation, circular-dependency, closed-Frame, and configuration failures have specific subclasses and retain source information where available.

Use frame.has() and frame.get_definition() for lookup checks that must not evaluate anything. Use frame.inspect_variable() when debugging a value's owner, cache state, dependency tree, or failure path. Dependency graphs and snapshots are available when an application needs ordering or change-impact analysis; they are not required for normal evaluation.

Practical rules

  1. Parse definitions once and reuse the resulting Module.
  2. Create one Frame per independent run or request.
  3. Pass only the host values that configuration actually needs.
  4. Treat cached results as snapshots; recalculate explicitly.
  5. Close every Frame you create, normally in finally.
  6. Prefer evaluate_config() when reading only one configuration value.
  7. Catch LclError at the application boundary and preserve its source-aware diagnostic text.

Project resources

Requirements and license

  • Python 3.14 or newer
  • No third-party runtime dependencies
  • MIT license

Metadata

Release files for lclang 1.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for lclang 1.0.0
File Size Uploaded
lclang-1.0.0.tar.gz 277.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for lclang 1.0.0
File Interpreter ABI Platform
lclang-1.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 474.4 kB

Release files / lclang-1.0.0.tar.gz

Download URL lclang-1.0.0.tar.gz
Size 277.3 kB
Tags Source
SHA-256 checksum
How to use checksums
3460c6a6ae1cd44b6e64c32759dd9375e7e8879e4f27e54dcf37ca32fc88123f
BLAKE2b-256 checksum
How to use checksums
11a64a92436e3defdbabbed6db015267fb89e2d92dd18d5b78a863d0358c5aad
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.14.2

Release files / lclang-1.0.0-py3-none-any.whl

Download URL lclang-1.0.0-py3-none-any.whl
Size 197.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
202fcc0830839242191341ea38548fa670ede4cf2c58fc7b9c67e5a72c4e06ee
BLAKE2b-256 checksum
How to use checksums
dddb63b860f4470d387551ef337804e10e55dbc86c80559e0f947a2537ee47f6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.14.2

Release history Release notifications | RSS feed

1.1.2

2 release files

1.1.1

2 release files

1.1.0

2 release files

1.0.14

2 release files

1.0.13

2 release files

1.0.12

2 release files

1.0.11

2 release files

1.0.10

2 release files

1.0.9

2 release files

1.0.8

2 release files

1.0.7

2 release files

1.0.6

2 release files

1.0.5

2 release files

1.0.4

2 release files

1.0.3

2 release files

1.0.2

2 release files

1.0.1

2 release files

This release

1.0.0 This release

2 release files

0.3.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page