Skip to main content

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: sql style keys become ;; expressions, booleans yes/no, lists [a, b] and dicts [key: "value"].
  • Block(...) is an unnamed { } block such as link or derived_table; a list of them repeats the key. Case(...) writes a case's when blocks and else.
  • Quoted("usd"), Literal("Count") and Expr("${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_to spaces 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
print 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)

Source distribution for looker-cst 0.1.0
File Size Uploaded
looker_cst-0.1.0.tar.gz 72.0 kB Details

Built distributions (wheels)

Table of built distributions (wheels) for looker-cst 0.1.0
File
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}

Release history Release notifications | RSS feed

This release

0.1.0 This release

5 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