lclang
LCL stands for Lazy Context Language. Lazy means that a definition is
evaluated only when a result needs it. Context means that reusable
definitions receive their concrete host inputs, lookup hierarchy, cache, and
lifecycle inside a short-lived Frame. Language means those definitions are
explicit expressions parsed and interpreted by lclang rather than ad hoc
substitution rules hidden in application code.
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.
Why use LCL?
LCL is useful when configuration describes relationships between values, not only values stored in a file. The questions below identify the problems its Module-and-Frame model is designed to solve.
lclang is pure Python, requires Python 3.14 or newer, has no third-party
runtime dependencies, and is distributed under the MIT license.
Are derived settings duplicated across environments?
Static data works well until several fields must agree. Copying a host name, URL, or policy result into every environment creates multiple values that can drift apart. LCL keeps the relationship itself in configuration:
__LCL_VERSION__: 1
scheme: "https"
host: f"api.{environment}.example.com"
endpoint: f"{scheme}://{host}"
The application supplies environment for the current run. host and
endpoint remain derived definitions, so changing the input or the relationship
does not require synchronizing copied results. File-backed definitions also
retain source origins for diagnostics.
Do the same rules need different inputs for each request or command?
Putting all configuration logic in Python can mix stable policy with transient
request data. LCL separates them: an immutable Module records what can be
calculated, while each Frame supplies what those definitions mean for one
run.
import lclang
PRICING = lclang.define_module(
"pricing",
{
"subtotal": "unit_price * quantity",
"discount": "discount_for(customer_id, subtotal)",
"total": "subtotal - discount",
},
)
async def price_order(unit_price, quantity, customer_id, discount_for):
async with lclang.define_frame(
PRICING,
preset={
"unit_price": unit_price,
"quantity": quantity,
"customer_id": customer_id,
"discount_for": discount_for,
},
) as frame:
return await frame.get("total")
PRICING can be parsed once and reused. Every call creates an independent
context with fresh inputs, result snapshots, dependency observations, and owned
asynchronous work. The supplied discount_for callable may be synchronous or
asynchronous; lclang awaits its result when necessary.
Are optional or expensive values calculated even when nobody uses them?
Eager configuration generation can perform unnecessary work or fail because of
an unused branch. A Frame starts from the requested name and evaluates only the
definitions needed to produce it. In the pricing example,
frame.get("subtotal") does not call discount_for; frame.get("total") does.
The first lookup stores a value or ordinary failure as a snapshot. Concurrent callers in the same event loop share the in-flight calculation, and later reads receive the same result. Recalculation is explicit and never silently invalidates dependants, making one Frame a predictable record of one run rather than a reactive spreadsheet.
Can you explain where a computed value came from?
Once configuration contains expressions, debugging only the final value is not enough. LCL can inspect a definition without evaluating it:
tree = frame.inspect_variable("total")
print("\n".join(tree.to_lines()))
The inspection tree reports definition owners, lookup paths, cache states, dependencies, typed values, and failures. Source-aware errors carry the variable evaluation stack, while static graphs and runtime dependency snapshots support ordering and change-impact analysis. This keeps computed configuration explicit even when it becomes more capable than a static data file.
Is LCL the wrong tool for this configuration?
Use JSON, TOML, YAML, or plain Python data when the input is already static and has no meaningful derived relationships. LCL is also not a hostile-code sandbox: expressions, host values, and host callables must come from trusted application configuration. Its purpose is to make trusted, contextual calculation lazy and inspectable, not to execute untrusted user programs.
Installation
python -m pip install lclang
The mental model
Most applications need only two concepts:
- A
Moduleis an immutable set of named, unevaluated definitions. It answers: what can be calculated? - A
Framecombines 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 use async with to close
the Frame deterministically.
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:
async with lclang.define_frame(
INVOICE,
preset={
"unit_price": unit_price,
"quantity": quantity,
"tax": tax,
},
) as frame:
return await frame.get("label")
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() + async with define_frame() |
reuse the Module; the context manager closes 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() |
use each created Frame as an async context manager |
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.
For an optional direct lookup, pass fallback=value. The fallback is returned
unchanged only when the requested name is absent from the complete Frame
hierarchy; it is not cached. Omitting it, or passing lclang.NO_FALLBACK, keeps
the normal LclNameError. Existing definitions that fail still raise their
evaluation error, so fallback=None does not hide a broken definition.
region = await frame.get("region", fallback="global")
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 use each created Frame in an
async with scope. 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.
Business-day calendars
lclang.utils.calendar provides async three-state calendars for business days,
holidays, and undefined dates. Calendars compose with union, intersection,
subtraction, fallback, reversal, and sparse filters; immutable mappings adjust
or shift dates and can be materialized as calendars. Named calendars load from
strict application-selected JSON directories with dependency-aware cache
retirement. Canonical Frames expose the same factories and singletons through
the calendars namespace. See the
calendar reference and
calendar tutorial.
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
- Parse definitions once and reuse the resulting Module.
- Create one Frame per independent run or request.
- Pass only the host values that configuration actually needs.
- Treat cached results as snapshots; recalculate explicitly.
- Use every owned Frame as an async context manager so it closes on block exit.
- Prefer
evaluate_config()when reading only one configuration value. - Catch
LclErrorat 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.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| lclang-1.0.2.tar.gz | 305.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| lclang-1.0.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 554.5 kB
Release files / lclang-1.0.2.tar.gz
| Download URL | lclang-1.0.2.tar.gz |
|---|---|
| Size | 305.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b405f6aba0476e1b954099968f18e068450e7244bb96592bfec8196e3730232b
|
|
BLAKE2b-256 checksum How to use checksums |
6f387ffc3bff76a97b286b6db1545a1c1996b6636caf517584d8f778eef52cba
|
| 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.2-py3-none-any.whl
| Download URL | lclang-1.0.2-py3-none-any.whl |
|---|---|
| Size | 248.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
7472b5b5c6fc15e97972b0988db1458d71010d07e7e50eb94e157c218bd24e06
|
|
BLAKE2b-256 checksum How to use checksums |
3df1527fc1e6412749502e4043d3b276e4fd8c11084e8e157b3e7fd0d5bc36a3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.14.2
|