looker-cst
A lossless LookML parser written in Rust with Python bindings. It parses a LookML file into a concrete syntax tree (CST) that keeps every comment, blank line and indentation, so a file can be read, modified and written back out with its formatting intact:
from looker_cst import parse
doc = parse(source)
assert str(doc) == source # byte for byte, for every file in the looker repo
The parser is built with nom. The Python package is an abi3 wheel built against Python 3.9, so one wheel works on Python 3.9 and 3.11.
Python usage
from looker_cst import parse, LookmlSyntaxError
doc = parse(path.read_text())
view = doc.find("view", "users") # first `view: users { ... }`
dim = view.find("dimension", "id")
dim["type"] # "number"
dim["type"] = "string" # keeps the value's kind: type: string
dim["label"] = "User ID" # adds a missing pair: label: "User ID"
dim["hidden"] = True # hidden: yes
dim["sql"] = "${TABLE}.user_id" # sql: ${TABLE}.user_id ;;
dim.comments = ["# primary key"] # rewrites the comment lines above the pair
email = view.add("dimension", name="email", index=2) # new block, spaced like its siblings
email["type"] = "string"
measure = view.find("measure", "count")
measure["drill_fields"] = ["id", "email"] # list layout and quoting are kept
measure["filters"] = {"status": "active"} # filters: [status: "active"]
view.add_source("""
measure: total {
type: sum
sql: ${TABLE}.amount ;;
}
""") # parsed and re-indented to fit
view.remove("dimension", name="legacy") # removes the pair and its comments
dim.detach() # removes a pair from wherever it is
path.write_text(str(doc))
API
parse(source) -> Document raises LookmlSyntaxError (a ValueError) with line, column
and offset attributes.
Document and block Pairs are containers with the same methods:
| Method | Description |
|---|---|
children |
Direct child pairs |
find(key, name=None) / find_all(...) |
Child pairs by key, and block name |
get(key, default=None), c[key] |
Value of the first child with key |
c[key] = value |
Set the first child with key, adding it when missing |
del c[key], remove(pair_or_key, name=None) |
Remove a child and the comments above it |
add(key, value=None, *, name, kind, index) |
Add a child; with no value it is a block, named when name given |
add_source(source, index=None) |
Insert pairs parsed from a LookML snippet, re-indented |
walk() |
Every pair underneath, depth first |
len(c), iter(c), key in c |
Child count, iteration, key membership |
Pair also has key, name (block name), kind, value, set_value(value, kind=None),
comments, parent, detach() and to_string(include_leading=False). Pairs are live handles:
editing a pair returned by find edits the document, and two handles to the same node compare
equal.
Values
kind |
LookML | Python value |
|---|---|---|
literal |
type: number |
"number" |
string |
label: "Say \"hi\"" |
'Say "hi"' (unescaped) |
expr |
sql: ${TABLE}.id ;; |
"${TABLE}.id" |
list |
filters: [a: "x", b, c] |
[("a", "x"), "b", "c"] |
block |
dimension: id { ... } |
None (use the container methods) |
Assigning to value (or c[key]) keeps the current kind, except that a literal becomes a
string when the new text needs quotes. A new value's kind is inferred: sql, html, sql_*
and *_sql keys are expressions; identifier-like text such as number is a literal, except
for keys that are conventionally quoted (label, description, group_label, ...); booleans
become yes/no; lists, tuples and dicts become lists. Pass kind= to choose explicitly.
New pairs copy the spacing of their neighbours, so a dimension added to a view gets the blank line and indentation the other dimensions have, and a replaced list keeps its inline or one-per-line layout, comma style and trailing comma.
Building objects from Python
View, Explore, Join, Dimension, DimensionGroup and Measure build LookML from
Python values. Each fixes only its LookML key; fields are arbitrary keyword arguments, so any
parameter Looker supports can be written, and nested objects are passed positionally:
from looker_cst import Block, Case, Dimension, Explore, Join, Measure, Named, Quoted, View
view = View(
"users",
Dimension("id", primary_key=True, type="number", sql="${TABLE}.id"),
Dimension(
"status",
case=Case({"${TABLE}.status = 1": "Active", "${TABLE}.status = 0": "Inactive"}, else_="Unknown"),
),
Dimension(
"email",
type="string",
sql="${TABLE}.email",
tags=["pii"],
link=[Block(label="Profile", url="https://admin.example.com/{{ value }}")],
),
Measure("count", type="count", drill_fields=["id", "email"], filters={"status": "Active"}),
sql_table_name="public.users",
)
path.write_text(str(view)) # a new file
doc = parse(path.read_text())
Dimension("name", type="string").add_to(doc.find("view", "users"), index=3) # into a parsed file
Explore("users", Join("orders", from_="orders_v2", sql_on="${users.id} = ${orders.user_id}")).add_to(doc)
- Values convert as in
add:sqlstyle keys become;;expressions, booleansyes/no, lists[a, b]and dicts[key: "value"]. Block(...)is an unnamed{ }block such aslinkorderived_table; a list of them repeats the key.Case(...)writes a case'swhenblocks andelse.Quoted("usd"),Literal("Count")andExpr("${id}")force a value's form.- A trailing underscore is dropped from field names, for Python keywords:
from_,else_. Named(key, name, ...)covers any other block, e.g.Named("parameter", "metric", type="unquoted").add_tospaces the new object like its siblings; where there are none to copy, named blocks are set apart by a blank line and unnamed ones are not.
Rust usage
let doc = looker_cst::parse(&source)?;
let view = doc.body.find("view", Some("users")).unwrap();
let id = view.read().body().unwrap().find("dimension", Some("id")).unwrap();
id.write().body_mut().unwrap().find("type", None).unwrap().write().set_text("string");
assert!(doc.to_string().contains("type: string"));
The tree is in src/cst.rs: every node owns the whitespace and comments before it (leading),
and a block or list owns the trivia before its closing bracket (trailing), so printing is a
plain concatenation. Editing helpers are in src/edit.rs.
Development
cargo test # Rust unit, fixture and looker repo tests
uv run pytest # builds the extension, then runs the Python tests
cargo run --release --example roundtrip -- ../looker # round trip check of a directory
uv run scripts/view_cst.py ../looker/models/analytics.model.lkml # browse a file's tree
The looker repo tests read every .lkml file in the looker repo. For each file they check the
round trip, rewrite every value with itself, edit every value and read it back, and add then
remove pairs in every block, checking the source is restored exactly.
| Variable | Default | Meaning |
|---|---|---|
LOOKER_REPO |
target/looker |
Checkout to test; cloned there when it does not exist |
LOOKER_REPO_URL |
https://github.com/mozilla/looker-hub.git |
Where to clone from |
REQUIRE_LOOKER_REPO |
unset | Fail instead of skipping when the clone fails |
The first run shallow clones main of mozilla/looker-hub
into target/looker and later runs reuse it; delete the directory (or cargo clean) to pick up
newer commits. To test another checkout, point the tests at it:
LOOKER_REPO=../looker-hub cargo test.
Benchmarks
cargo bench # criterion: fixture, looker repo, largest file
uv run --with lkml scripts/benchmark.py # Python: looker_cst against lkml
Both use the looker repo checkout the tests clone (target/looker, or $LOOKER_REPO), and
scripts/benchmark.py also takes a path. On an Apple Silicon laptop over the 3433 files
(71 MB) of mozilla/looker-hub, through Python (median
of 3 runs):
| Benchmark | looker_cst | lkml | lkml / ours |
|---|---|---|---|
| parse | 266.6 ms | 30823.7 ms | 116x |
| 59.9 ms | 1551.5 ms | 26x | |
| round trip | 304.2 ms | 30796.7 ms | 101x |
From Rust, cargo bench parses the same repo in about 240 ms (~280 MiB/s). The lkml comparison
takes several minutes on the full hub; pass a smaller directory to scripts/benchmark.py for a
quick check.
To test a specific Python version:
UV_PROJECT_ENVIRONMENT=.venv-3.9 uv run --python 3.9 pytest
To build a wheel:
uvx maturin build --release # target/wheels/looker_cst-*-cp39-abi3-*.whl
Metadata
Release files for looker-cst 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| looker_cst-0.1.0.tar.gz | 72.0 kB | Details |
Built distributions (wheels)
| File | Reset | |||
|---|---|---|---|---|
| looker_cst-0.1.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl | CPython 3.9 | abi3 | Linux glibc 2.17+ x86-64 | Details |
| looker_cst-0.1.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl | CPython 3.9 | abi3 | Linux glibc 2.17+ ARM64 | Details |
| looker_cst-0.1.0-cp39-abi3-macosx_11_0_arm64.whl | CPython 3.9 | abi3 | macOS 11.0+ ARM64 | Details |
| looker_cst-0.1.0-cp39-abi3-macosx_10_12_x86_64.whl | CPython 3.9 | abi3 | macOS 10.12+ x86-64 | Details |
Total release size: 1.5 MB
Release files / looker_cst-0.1.0.tar.gz
| Download URL | looker_cst-0.1.0.tar.gz |
|---|---|
| Size | 72.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ff55f4247b5223cf75f56ca06f0b5587876ff68a7dc744debf1a648a625f48e2
|
|
BLAKE2b-256 checksum How to use checksums |
4d7a2e76bff1e1bc43ff69f89ac644d5384dc52c30e68de85ac114fe2c08a0d9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.7 {"installer":{"name":"uv","version":"0.11.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / looker_cst-0.1.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
| Download URL | looker_cst-0.1.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl |
|---|---|
| Size | 351.2 kB |
| Tags | CPython 3.9 Linux glibc 2.17+ x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
a6a93bda1cc5dfb341c84f5c1fcf928470e1ed3e674231b402f03f318d529cf8
|
|
BLAKE2b-256 checksum How to use checksums |
f0152401e756c12486da891e4a5f3b6a212e7f531b9e2efef2e643513e18a82c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.7 {"installer":{"name":"uv","version":"0.11.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / looker_cst-0.1.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
| Download URL | looker_cst-0.1.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl |
|---|---|
| Size | 337.9 kB |
| Tags | CPython 3.9 Linux glibc 2.17+ ARM64 abi3 |
|
SHA-256 checksum How to use checksums |
002149ac2d5ee59677bf1bd96c79f5e46c02ea811ef99dfc9eb125fb2b9ea0d4
|
|
BLAKE2b-256 checksum How to use checksums |
322c52a98631562f961c03b5e19f54bc1f0e0f8118c151636e798700ccdf7471
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.7 {"installer":{"name":"uv","version":"0.11.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / looker_cst-0.1.0-cp39-abi3-macosx_11_0_arm64.whl
| Download URL | looker_cst-0.1.0-cp39-abi3-macosx_11_0_arm64.whl |
|---|---|
| Size | 357.4 kB |
| Tags | CPython 3.9 abi3 macOS 11.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
c49e1750c448cfe110f9ebac3bbd44608b3179ee004bf52227bc65acbce4c49b
|
|
BLAKE2b-256 checksum How to use checksums |
61e18d1531c031f7e013980bdf3761bf5818dde472ed1f1825d558b7f767f066
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.7 {"installer":{"name":"uv","version":"0.11.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / looker_cst-0.1.0-cp39-abi3-macosx_10_12_x86_64.whl
| Download URL | looker_cst-0.1.0-cp39-abi3-macosx_10_12_x86_64.whl |
|---|---|
| Size | 359.2 kB |
| Tags | CPython 3.9 abi3 macOS 10.12+ x86-64 |
|
SHA-256 checksum How to use checksums |
309bb27beda8310d8a858c1e255b1b4bce3b8bd333678850cf23c6055718b1b9
|
|
BLAKE2b-256 checksum How to use checksums |
ff87299fc37022e221f4baf7210c7e42bcf19f92041727631ebe00034dcfe396
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.7 {"installer":{"name":"uv","version":"0.11.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|