Skip to content

Commit 53ed662

Browse files
authored
Merge pull request #12 from cube-js/cubeToLookerML
Cube to looker ml
2 parents 799825c + f683d0c commit 53ed662

File tree

12 files changed

+1654
-61
lines changed

12 files changed

+1654
-61
lines changed

CLAUDE.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
lkml2cube is a Python CLI tool that converts LookML models into Cube data models. It uses the `lkml` library to parse LookML files and generates YAML-based Cube definitions.
8+
9+
## Development Commands
10+
11+
### Environment Setup
12+
- This project uses PDM for dependency management
13+
- Install dependencies: `pdm install`
14+
- Run tests: `pdm run pytest` or `pytest`
15+
16+
### Testing
17+
- Tests are located in `tests/` directory
18+
- Main test file: `tests/test_e2e.py`
19+
- Test samples are in `tests/samples/` with both `lkml/` and `cubeml/` subdirectories
20+
- Tests compare generated output against expected YAML files
21+
22+
### CLI Usage
23+
The tool provides three main commands:
24+
- `lkml2cube cubes` - Converts LookML views to Cube definitions (cubes only)
25+
- `lkml2cube views` - Converts LookML explores to Cube definitions (cubes + views)
26+
- `lkml2cube explores` - Generates LookML explores from Cube meta API (correctly maps Cube cubes→LookML views, Cube views→LookML explores)
27+
28+
## Architecture
29+
30+
### Core Components
31+
32+
#### Parser Module (`lkml2cube/parser/`)
33+
- `loader.py` - File loading, writing, and summary utilities (includes LookML generation)
34+
- `views.py` - Converts LookML views to Cube definitions
35+
- `explores.py` - Handles explore parsing and join generation
36+
- `cube_api.py` - Interfaces with Cube meta API, correctly separates cubes vs views
37+
- `types.py` - Custom YAML types for proper formatting
38+
39+
#### Main Entry Point
40+
- `main.py` - Typer-based CLI with three commands: cubes, views, explores
41+
- Uses Rich for console output formatting
42+
43+
### Key Concepts
44+
- **Cubes vs Views**: The `cubes` command only generates Cube model definitions, while `views` creates both cubes and views with join relationships
45+
- **Explores**: LookML explores define join relationships equivalent to Cube's view definitions
46+
- **Include Resolution**: Uses `--rootdir` parameter to resolve LookML `include:` statements
47+
- **Cube API Mapping**:
48+
- Cube cubes (with `sql_table`/`sql`) → LookML views
49+
- Cube views (with `aliasMember` joins) → LookML explores with join definitions
50+
- **LookML Enhancement**: Generates production-ready LookML with includes, proper joins, primary keys, and drill fields
51+
52+
### File Structure
53+
- `examples/` - Contains sample output files (cubes and views)
54+
- `tests/samples/` - Test fixtures with both LookML input and expected Cube output
55+
- `lkml2cube/` - Main source code
56+
- `dist/` - Built distribution files
57+
58+
## Development Notes
59+
60+
### YAML Formatting
61+
The tool uses custom YAML representers for proper formatting:
62+
- `folded_unicode` and `literal_unicode` types for multi-line strings
63+
- Configured in `main.py` with `yaml.add_representer()`
64+
65+
### CLI Options
66+
Common options across commands:
67+
- `--parseonly` - Shows parsed LookML as Python dict
68+
- `--printonly` - Prints generated YAML to stdout
69+
- `--outputdir` - Directory for output files
70+
- `--rootdir` - Base path for resolving includes

export_pdm.sh

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#!/bin/bash
2+
# This script exports the PDM project dependencies to a requirements.txt file
3+
# Ensure PDM is installed
4+
if ! command -v pdm &> /dev/null
5+
then
6+
echo "PDM could not be found. Please install PDM first."
7+
exit 1
8+
fi
9+
10+
pdm export --without-hashes --format requirements > requirements.txt
11+
12+
pip install -r requirements.txt
13+
if [ $? -eq 0 ]; then
14+
echo "Dependencies installed successfully."
15+
else
16+
echo "Failed to install dependencies."
17+
exit 1
18+
fi

lkml2cube/main.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
import typer
44
import yaml
55

