CMeta is a compact metadata format for describing data lakes, tables, columns, and how tables join, in a way that is:
- πͺΆ Token-efficient: compresses schema/metadata for LLMs with narrow context windows
- π§βπ» Human-readable: easy to read and edit manually
- π Reversible: can be converted to and from JSON (compact or verbose)
This package provides Python utilities to:
- Parse CMeta text β JSON
- Convert to/from a compact JSON structure
- Convert to/from a flat, extended JSON structure (very verbose for some specific Nexus use cases)
- Normalize the type names a data source reports (
utf8,int64, ...) to SQL ones
uv add nuklai-cmetaUses uv. You can also install with
pip install nuklai-cmetaif you prefer.
-
Hierarchy:
- Lake β Tables β Columns
:denotes containers (lakes and tables)*denotes columns~denotes joins (see Joins)
-
Descriptions: in
[ ... ] -
Types: in
< ... >using SQL types (string,int,boolean,date,timestamp, etc.). Nested types keep their own angle brackets:<list<item: string>> -
Nested fields: use dot notation (
car.engine.horsepower)
Example:
Webshop[Contains all webshop data]:
users[Contains all registered users]:
* user_id<int>[Unique ID of a user]
* name<string>[Full name of a user]
* email<string>[Email address]
orders[Customer orders]:
* id<int>[Order id]
* total<double>[Total amount]
* created_at<timestamp>[When created]
* user_id<int>[The user who placed the order]
~ orders.user_id -> users.user_id<many:1>
Inside a description ([ ... ]):
| Write | For |
|---|---|
\], \<, \> |
the literal ], <, > |
\\ |
a literal backslash |
\n, \r |
a line break (CMeta is line based, a description always stays on one line) |
A backslash followed by anything else is kept as written, so \d+ reads as \d+.
Lake and table names are written as they are, spaces included (Demo Shop, Order Details). Column names too, as long as they hold no whitespace (user_id, car.engine.hp).
A name that would otherwise read back differently is written in double quotes, like a SQL identifier:
"Demo Shop"[..]: <- only if it had e.g. a ':' or '[' in it, otherwise Demo Shop
"sales[2024]"[..]:
* "Order Date"<timestamp>[When it was placed]
* "say \"hi\""<string>
Inside quotes, \", \\, \n and \r are escapes. A lake or table needs quotes when its name contains [, ], :, " or \, starts with * or ~, is empty, or has whitespace other than single spaces or at either end. A column needs them when its name contains whitespace, <, >, [, ], " or \, or is empty.
A join says a column of one table holds values of a column of another table in the same lake. It is a line inside the lake, indented like a table, starting with ~:
~ orders.user_id -> users.user_id<many:1>[unconfirmed: 60% of values match]
from.column -> to.column:fromis conventionally the referencing (many) side,tothe referenced (one) side<many:1>(optional): the cardinality, one token such asmany:1,1:1ormany:many[ ... ](optional): a description, escaped like any other- Names follow the quoting rules above, except that a
.also needs quotes here, because it separates table from column:"Support Tickets".customer_id -> Customers.customer_id,Cars."engine.hp" -> ...
Joins can be anywhere directly under their lake, and they are written after its tables.
v1.1 only adds to v1. Text that is valid v1 reads exactly the same, and a model that only uses what v1 can express is written byte for byte as before. New in v1.1: the \\, \n and \r escapes, quoted names, nested types, and joins. One thing to know: a v1 name that started with a " used to be read literally, and is now read as a quoted name.
from cmeta import parse_cmeta
text = """
Webshop[Contains all webshop data]:
users[Contains all registered users]:
* user_id<int>[Unique ID]
* name<string>[Full name]
"""
model = parse_cmeta(text)
print(model.lakes[0].tables[0].columns[0].name)
# "user_id"Text that isn't valid CMeta raises CMetaParseError, with the line and what is wrong (and never anything else, whatever it is given).
from cmeta import to_cmeta
cmeta_str = to_cmeta(model)
print(cmeta_str)Whatever a model holds comes back the same when its text is parsed, with these exceptions: an empty description is left out and reads back as None; a type is written on one line with single spaces; and no type at all is written as unknown, not silently as string. A model holding something CMeta cannot express (a type with unbalanced </>, a join cardinality that isn't a single token) raises CMetaFormatError.
from cmeta import Join, Lake, Model, Table, to_cmeta
lake = Lake("Webshop", tables=[Table("users"), Table("orders")])
lake.joins.append(Join("orders", "user_id", "users", "user_id", cardinality="many:1"))
print(to_cmeta(Model([lake])))from cmeta import model_to_compact_json, compact_json_to_model
cj = model_to_compact_json(model)
print(cj[0]["tables"][0]["columns"][0])
# {'name': 'user_id', 'type': 'int', 'description': 'Unique ID'}
m2 = compact_json_to_model(cj)
assert to_cmeta(m2) == to_cmeta(model)Compact JSON format:
[
{
"name": "Webshop",
"description": "Contains all webshop data",
"tables": [
{
"name": "users",
"description": "Contains all registered users",
"columns": [
{"name": "user_id", "type": "int", "description": "Unique ID"}
]
}
],
"joins": [
{
"fromTable": "orders", "fromColumn": "user_id",
"toTable": "users", "toColumn": "user_id",
"cardinality": "many:1", "description": null
}
]
}
]joins is only there for lakes that have some.
from cmeta import model_to_extended_json, extended_json_to_model, model_to_join_rows
ej = model_to_extended_json(model)
print(ej[0])
# {
# 'columnName': 'user_id',
# 'columnDescription': 'Unique ID',
# 'dataType': 'int',
# 'sourceDescription': 'Contains all webshop data',
# 'sourceName': 'Webshop',
# 'tableDescription': 'Contains all registered users',
# 'tableName': 'users'
# }
m3 = extended_json_to_model(ej)
# Joins don't fit one row per column, so they get rows of their own
joins = model_to_join_rows(model) # [{'sourceName': 'Webshop', 'fromTable': 'orders', ...}]
m4 = extended_json_to_model(ej, joins)Extended JSON format:
[
{
"columnName": "user_id",
"columnDescription": "Unique ID",
"dataType": "int",
"sourceDescription": "Contains all webshop data",
"sourceName": "Webshop",
"tableDescription": "Contains all registered users",
"tableName": "users"
}
]Data sources report types in their own words. normalize_type turns them into the SQL names CMeta documents:
from cmeta import normalize_type
normalize_type("utf8") # "string"
normalize_type("int64") # "bigint"
normalize_type("timestamp[ns, tz=UTC]") # "timestamp"
normalize_type("decimal128(10, 2)") # "decimal(10, 2)"
normalize_type("list<item: string>") # "array"
normalize_type(None) # "unknown"CMeta supports common datatypes:
stringint,bigintfloat,double,decimalbooleandate,timestampjson,array,map
The type is free text, so any other name works too.
Clone and set up:
uv venv && source .venv/bin/activate
make installRun checks:
make ci # lint + typecheck + test
make lint # ruff
make format # autoformat
make test # pytestThe tests include seeded random ones: random models, full of the characters that tend to break line-based formats, must survive being written and read back, and random garbage must only ever raise CMetaParseError.
Dev workflow for trusted contributors:
-
Bump the version in
pyproject.toml(and add it toCHANGELOG.md). -
Commit & push to
main. -
Tag the release:
git tag v0.2.0 && git push origin v0.2.0 -
The GitHub Actions workflow will:
- run tests
- build wheels/sdist
- publish to PyPI
MIT β free for personal and commercial use.