lclang
A typed, async-first configuration expression language for Python.
Project website · Documentation · Tutorials
lclang gives applications a disciplined way to express calculated
configuration, evaluate it only when needed, and explain where every result
came from. It occupies the useful space between static data files and putting
all configuration logic in Python.
LCL stands for Lazy Context Language:
- Lazy definitions run only when a requested result depends on them.
- Context comes from a short-lived Frame containing host inputs, lookup hierarchy, cached results, dependency evidence, and owned async work.
- Language means rules are parsed into an immutable syntax tree and run by lclang's own interpreter, rather than hidden in string substitution or application glue.
The package includes the expression language, reusable Modules and Frames,
UTF-8 .lclcfg files, dependency analysis, runtime inspection, reviewed
standard namespaces, a typed command-line framework, tree workflows and status,
and a composable business-day calendar system.
Import language construction and parsing from lclang.lang, configuration from
lclang.config, command-line tools from lclang.cli, and tasks from
lclang.workflow. Utilities, logging and diagnostics have their own
lclang.utils, lclang.logger and lclang.error APIs. The package root has no
public API exports.
Why lclang
Static formats are excellent for static values. They become awkward when a configuration needs derived names, conditional policy, reusable functions, request-specific inputs, or an explanation of why a value has its current form. Plain Python can calculate all of that, but it also makes the boundary between policy and application behavior easy to blur.
lclang provides a strong way of working while leaving room for each application to supply its own values and callables:
- Parse rules once, then reuse them safely across independent runs.
- Keep stable policy separate from request, tenant, command, or environment inputs.
- Evaluate only the dependency path needed for the requested output.
- Share concurrent work and cache value or failure snapshots predictably.
- Inspect definitions, owners, cache states, lookup paths, and dependencies.
- Diagnose failures with specific six-digit codes, retained causes and source
snapshots; catch the shared
LclErrorfamily inlclang.error. - Handle combined execution and cleanup failures with Python's exception-group protocol; follow the exception guide for actual diagnostics and ownership examples.
- Compose configuration files without losing source and history information.
- Use the same evaluation model in services, batch jobs, CLIs, and tests.
- Stay fully typed with no third-party runtime dependencies.
This makes lclang especially useful for deployment policy, derived service settings, pricing and eligibility rules, command defaults, scheduling logic, and other trusted configuration that has real relationships between values.
Installation
python -m pip install lclang
For upgrades from 1.0.16, follow the 1.1.0 migration steps for imports, error handling and logging changes.
Start in 60 seconds
Define a reusable Module once. Create a fresh Frame for one calculation, supply the values owned by the application, and ask only for the outputs you need.
import asyncio
from lclang.lang import define_frame, define_module
ORDER = define_module(
"order",
{
"subtotal": "unit_price * quantity",
"shipping": "0 if subtotal >= 50 else 5",
"total": "subtotal + shipping",
"summary": 'f"{quantity} items: {total:.2f}"',
},
)
async def main() -> None:
async with define_frame(
ORDER,
preset={"unit_price": 13, "quantity": 3},
) as frame:
assert await frame.get("subtotal") == 39
assert await frame.get("total") == 44
assert await frame.get("summary") == "3 items: 44.00"
asyncio.run(main())
The first lookup multiplies the two host inputs and stores subtotal as a
snapshot. The total lookup reuses that snapshot, takes the false branch of the
shipping condition because 39 is below 50, and adds 5. The final lookup follows
summary -> total -> subtotal; the previously calculated values are reused, and
the f-string formats 44 with two decimal places.
Definition order is not evaluation order. Every expression is parsed and validated when the Module is created, while evaluation follows name lookups from the requested result.
For a single expression, give its result a name and use the same workflow:
import asyncio
from lclang.lang import define_frame, define_module
TOTAL = define_module("total", {"result": "unit_price * quantity"})
async def main() -> None:
async with define_frame(
TOTAL,
preset={"unit_price": 6, "quantity": 4},
) as frame:
assert await frame.get("result") == 24
asyncio.run(main())
asyncio.run() is the outer boundary for a synchronous application. In an
async application, await your coroutine in the application's existing loop.
The Module and Frame model
Most integrations need only two concepts.
Module: what can be calculated?
A Module is an immutable collection of named, unevaluated definitions. It has
no per-run cache or lifecycle state, so the same Module can be reused across
requests, tenants, commands, and tests.
Frame: what do those definitions mean now?
A Frame combines a Module with concrete host inputs, parent lookup, lazy
result snapshots, dependency observations, and owned asynchronous work. It is
stateful, belongs to one event loop, and should normally represent one
independent run.
Scoped values organize larger policies without creating nested Frames. A flat
binding such as service.database.port makes service and
service.database lazy proxies over the same requesting Frame. For a quick
unnamed calculation over that context, use
await frame.evaluate("service.database.port * 2"); unlike frame.get, the
unnamed root is parsed and run on every call.
Proxy attribute and string-index access are equivalent. Immediate children are
available through await proxy.field_names(), and
await proxy[name].as_record(DataclassType) materializes a detached typed view
while every field retains the shared Frame's lookup, caching, tracing, and
override behavior.
expression source -> immutable Module
immutable Module + host inputs -> short-lived Frame -> requested results
Use async with so owned work and async context managers close
deterministically. A Frame is safe for concurrent tasks in the same event loop,
but not across different loops.
The first await frame.get(name) calculates and stores either a value or an
ordinary failure. Concurrent callers share the in-flight work. Later reads get
the same snapshot. This is deliberate: a Frame is a reproducible calculation
run, not a reactive spreadsheet.
Changing an input does not silently invalidate dependants. Recalculation is explicit and local:
frame.mixin({"unit_price": 10})
await frame.recalculate("subtotal")
await frame.recalculate("total")
If several related inputs change, creating a new Frame is often clearer. Use
mixin() and recalculate() when retaining the other snapshots in the current
run is intentional.
Feature tour
Expression language
LCL is expression-only, Unicode-aware, and intentionally familiar to Python users. It supports arithmetic, comparisons, short-circuit Boolean logic, conditionals, null coalescing, safe attributes, calls, slices, unpacking, collection displays, comprehensions, generators, f-strings, exceptions, immutable named records, context managers, and sync or async iteration.
profile?.display_name ?? "anonymous"
{name=profile.name, active=true}.name
[item * 2 for item in values if item > 0]
f"{service}: {port}"
(left, right=10) -> left + right
try primary() except ServiceError: fallback()
Arrow functions provide defaults, keyword arguments, lexical closures, and
recursive-program support. parse_expression() returns immutable syntax,
to_source() renders canonical source. Public evaluation uses define_module(),
define_frame(), and await frame.get(name) so calculations share one lookup
and ownership model.
Configuration files with provenance
.lclcfg files move definitions outside Python while preserving file, line,
override, and inclusion history.
__LCL_VERSION__: 1
service: "catalog"
port: 8443
host: f"{service}.{environment}"
address: f"{host}:{port}"
The application supplies environment; the file owns the stable relationship
between it and the final address. Definitions can refer forward or backward.
using declarations expand other .lclcfg files in source order, later
definitions win, and complete history remains available for diagnostics.
using? skips a directly missing optional file while preserving errors in
existing files. Both declarations share the same target and expansion rules.
import and import? expand an independent child context under a required
static alias, such as import f"{__dir__}/pricing.lclcfg" as pricing.
References to child definitions become pricing.*; undeclared inputs remain
external. Repeated aliases merge fields, and existing empty files reserve empty
namespaces. Use f"{__dir__}/..." for every introduction to make its defining
directory explicit.
Targets may be literal paths or position-sensitive LCL f-strings. A dynamic
target sees definitions already expanded above it, explicit loader or CLI
overrides, and canonical builtins such as env; its temporary evaluation cache
is discarded before final runtime winners are built.
from pathlib import Path
from lclang.config import load_config
async def address_for(environment: str) -> str:
config = await load_config(Path("settings.lclcfg"))
async with config.to_frame(preset={"environment": environment}) as frame:
result = await frame.get("address")
assert isinstance(result, str)
return result
Loading is asynchronous, UTF-8, bounded by configurable limits, cycle-aware, and concurrency-sharing. It performs no globbing or network access. Only dynamic file-target f-strings evaluate during composition.
NEED_OVERRIDE requires an actual replacement definition, while
RUNTIME_OVERRIDE also accepts runtime inputs. Both fail lazily when unfilled.
Module.mixin() composes replacement definitions into a new immutable Module;
Frame.mixin() supplies host values. The
configuration composition tutorial
explains precedence, isolation, overrides, and common mistakes.
For readable configuration, keep definitions from one scope on consecutive
rows, separate functional or scope groups with a blank line, and describe rows
or groups with # comments. Qualified leaves infer their prefixes, so explicit
FRAME_PROXY declarations remain supported but are not recommended for an
ordinary file layout.
Dependency analysis and runtime inspection
lclang can explain calculated configuration instead of treating it as a black box.
- Static analysis classifies eager, conditional, and deferred references.
- Module and Frame graphs support filtered queries and deterministic topological ordering.
- Runtime tracing records the names actually selected during evaluation.
- Dependency snapshots reconcile static possibilities with observed work.
frame.get_definition(name)returns selected syntax without evaluating it.frame.inspect_variable(name)builds a detached tree of owners, lookup paths, cache states, values, failures, and dependency descendants.
tree = frame.inspect_variable("total")
print("\n".join(tree.to_lines()))
Syntax, name, evaluation, circular-dependency, closed-Frame, and configuration
failures use specific LclError subclasses. Source-aware errors retain the
complete loading and evaluation stacks, source excerpts, and snapshots of values
already read. The multiline English display preserves original causes and
tracebacks without performing another evaluation. Exact-name masks protect
expressions and captured values, including masked function arguments.
For execution details, use use_verbose(logger, enabled=verbose) from
lclang.logger. It sends readable DEBUG records through the application's
standard-library or lclang logger. Failure identifiers connect the first observed
failure, later cached returns and the complete exception diagnostic. See the
logging reference.
Text diagnostics share dependency-tree connectors and inline error reasons.
Verbose records display TRACE with a compact prefix while retaining DEBUG
severity. CLI eval_lcl -o TREE shows actual dependencies; parse_lcl shows
static inspection unless -o EVAL is selected. LclExceptionFormatter provides
the same exception trees to application loggers, with compact or full Python
tracebacks. See text diagnostics.
Python callables show their implementation location when available. Applications
can install explicit uncaught-error hooks through lclang.logger to retain user
Python frames alongside the LCL diagnostic tree.
Async-first evaluation
Frame inputs, host callables, call results, iterators, and context-manager protocols may be synchronous or asynchronous. lclang awaits them when needed and keeps cancellation isolated between the owner of a calculation and other tasks waiting for it.
Task-local evaluation limits can bound work. Circular lookups produce a named dependency path. Frame closing finalizes owned resources deterministically. These properties let the same policy work naturally in an async service without making simple synchronous scripts cumbersome.
Reviewed standard namespaces
Canonical Frames include familiar pure builtins plus small, read-only namespaces assembled from reviewed manifests:
iter.collectanditer.firstconsume sync or async iterables.text.joinandtext.linesprovide deterministic text operations.data.lookupanddata.mergework with immutable mapping snapshots.json.encodeandjson.decodeprovide strict JSON conversion.parse_ymdandto_ymdconvert dates to and from strictYYYYMMDDstrings.recursivebuilds eager fixed-point functions for recursive LCL programs.SnowflakeGeneratorcreates a reusable ID generator with an assigned worker ID.env.NAMEreads a live process environment value, with scoped overrides andenv.get(name, default)for arbitrary names.
SnowflakeGenerator.next_id() reads the wall clock and advances its instance's
sequence. Apart from this stateful utility and the explicit read-only env
utility, the helpers provide no ambient
filesystem, process, network, dynamic import, reflection, or mutation
capability.
Command-line applications
lclang.cli combines typed async commands, nested groups, configuration and
overrides, structured help, process-scoped queue logging and explicit dry-run policy.
Handlers receive a CliContext and return a CliResult.
CliEntrance(..., lcl_mixin={...}) shares host defaults with every routed
command's configuration and workflow tasks.
See the CLI reference
and CLI tutorial.
Standalone applications use LoggerHandlerConfig, use_logger_handler and
use_logger from lclang.logger, which also exports Logger, runtime, resolved
configuration and metric types. await resolve_logger_config(frame) extracts
logger.* from a Frame loaded from .lclcfg, without requiring CLI.
Logger methods accept compact=True for task boundaries that omit the logger
name and caller location from the default layout; ordinary calls retain both.
Log lines default to server local time with a numeric offset; set
logger.timezone to "utc" for UTC timestamps ending in Z.
One background writer handles stderr and named
file sinks, with independent UTC time/size rotation and permanent segment paths.
logger.file.default supplies missing sink fields; explicit enabled settings
win over template overrides. CLI and Workflow share these keys through LCL and
-o. Scope exit drains output and restores stdlib logging configuration.
See the logger reference.
env, safe_repr, and SnowflakeGenerator are available from lclang.utils; the
Python utilities tutorial
shows these APIs without requiring CLI or LCL evaluation.
Tree workflows and status
Dataclass fields supply CLI help and override paths from annotations, defaults
and metadata["help"]. flatten_to_dict prepares dotted presets while preserving
leaf references and the existing Frame rules for objects and scoped paths.
Explicit ValueBox and CallableBox wrappers let typed projections represent
optional values, collections and callbacks without runtime dependencies. Only
declared workflow bindings box inputs and unbox outputs; ordinary objects keep
their contents and identity. See the type guide
and mapping guide.
lclang.workflow defines a validated tree of typed async actions. Plain
recursive dataclass mappings connect named TaskVar values and typed field
projections to arguments and explicitly
publish outputs. A typed variable's .quote can also supply an entire dataclass
mapping, with scope record conversion and publication. Each node can own async
context tasks for local resources or
exception handling, followed by ordered child tasks. Execution is parent-first,
depth-first against one shared Frame; inherited task Frames confine context outputs
to the owning subtree,
while mapped action outputs can feed later nodes.
Variables support fixed defaults or lazy synchronous/asynchronous factories,
shared once per execution and visible to both mappings and LCL expressions.
TaskContext.log_event() records selected business fields with explicit masking
as aligned multiline messages headed by the complete task log path. Nested
workflow logs retain parent call names while business task IDs remain local. Public
make_repr_lines and make_multi_log_lines helpers offer the same protected
formatting for application records. lclang.utils.invoke() calls sync or async
callbacks once and resolves their results without background work.
An action can call context.skip_children() to omit its child subtree without
creating status records, while retaining its own output and resource cleanup.
Actions can use async with workflow.execute_in_task(...)
for dynamic batches, with explicit inputs, isolated Frames, native outputs and
detached child status. The child Frame stays available until the call scope exits.
define_workflow(..., lcl_mixin={...}) supplies host values and callables to
configuration expressions and tasks, and becomes host defaults in CLI commands.
The fixed tree, scope, cleanup, and status rules provide a strong format. Inside
it, actions remain ordinary async Python and can use application-specific
services or detailed status steps. workflow.to_lines() renders the static tree
and field flows. workflow.to_cli() infers external parameters, help, and
masking, with required/optional inputs and static defaults grouped by scope.
lclang.cli.scan_commands() discovers commands in a package.
Execution records nested task and step outcomes, including visible covered
failures, skipped branches, and error origins. Contexts unwind in reverse order,
and the result retains the final status tree plus every successful action's
materialized arguments and outputs. CLI workflows log start, traceback-bearing
error, and finalized completion records using dot-connected task branches and
callback-only action/entry/exit durations. Status-driven unwinding produces a
single-line warning; real context handling and cleanup failures keep their diagnostics.
Default lifecycle prefixes omit framework locations, while events retain application callers;
__task_id_branch__ exposes the owning branch inside task Frames. Verbose runs
add aligned typed argument/output mappings. The final status tree is one
severity-aware multi-line record.
Business-day calendars
lclang.utils.calendar provides an async-first, three-state calendar algebra.
Every date is a business day, holiday, or undefined; undefined means that the
calendar has no opinion and enables meaningful composition.
Calendars support union, intersection, subtraction, ordered fallback, reversal,
business-only and holiday-only filters, bounded date movement, immutable date
mappings, built-in weekday and period-boundary calendars, strict JSON loading,
concurrent named-calendar caching, and dependency-aware retirement. Canonical
Frames expose the reviewed factories and singletons through the calendars
namespace.
The calendar layer works as a Python utility in its own right and also gives LCL configuration a precise vocabulary for settlement dates, processing windows, and scheduling policy.
Choose the integration for your policy
| Need | Preferred API | Ownership model |
|---|---|---|
| One or more named expressions | define_module() and async with define_frame() |
reuse the Module; close each Frame |
| One unnamed expression in an existing context | await frame.evaluate(source) |
Frame supplies lookup; caller owns the uncached result |
| Definitions from a config file | load_config() and config.to_frame() |
reuse the Config; close each Frame |
| Many runs with the same policy | FrameFactory or Config.frame_factory() |
each created Frame is caller-owned |
| A typed multi-step operation | define_workflow() then await workflow.execute() |
caller owns the shared execution Frame |
| Syntax printing or analysis | parse_expression() and analysis APIs |
no evaluation state is created |
For optional lookup, await frame.get(name, fallback=value) returns the
fallback only when the name is absent from the complete Frame hierarchy. It
does not hide a definition that exists but fails, and it does not cache the
fallback.
When lclang fits
Choose lclang when configuration has meaningful relationships and benefits from lazy evaluation, per-run context, source-aware diagnostics, or dependency evidence. It is a strong fit when rules should be reusable and inspectable but host applications must retain control over inputs and capabilities.
Prefer JSON, TOML, YAML, or plain Python data when the input is already static and has no useful derived relationships. Prefer ordinary Python when the logic is fundamentally application behavior, needs unrestricted object access, or is clearer as a normal function than as configuration policy.
Practical integration rules
- Parse definitions once and reuse the resulting Module.
- Create one Frame per independent request, command, tenant, or run.
- Pass narrow values and purpose-built callables, not broad service objects.
- Request only the output names the application needs.
- Treat cached values as snapshots and recalculate explicitly.
- Use caller-owned Frames as async context managers.
- Convert loaded configuration with
config.to_frame()and use the same Frame lookup and lifecycle as definitions written in Python. - Catch
LclErrorat the application boundary and preserve its diagnostic context.
Production characteristics
- Stable 1.x release line, with migration notes for minor releases.
- Pure Python for Python 3.14 and newer.
- Fully typed and ships a
py.typedmarker. - No third-party runtime dependencies.
- Python utilities for awaiting, masks, defaults, scoped proxy markers and dates
are available from
lclang.utilswithout loading the language engine. - Custom lexer, parser, immutable AST, and async interpreter.
- No Python
eval,exec, AST compilation, Java, or generated parser runtime. - 100% branch coverage enforced by the project quality gate.
- Black formatting and strict mypy/Pyright checks, with shared Pylance editor settings.
- Parser differential and property tests, concurrency and dependency stress tests, lifecycle leak checks, and executable documentation.
- Source integration smoke and default full stress tests.
Trust and security
lclang is a trusted configuration language, not a hostile-code sandbox.
env reads the process environment; explicitly configured calendar loaders
read files. Host-provided objects and callables retain their ordinary Python
capabilities. Developer command discovery imports the application's trusted
Python modules. These choices determine the authority available during use.
Treat LCL source, .lclcfg files, host objects, and host callables as trusted
application configuration. Keep the Python boundary narrow and use loading and
evaluation limits where bounded work matters.
Requirements and license
- Python 3.14 or newer
- No third-party runtime dependencies
- MIT license
See the error reference for the complete builtin code directory and executable examples of common failures.
Metadata
Release files for lclang 1.1.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.1.2.tar.gz | 325.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| lclang-1.1.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 853.6 kB
Release files / lclang-1.1.2.tar.gz
| Download URL | lclang-1.1.2.tar.gz |
|---|---|
| Size | 325.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
980a2ada94ac0c34e97a110591027032b286e0cbb02962422c43bdf6b40ab907
|
|
BLAKE2b-256 checksum How to use checksums |
fdead94806e04eb1cf23794d505792f682f32c3dfa019551740c5253d3d9d851
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 16, 2026.
Transparency logRelease files / lclang-1.1.2-py3-none-any.whl
| Download URL | lclang-1.1.2-py3-none-any.whl |
|---|---|
| Size | 528.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
93a989ed95b6a5c25cd0aad26961f3f81fb1f3f253b6efe001a0ada5b8d71ab9
|
|
BLAKE2b-256 checksum How to use checksums |
1e7af3a5cdfb658a1e9077ac392a62aafc69b7e53daea068f22eb0be5c63a12e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 16, 2026.
Transparency log