6+
from lkml2cube.parser.cube_api import meta_loader, parse_meta
67
from lkml2cube.parser.explores import parse_explores, generate_cube_joins
7-
from lkml2cube.parser.loader import file_loader, write_files, print_summary
8+
from lkml2cube.parser.loader import file_loader, write_files, write_lookml_files, print_summary
89
from lkml2cube.parser.views import parse_view
910
from lkml2cube.parser.types import (
1011
folded_unicode,
@@ -126,5 +127,52 @@ def views(
126127
print_summary(summary)
127128

128129

130+
@app.command()
131+
def explores(
132+
metaurl: Annotated[str, typer.Argument(help="The url for cube meta endpoint")],
133+
token: Annotated[str, typer.Option(help="JWT token for Cube meta")],
134+
parseonly: Annotated[
135+
bool,
136+
typer.Option(
137+
help=(
138+
"When present it will only show the python"
139+
" dict read from the lookml file"
140+
)
141+
),
142+
] = False,
143+
outputdir: Annotated[
144+
str, typer.Option(help="The path for the output files to be generated")
145+
] = ".",
146+
printonly: Annotated[
147+
bool, typer.Option(help="Print to stdout the parsed files")
148+
] = False,
149+
):
150+
"""
151+
Generate cubes-only given a LookML file that contains LookML Views.
152+
"""
153+
154+
cube_model = meta_loader(
155+
meta_url=metaurl,
156+
token=token,
157+
)
158+
159+
if cube_model is None:
160+
console.print(f"No response received from: {metaurl}", style="bold red")
161+
raise typer.Exit()
162+
163+
if parseonly:
164+
console.print(pprint.pformat(cube_model))
165+
return
166+
167+
lookml_model = parse_meta(cube_model)
168+
169+
if printonly:
170+
console.print(yaml.dump(lookml_model, allow_unicode=True))
171+
return
172+
173+
summary = write_lookml_files(lookml_model, outputdir=outputdir)
174+
print_summary(summary)
175+
176+
129177
if __name__ == "__main__":
130178
app()

lkml2cube/parser/cube_api.py

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import requests
2+
from lkml2cube.parser.types import reverse_type_map, literal_unicode, console
3+
4+
5+
def meta_loader(
6+
meta_url: str,
7+
token: str,
8+
) -> dict:
9+
"""
10+
Load the Cube meta API and return the model as a dictionary.
11+
"""
12+
13+
if not token:
14+
raise ValueError("A valid token must be provided to access the Cube meta API.")
15+
16+
# We need the extended version of the meta API to get the full model
17+
if not meta_url.endswith("?extended"):
18+
meta_url += "?extended"
19+
20+
headers = {"Authorization": f"Bearer {token}"}
21+
response = requests.get(meta_url, headers=headers)
22+
23+
if response.status_code != 200:
24+
raise Exception(f"Failed to fetch meta data: {response.text}")
25+
26+
return response.json()
27+
28+
29+
def parse_members(members: list) -> list:
30+
"""
31+
Parse measures and dimensions from the Cube meta model.
32+
"""
33+
34+
rpl_table = (
35+
lambda s: s.replace("${", "{").replace("{CUBE}", "{TABLE}").replace("{", "${")
36+
)
37+
convert_to_literal = lambda s: (
38+
literal_unicode(rpl_table(s)) if "\n" in s else rpl_table(s)
39+
)
40+
parsed_members = []
41+
42+
for member in members:
43+
if member.get("type") not in reverse_type_map:
44+
console.print(
45+
f'Dimension type: {member["type"]} not implemented yet:\n {member}',
46+
style="bold red",
47+
)
48+
continue
49+
50+
dim = {
51+
"name": member.get("name"),
52+
"label": member.get("title", member.get("name")),
53+
"description": member.get("description", ""),
54+
"type": reverse_type_map.get(member.get("aggType", member.get("type"))),
55+
}
56+
if "sql" in member:
57+
dim["sql"] = convert_to_literal(member["sql"])
58+
59+
if not member.get("public"):
60+
dim["hidden"] = "yes"
61+
62+
parsed_members.append(dim)
63+
return parsed_members
64+
65+
66+
def parse_meta(cube_model: dict) -> dict:
67+
"""
68+
Parse the Cube meta model and return a simplified version.
69+
Separates Cube cubes (-> LookML views) from Cube views (-> LookML explores).
70+
"""
71+
72+
lookml_model = {
73+
"views": [],
74+
"explores": [],
75+
}
76+
77+
for model in cube_model.get("cubes", []):
78+
# Determine if this is a cube (table-based) or view (join-based)
79+
is_view = _is_cube_view(model)
80+
81+
if is_view:
82+
# This is a Cube view -> LookML explore
83+
explore = _parse_cube_view_to_explore(model)
84+
lookml_model["explores"].append(explore)
85+
else:
86+
# This is a Cube cube -> LookML view
87+
view = _parse_cube_to_view(model)
88+
lookml_model["views"].append(view)
89+
90+
return lookml_model
91+
92+
93+
def _is_cube_view(model: dict) -> bool:
94+
"""
95+
Determine if a Cube model is a view (has joins) or a cube (has its own data source).
96+
Views typically have aliasMember references and no sql_table/sql property.
97+
"""
98+
# Check if any dimensions or measures use aliasMember (indicating joins)
99+
has_alias_members = False
100+
101+
for dimension in model.get("dimensions", []):
102+
if "aliasMember" in dimension:
103+
has_alias_members = True
104+
break
105+
106+
if not has_alias_members:
107+
for measure in model.get("measures", []):
108+
if "aliasMember" in measure:
109+
has_alias_members = True
110+
break
111+
112+
# If it has alias members and no own data source, it's a view
113+
has_own_data_source = "sql_table" in model or "sql" in model
114+
115+
return has_alias_members and not has_own_data_source
116+
117+
118+
def _parse_cube_to_view(model: dict) -> dict:
119+
"""
120+
Parse a Cube cube into a LookML view.
121+
"""
122+
view = {
123+
"name": model.get("name"),
124+
"label": model.get("title", model.get("description", model.get("name"))),
125+
"extends": [],
126+
"dimensions": [],
127+
"measures": [],
128+
"filters": [],
129+
}
130+
131+
if "extends" in model:
132+
view["extends"] = [model["extends"]]
133+
134+
if "sql_table" in model:
135+
view["sql_table_name"] = model["sql_table"]
136+
137+
if "sql" in model:
138+
view["derived_table"] = {"sql": model["sql"]}
139+
140+
if "dimensions" in model:
141+
view["dimensions"] = parse_members(model["dimensions"])
142+
if "measures" in model:
143+
view["measures"] = parse_members(model["measures"])
144+
145+
return view
146+
147+
148+
def _parse_cube_view_to_explore(model: dict) -> dict:
149+
"""
150+
Parse a Cube view into a LookML explore with joins.
151+
"""
152+
explore = {
153+
"name": model.get("name"),
154+
"label": model.get("title", model.get("description", model.get("name"))),
155+
"joins": []
156+
}
157+
158+
# Extract join information from aliasMember references
159+
joined_cubes = set()
160+
primary_cube = None
161+
162+
# Find all referenced cubes from dimensions and measures
163+
for dimension in model.get("dimensions", []):
164+
if "aliasMember" in dimension:
165+
cube_name = dimension["aliasMember"].split(".")[0]
166+
joined_cubes.add(cube_name)
167+
168+
for measure in model.get("measures", []):
169+
if "aliasMember" in measure:
170+
cube_name = measure["aliasMember"].split(".")[0]
171+
joined_cubes.add(cube_name)
172+
173+
# Try to determine the primary cube (base of the explore)
174+
# Usually the most referenced cube or the first one
175+
if joined_cubes:
176+
# For now, use the first cube alphabetically as primary
177+
# In a real implementation, you might have more logic here
178+
primary_cube = min(joined_cubes)
179+
joined_cubes.remove(primary_cube)
180+
181+
explore["view_name"] = primary_cube
182+
183+
# Create joins for the remaining cubes
184+
for cube_name in sorted(joined_cubes):
185+
join = {
186+
"name": cube_name,
187+
"view_label": cube_name.replace("_", " ").title(),
188+
"type": "left_outer", # Default join type
189+
"relationship": "many_to_one", # Default relationship
190+
# In a real implementation, you'd extract actual join conditions
191+
# from the Cube model's join definitions
192+
"sql_on": f"${{{primary_cube}.id}} = ${{{cube_name}.id}}"
193+
}
194+
explore["joins"].append(join)
195+
196+
return explore

0 commit comments

Comments
 (0)