From 576ebda467d09c8f69a222b76e6dc214bb7f1b1a Mon Sep 17 00:00:00 2001 From: Paco Valdez Date: Mon, 2 Jun 2025 15:21:18 -0700 Subject: [PATCH 1/4] Add explores command --- lkml2cube/main.py | 49 +++++++++++++++++ lkml2cube/parser/cube_api.py | 103 +++++++++++++++++++++++++++++++++++ lkml2cube/parser/types.py | 12 ++++ 3 files changed, 164 insertions(+) create mode 100644 lkml2cube/parser/cube_api.py diff --git a/lkml2cube/main.py b/lkml2cube/main.py index 6ce28e0..f4c223a 100644 --- a/lkml2cube/main.py +++ b/lkml2cube/main.py @@ -3,6 +3,7 @@ import typer import yaml +from lkml2cube.parser.cube_api import meta_loader, parse_meta from lkml2cube.parser.explores import parse_explores, generate_cube_joins from lkml2cube.parser.loader import file_loader, write_files, print_summary from lkml2cube.parser.views import parse_view @@ -126,5 +127,53 @@ def views( print_summary(summary) +@app.command() +def explores( + metaurl: Annotated[str, typer.Argument(help="The url for cube meta endpoint")], + token: Annotated[str, typer.Option(help="JWT token for Cube meta")], + parseonly: Annotated[ + bool, + typer.Option( + help=( + "When present it will only show the python" + " dict read from the lookml file" + ) + ), + ] = False, + outputdir: Annotated[ + str, typer.Option(help="The path for the output files to be generated") + ] = ".", + printonly: Annotated[ + bool, typer.Option(help="Print to stdout the parsed files") + ] = False, +): + """ + Generate cubes-only given a LookML file that contains LookML Views. + """ + + cube_model = meta_loader( + meta_url=metaurl, + token=token, + ) + + if cube_model is None: + console.print(f"No response received from: {metaurl}", style="bold red") + raise typer.Exit() + + if parseonly: + console.print(pprint.pformat(cube_model)) + return + + lookml_model = parse_meta(cube_model) + # cube_def = parse_explores(lookml_model, use_explores_name) + + if printonly: + console.print(yaml.dump(lookml_model, allow_unicode=True)) + return + + # summary = write_files(cube_def, outputdir=outputdir) + # print_summary(summary) + + if __name__ == "__main__": app() diff --git a/lkml2cube/parser/cube_api.py b/lkml2cube/parser/cube_api.py new file mode 100644 index 0000000..dc08d63 --- /dev/null +++ b/lkml2cube/parser/cube_api.py @@ -0,0 +1,103 @@ +import requests +from lkml2cube.parser.types import reverse_type_map, literal_unicode, console + + +def meta_loader( + meta_url: str, + token: str, +) -> dict: + """ + Load the Cube meta API and return the model as a dictionary. + """ + + if not token: + raise ValueError("A valid token must be provided to access the Cube meta API.") + + # We need the extended version of the meta API to get the full model + if not meta_url.endswith("?extended"): + meta_url += "?extended" + + headers = {"Authorization": f"Bearer {token}"} + response = requests.get(meta_url, headers=headers) + + if response.status_code != 200: + raise Exception(f"Failed to fetch meta data: {response.text}") + + return response.json() + + +def parse_members(members: list) -> list: + """ + Parse measures and dimensions from the Cube meta model. + """ + + rpl_table = ( + lambda s: s.replace("${", "{").replace("{CUBE}", "{TABLE}").replace("{", "${") + ) + convert_to_literal = lambda s: ( + literal_unicode(rpl_table(s)) if "\n" in s else rpl_table(s) + ) + parsed_members = [] + + for member in members: + if member.get("type") not in reverse_type_map: + console.print( + f'Dimension type: {member["type"]} not implemented yet:\n {member}', + style="bold red", + ) + continue + + dim = { + "name": member.get("name"), + "label": member.get("title", member.get("name")), + "description": member.get("description", ""), + "type": reverse_type_map.get(member.get("aggType", member.get("type"))), + } + if "sql" in member: + dim["sql"] = convert_to_literal(member["sql"]) + + if not member.get("public"): + dim["hidden"] = "yes" + + parsed_members.append(dim) + return parsed_members + + +def parse_meta(cube_model: dict) -> dict: + """ + Parse the Cube meta model and return a simplified version. + """ + + lookml_model = { + "views": [], + "explores": [], + } + + for model in cube_model.get("cubes", []): + + view = { + "name": model.get("name"), + "label": model.get("title", model.get("description", model.get("name"))), + "extends": [], + "dimensions": [], + "measures": [], + "filters": [], + } + + if "extends" in model: + view["extends"] = [model["extends"]] + + if "sql_table" in model: + view["sql_table_name"] = model["sql_table"] + + if "sql" in model: + view["derived_table"] = {"sql": model["sql"]} + + if "dimensions" in model: + view["dimensions"] = parse_members(model["dimensions"]) + if "measures" in model: + view["measures"] = parse_members(model["measures"]) + + lookml_model["views"].append(view) + + return lookml_model diff --git a/lkml2cube/parser/types.py b/lkml2cube/parser/types.py index 679f757..913f95b 100644 --- a/lkml2cube/parser/types.py +++ b/lkml2cube/parser/types.py @@ -25,6 +25,18 @@ def print(self, s, *args): "count_distinct": "count_distinct_approx", } +reverse_type_map = { + "string": "string", + "number": "number", + "count": "count", + "boolean": "yesno", + "sum": "sum", + "avg": "average", + "time": "time", + "count_distinct": "count_distinct", + "count_distinct_approx": "count_distinct", +} + class folded_unicode(str): pass From e2244612a47d7bb057502cbb59df9aad460dd257 Mon Sep 17 00:00:00 2001 From: Paco Valdez Date: Mon, 14 Jul 2025 15:25:53 -0700 Subject: [PATCH 2/4] Fix explores command --- CLAUDE.md | 69 ++++++ export_pdm.sh | 18 ++ lkml2cube/main.py | 7 +- lkml2cube/parser/cube_api.py | 131 ++++++++-- lkml2cube/parser/loader.py | 233 ++++++++++++++++- pdm.lock | 135 ++++++---- pdm.toml | 2 + requirements.txt | 4 + tests/test_explores_command.py | 387 +++++++++++++++++++++++++++++ tests/test_lookml_writer.py | 440 +++++++++++++++++++++++++++++++++ 10 files changed, 1343 insertions(+), 83 deletions(-) create mode 100644 CLAUDE.md create mode 100755 export_pdm.sh create mode 100644 pdm.toml create mode 100644 requirements.txt create mode 100644 tests/test_explores_command.py create mode 100644 tests/test_lookml_writer.py diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2e98840 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,69 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +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. + +## Development Commands + +### Environment Setup +- This project uses PDM for dependency management +- Install dependencies: `pdm install` +- Run tests: `pdm run pytest` or `pytest` + +### Testing +- Tests are located in `tests/` directory +- Main test file: `tests/test_e2e.py` +- Test samples are in `tests/samples/` with both `lkml/` and `cubeml/` subdirectories +- Tests compare generated output against expected YAML files + +### CLI Usage +The tool provides three main commands: +- `lkml2cube cubes` - Converts LookML views to Cube definitions (cubes only) +- `lkml2cube views` - Converts LookML explores to Cube definitions (cubes + views) +- `lkml2cube explores` - Generates LookML explores from Cube meta API (correctly maps Cube cubes→LookML views, Cube views→LookML explores) + +## Architecture + +### Core Components + +#### Parser Module (`lkml2cube/parser/`) +- `loader.py` - File loading, writing, and summary utilities (includes LookML generation) +- `views.py` - Converts LookML views to Cube definitions +- `explores.py` - Handles explore parsing and join generation +- `cube_api.py` - Interfaces with Cube meta API, correctly separates cubes vs views +- `types.py` - Custom YAML types for proper formatting + +#### Main Entry Point +- `main.py` - Typer-based CLI with three commands: cubes, views, explores +- Uses Rich for console output formatting + +### Key Concepts +- **Cubes vs Views**: The `cubes` command only generates Cube model definitions, while `views` creates both cubes and views with join relationships +- **Explores**: LookML explores define join relationships equivalent to Cube's view definitions +- **Include Resolution**: Uses `--rootdir` parameter to resolve LookML `include:` statements +- **Cube API Mapping**: + - Cube cubes (with `sql_table`/`sql`) → LookML views + - Cube views (with `aliasMember` joins) → LookML explores with join definitions + +### File Structure +- `examples/` - Contains sample output files (cubes and views) +- `tests/samples/` - Test fixtures with both LookML input and expected Cube output +- `lkml2cube/` - Main source code +- `dist/` - Built distribution files + +## Development Notes + +### YAML Formatting +The tool uses custom YAML representers for proper formatting: +- `folded_unicode` and `literal_unicode` types for multi-line strings +- Configured in `main.py` with `yaml.add_representer()` + +### CLI Options +Common options across commands: +- `--parseonly` - Shows parsed LookML as Python dict +- `--printonly` - Prints generated YAML to stdout +- `--outputdir` - Directory for output files +- `--rootdir` - Base path for resolving includes \ No newline at end of file diff --git a/export_pdm.sh b/export_pdm.sh new file mode 100755 index 0000000..89777a5 --- /dev/null +++ b/export_pdm.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# This script exports the PDM project dependencies to a requirements.txt file +# Ensure PDM is installed +if ! command -v pdm &> /dev/null +then + echo "PDM could not be found. Please install PDM first." + exit 1 +fi + +pdm export --without-hashes --format requirements > requirements.txt + +pip install -r requirements.txt +if [ $? -eq 0 ]; then + echo "Dependencies installed successfully." +else + echo "Failed to install dependencies." + exit 1 +fi \ No newline at end of file diff --git a/lkml2cube/main.py b/lkml2cube/main.py index f4c223a..daa60b2 100644 --- a/lkml2cube/main.py +++ b/lkml2cube/main.py @@ -5,7 +5,7 @@ from lkml2cube.parser.cube_api import meta_loader, parse_meta from lkml2cube.parser.explores import parse_explores, generate_cube_joins -from lkml2cube.parser.loader import file_loader, write_files, print_summary +from lkml2cube.parser.loader import file_loader, write_files, write_lookml_files, print_summary from lkml2cube.parser.views import parse_view from lkml2cube.parser.types import ( folded_unicode, @@ -165,14 +165,13 @@ def explores( return lookml_model = parse_meta(cube_model) - # cube_def = parse_explores(lookml_model, use_explores_name) if printonly: console.print(yaml.dump(lookml_model, allow_unicode=True)) return - # summary = write_files(cube_def, outputdir=outputdir) - # print_summary(summary) + summary = write_lookml_files(lookml_model, outputdir=outputdir) + print_summary(summary) if __name__ == "__main__": diff --git a/lkml2cube/parser/cube_api.py b/lkml2cube/parser/cube_api.py index dc08d63..277d428 100644 --- a/lkml2cube/parser/cube_api.py +++ b/lkml2cube/parser/cube_api.py @@ -66,6 +66,7 @@ def parse_members(members: list) -> list: def parse_meta(cube_model: dict) -> dict: """ Parse the Cube meta model and return a simplified version. + Separates Cube cubes (-> LookML views) from Cube views (-> LookML explores). """ lookml_model = { @@ -74,30 +75,120 @@ def parse_meta(cube_model: dict) -> dict: } for model in cube_model.get("cubes", []): + # Determine if this is a cube (table-based) or view (join-based) + is_view = _is_cube_view(model) + + if is_view: + # This is a Cube view -> LookML explore + explore = _parse_cube_view_to_explore(model) + lookml_model["explores"].append(explore) + else: + # This is a Cube cube -> LookML view + view = _parse_cube_to_view(model) + lookml_model["views"].append(view) - view = { - "name": model.get("name"), - "label": model.get("title", model.get("description", model.get("name"))), - "extends": [], - "dimensions": [], - "measures": [], - "filters": [], - } + return lookml_model + + +def _is_cube_view(model: dict) -> bool: + """ + Determine if a Cube model is a view (has joins) or a cube (has its own data source). + Views typically have aliasMember references and no sql_table/sql property. + """ + # Check if any dimensions or measures use aliasMember (indicating joins) + has_alias_members = False + + for dimension in model.get("dimensions", []): + if "aliasMember" in dimension: + has_alias_members = True + break + + if not has_alias_members: + for measure in model.get("measures", []): + if "aliasMember" in measure: + has_alias_members = True + break + + # If it has alias members and no own data source, it's a view + has_own_data_source = "sql_table" in model or "sql" in model + + return has_alias_members and not has_own_data_source + + +def _parse_cube_to_view(model: dict) -> dict: + """ + Parse a Cube cube into a LookML view. + """ + view = { + "name": model.get("name"), + "label": model.get("title", model.get("description", model.get("name"))), + "extends": [], + "dimensions": [], + "measures": [], + "filters": [], + } - if "extends" in model: - view["extends"] = [model["extends"]] + if "extends" in model: + view["extends"] = [model["extends"]] - if "sql_table" in model: - view["sql_table_name"] = model["sql_table"] + if "sql_table" in model: + view["sql_table_name"] = model["sql_table"] - if "sql" in model: - view["derived_table"] = {"sql": model["sql"]} + if "sql" in model: + view["derived_table"] = {"sql": model["sql"]} - if "dimensions" in model: - view["dimensions"] = parse_members(model["dimensions"]) - if "measures" in model: - view["measures"] = parse_members(model["measures"]) + if "dimensions" in model: + view["dimensions"] = parse_members(model["dimensions"]) + if "measures" in model: + view["measures"] = parse_members(model["measures"]) - lookml_model["views"].append(view) + return view - return lookml_model + +def _parse_cube_view_to_explore(model: dict) -> dict: + """ + Parse a Cube view into a LookML explore with joins. + """ + explore = { + "name": model.get("name"), + "label": model.get("title", model.get("description", model.get("name"))), + "joins": [] + } + + # Extract join information from aliasMember references + joined_cubes = set() + primary_cube = None + + # Find all referenced cubes from dimensions and measures + for dimension in model.get("dimensions", []): + if "aliasMember" in dimension: + cube_name = dimension["aliasMember"].split(".")[0] + joined_cubes.add(cube_name) + + for measure in model.get("measures", []): + if "aliasMember" in measure: + cube_name = measure["aliasMember"].split(".")[0] + joined_cubes.add(cube_name) + + # Try to determine the primary cube (base of the explore) + # Usually the most referenced cube or the first one + if joined_cubes: + # For now, use the first cube alphabetically as primary + # In a real implementation, you might have more logic here + primary_cube = min(joined_cubes) + joined_cubes.remove(primary_cube) + + explore["view_name"] = primary_cube + + # Create joins for the remaining cubes + for cube_name in sorted(joined_cubes): + join = { + "name": cube_name, + "type": "left_outer", # Default join type + # In a real implementation, you'd extract actual join conditions + # from the Cube model's join definitions + "sql_on": f"${{{primary_cube}.id}} = ${{{cube_name}.id}}" + } + explore["joins"].append(join) + + return explore diff --git a/lkml2cube/parser/loader.py b/lkml2cube/parser/loader.py index 43ea0e6..d054d18 100644 --- a/lkml2cube/parser/loader.py +++ b/lkml2cube/parser/loader.py @@ -1,6 +1,8 @@ import glob import lkml import rich +import rich.table +import rich.console import yaml from os.path import abspath, dirname, join @@ -125,12 +127,227 @@ def write_files(cube_def, outputdir): return summary +def write_lookml_files(lookml_model, outputdir): + """ + Write LookML model to files in the output directory. + """ + summary = {"views": [], "explores": []} + + if not lookml_model: + raise Exception("No LookML model available") + + for lookml_root_element in ("views", "explores"): + if lookml_root_element in lookml_model and lookml_model[lookml_root_element]: + + Path(join(outputdir, lookml_root_element)).mkdir(parents=True, exist_ok=True) + + for element in lookml_model[lookml_root_element]: + element_name = element.get("name") + if not element_name: + continue + + file_name = f"{element_name}.{lookml_root_element[:-1]}.lkml" + file_path = join(outputdir, lookml_root_element, file_name) + + # Generate LookML content + lookml_content = _generate_lookml_content(element, lookml_root_element[:-1]) + + with open(file_path, "w") as f: + f.write(lookml_content) + + summary[lookml_root_element].append({ + "name": element_name, + "path": str(Path(file_path)) + }) + + return summary + + +def _generate_lookml_content(element, element_type): + """ + Generate LookML content for a view or explore element. + """ + lines = [] + name = element.get("name", "unnamed") + + lines.append(f"{element_type} {name} {{") + + if element_type == "view": + # Handle view-specific properties + if "label" in element: + lines.append(f' label: "{element["label"]}"') + + if "sql_table_name" in element: + lines.append(f' sql_table_name: {element["sql_table_name"]} ;;') + + if "derived_table" in element and "sql" in element["derived_table"]: + lines.append(" derived_table: {") + sql_content = element["derived_table"]["sql"] + if isinstance(sql_content, str) and "\n" in sql_content: + lines.append(" sql:") + for sql_line in sql_content.split("\n"): + lines.append(f" {sql_line}") + lines.append(" ;;") + else: + lines.append(f" sql: {sql_content} ;;") + lines.append(" }") + + if "extends" in element and element["extends"]: + for extend in element["extends"]: + lines.append(f" extends: [{extend}]") + + # Handle dimensions + if "dimensions" in element: + for dim in element["dimensions"]: + lines.extend(_generate_dimension_lines(dim)) + + # Handle measures + if "measures" in element: + for measure in element["measures"]: + lines.extend(_generate_measure_lines(measure)) + + # Handle filters + if "filters" in element: + for filter_def in element["filters"]: + lines.extend(_generate_filter_lines(filter_def)) + + elif element_type == "explore": + # Handle explore-specific properties + if "label" in element: + lines.append(f' label: "{element["label"]}"') + + # Add view_name if specified + if "view_name" in element: + lines.append(f" view_name: {element['view_name']}") + + # Add joins + if "joins" in element and element["joins"]: + for join in element["joins"]: + lines.extend(_generate_join_lines(join)) + + lines.append("}") + return "\n".join(lines) + + +def _generate_dimension_lines(dimension): + """ + Generate LookML lines for a dimension. + """ + lines = [] + name = dimension.get("name", "unnamed") + lines.append(f" dimension: {name} {{") + + if "label" in dimension: + lines.append(f' label: "{dimension["label"]}"') + + if "description" in dimension and dimension["description"]: + lines.append(f' description: "{dimension["description"]}"') + + if "type" in dimension: + lines.append(f' type: {dimension["type"]}') + + if "sql" in dimension: + sql_content = dimension["sql"] + if isinstance(sql_content, str) and "\n" in sql_content: + lines.append(" sql:") + for sql_line in sql_content.split("\n"): + lines.append(f" {sql_line}") + lines.append(" ;;") + else: + lines.append(f" sql: {sql_content} ;;") + + if "hidden" in dimension and dimension["hidden"] == "yes": + lines.append(" hidden: yes") + + lines.append(" }") + return lines + + +def _generate_measure_lines(measure): + """ + Generate LookML lines for a measure. + """ + lines = [] + name = measure.get("name", "unnamed") + lines.append(f" measure: {name} {{") + + if "label" in measure: + lines.append(f' label: "{measure["label"]}"') + + if "description" in measure and measure["description"]: + lines.append(f' description: "{measure["description"]}"') + + if "type" in measure: + lines.append(f' type: {measure["type"]}') + + if "sql" in measure: + sql_content = measure["sql"] + if isinstance(sql_content, str) and "\n" in sql_content: + lines.append(" sql:") + for sql_line in sql_content.split("\n"): + lines.append(f" {sql_line}") + lines.append(" ;;") + else: + lines.append(f" sql: {sql_content} ;;") + + if "hidden" in measure and measure["hidden"] == "yes": + lines.append(" hidden: yes") + + lines.append(" }") + return lines + + +def _generate_filter_lines(filter_def): + """ + Generate LookML lines for a filter. + """ + lines = [] + name = filter_def.get("name", "unnamed") + lines.append(f" filter: {name} {{") + + if "label" in filter_def: + lines.append(f' label: "{filter_def["label"]}"') + + if "description" in filter_def and filter_def["description"]: + lines.append(f' description: "{filter_def["description"]}"') + + if "type" in filter_def: + lines.append(f' type: {filter_def["type"]}') + + lines.append(" }") + return lines + + +def _generate_join_lines(join): + """ + Generate LookML lines for a join. + """ + lines = [] + name = join.get("name", "unnamed") + lines.append(f" join: {name} {{") + + if "type" in join: + lines.append(f" type: {join['type']}") + + if "sql_on" in join: + lines.append(f" sql_on: {join['sql_on']} ;;") + + if "relationship" in join: + lines.append(f" relationship: {join['relationship']}") + + lines.append(" }") + return lines + + def print_summary(summary): - for cube_root_element in ("cubes", "views"): - table = rich.table.Table(title=f"Generated {cube_root_element}") - table.add_column("Element Name", justify="right", style="cyan", no_wrap=True) - table.add_column("Path", style="magenta") - for row in summary[cube_root_element]: - table.add_row(row["name"], row["path"]) - if len(summary[cube_root_element]) > 0: - console.print(table) + # Use the proper Rich console for table rendering + rich_console = rich.console.Console() + + for cube_root_element in ("cubes", "views", "explores"): + if cube_root_element in summary and summary[cube_root_element]: + table = rich.table.Table(title=f"Generated {cube_root_element}") + table.add_column("Element Name", justify="right", style="cyan", no_wrap=True) + table.add_column("Path", style="magenta") + for row in summary[cube_root_element]: + table.add_row(row["name"], row["path"]) + rich_console.print(table) diff --git a/pdm.lock b/pdm.lock index d864187..495a091 100644 --- a/pdm.lock +++ b/pdm.lock @@ -12,16 +12,15 @@ requires_python = ">=3.10" [[package]] name = "click" -version = "8.1.6" -requires_python = ">=3.7" +version = "8.2.1" +requires_python = ">=3.10" summary = "Composable command line interface toolkit" dependencies = [ "colorama; platform_system == \"Windows\"", - "importlib-metadata; python_version < \"3.8\"", ] files = [ - {file = "click-8.1.6-py3-none-any.whl", hash = "sha256:fa244bb30b3b5ee2cae3da8f55c9e5e0c0e86093306301fb418eb9dc40fbded5"}, - {file = "click-8.1.6.tar.gz", hash = "sha256:48ee849951919527a045bfe3bf7baa8a959c423134e1a5b98c05c20ba75a1cbd"}, + {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"}, + {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"}, ] [[package]] @@ -36,22 +35,25 @@ files = [ [[package]] name = "exceptiongroup" -version = "1.1.2" +version = "1.3.0" requires_python = ">=3.7" summary = "Backport of PEP 654 (exception groups)" +dependencies = [ + "typing-extensions>=4.6.0; python_version < \"3.13\"", +] files = [ - {file = "exceptiongroup-1.1.2-py3-none-any.whl", hash = "sha256:e346e69d186172ca7cf029c8c1d16235aa0e04035e5750b4b95039e65204328f"}, - {file = "exceptiongroup-1.1.2.tar.gz", hash = "sha256:12c3e887d6485d16943a309616de20ae5582633e0a2eda17f4e10fd61c1e8af5"}, + {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, + {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, ] [[package]] name = "iniconfig" -version = "2.0.0" -requires_python = ">=3.7" +version = "2.1.0" +requires_python = ">=3.8" summary = "brain-dead simple config-ini parsing" files = [ - {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, - {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] [[package]] @@ -88,50 +90,51 @@ files = [ [[package]] name = "packaging" -version = "23.1" -requires_python = ">=3.7" +version = "25.0" +requires_python = ">=3.8" summary = "Core utilities for Python packages" files = [ - {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, - {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, ] [[package]] name = "pluggy" -version = "1.5.0" -requires_python = ">=3.8" +version = "1.6.0" +requires_python = ">=3.9" summary = "plugin and hook calling mechanisms for python" files = [ - {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, - {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, ] [[package]] name = "pygments" -version = "2.16.1" -requires_python = ">=3.7" +version = "2.19.2" +requires_python = ">=3.8" summary = "Pygments is a syntax highlighting package written in Python." files = [ - {file = "Pygments-2.16.1-py3-none-any.whl", hash = "sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692"}, - {file = "Pygments-2.16.1.tar.gz", hash = "sha256:1daff0494820c69bc8941e407aa20f577374ee88364ee10a98fdbe0aece96e29"}, + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, ] [[package]] name = "pytest" -version = "8.3.5" -requires_python = ">=3.8" +version = "8.4.1" +requires_python = ">=3.9" summary = "pytest: simple powerful testing with Python" dependencies = [ - "colorama; sys_platform == \"win32\"", - "exceptiongroup>=1.0.0rc8; python_version < \"3.11\"", - "iniconfig", - "packaging", + "colorama>=0.4; sys_platform == \"win32\"", + "exceptiongroup>=1; python_version < \"3.11\"", + "iniconfig>=1", + "packaging>=20", "pluggy<2,>=1.5", + "pygments>=2.7.2", "tomli>=1; python_version < \"3.11\"", ] files = [ - {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"}, - {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"}, + {file = "pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7"}, + {file = "pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c"}, ] [[package]] @@ -196,27 +199,57 @@ files = [ [[package]] name = "shellingham" -version = "1.5.0.post1" +version = "1.5.4" requires_python = ">=3.7" summary = "Tool to Detect Surrounding Shell" files = [ - {file = "shellingham-1.5.0.post1-py2.py3-none-any.whl", hash = "sha256:368bf8c00754fd4f55afb7bbb86e272df77e4dc76ac29dbcbb81a59e9fc15744"}, - {file = "shellingham-1.5.0.post1.tar.gz", hash = "sha256:823bc5fb5c34d60f285b624e7264f4dda254bc803a3774a147bf99c0e3004a28"}, + {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, + {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, ] [[package]] name = "tomli" -version = "2.0.1" -requires_python = ">=3.7" +version = "2.2.1" +requires_python = ">=3.8" summary = "A lil' TOML parser" files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, + {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, + {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, + {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, + {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, + {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, + {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, + {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, + {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, ] [[package]] name = "typer" -version = "0.15.2" +version = "0.16.0" requires_python = ">=3.7" summary = "Typer, build great CLIs. Easy to code. Based on Python type hints." dependencies = [ @@ -226,30 +259,30 @@ dependencies = [ "typing-extensions>=3.7.4.3", ] files = [ - {file = "typer-0.15.2-py3-none-any.whl", hash = "sha256:46a499c6107d645a9c13f7ee46c5d5096cae6f5fc57dd11eccbbb9ae3e44ddfc"}, - {file = "typer-0.15.2.tar.gz", hash = "sha256:ab2fab47533a813c49fe1f16b1a370fd5819099c00b119e0633df65f22144ba5"}, + {file = "typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855"}, + {file = "typer-0.16.0.tar.gz", hash = "sha256:af377ffaee1dbe37ae9440cb4e8f11686ea5ce4e9bae01b84ae7c63b87f1dd3b"}, ] [[package]] name = "typer" -version = "0.15.2" +version = "0.16.0" extras = ["all"] requires_python = ">=3.7" summary = "Typer, build great CLIs. Easy to code. Based on Python type hints." dependencies = [ - "typer==0.15.2", + "typer==0.16.0", ] files = [ - {file = "typer-0.15.2-py3-none-any.whl", hash = "sha256:46a499c6107d645a9c13f7ee46c5d5096cae6f5fc57dd11eccbbb9ae3e44ddfc"}, - {file = "typer-0.15.2.tar.gz", hash = "sha256:ab2fab47533a813c49fe1f16b1a370fd5819099c00b119e0633df65f22144ba5"}, + {file = "typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855"}, + {file = "typer-0.16.0.tar.gz", hash = "sha256:af377ffaee1dbe37ae9440cb4e8f11686ea5ce4e9bae01b84ae7c63b87f1dd3b"}, ] [[package]] name = "typing-extensions" -version = "4.7.1" -requires_python = ">=3.7" -summary = "Backported and Experimental Type Hints for Python 3.7+" +version = "4.14.1" +requires_python = ">=3.9" +summary = "Backported and Experimental Type Hints for Python 3.9+" files = [ - {file = "typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36"}, - {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, + {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, + {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, ] diff --git a/pdm.toml b/pdm.toml new file mode 100644 index 0000000..57c9789 --- /dev/null +++ b/pdm.toml @@ -0,0 +1,2 @@ +[strategy] +inherit_metadata = true diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..576c1c0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +typer[all]>=0.9.0 +lkml>=1.3.1 +pyyaml>=6.0.1 +rich>=13.7.1 \ No newline at end of file diff --git a/tests/test_explores_command.py b/tests/test_explores_command.py new file mode 100644 index 0000000..21fe7a2 --- /dev/null +++ b/tests/test_explores_command.py @@ -0,0 +1,387 @@ +import pytest +import tempfile +import json +from pathlib import Path +from unittest.mock import patch, Mock +from typer.testing import CliRunner + +from lkml2cube.main import app + + +class TestExploresCommand: + """Integration tests for the explores command.""" + + def setup_method(self): + """Set up test fixtures.""" + self.runner = CliRunner() + + # Sample Cube meta API response with both cubes and views + self.sample_cube_meta = { + "cubes": [ + { + "name": "orders", + "title": "Orders Table", + "sql_table": "public.orders", + "dimensions": [ + { + "name": "id", + "title": "Order ID", + "type": "number", + "sql": "${TABLE}.id", + "public": True + }, + { + "name": "status", + "title": "Order Status", + "type": "string", + "sql": "${TABLE}.status", + "public": True, + "description": "Status of the order" + }, + { + "name": "internal_code", + "type": "string", + "sql": "${TABLE}.internal_code", + "public": False # This should be hidden + } + ], + "measures": [ + { + "name": "count", + "title": "Order Count", + "type": "count", + "aggType": "count", + "sql": "*", + "public": True + }, + { + "name": "total_amount", + "title": "Total Amount", + "type": "sum", + "aggType": "sum", + "sql": "${TABLE}.amount", + "public": True, + "description": "Total order amount" + } + ] + }, + { + "name": "customers", + "title": "Customer Information", + "sql": "SELECT * FROM customers WHERE active = true", + "dimensions": [ + { + "name": "customer_id", + "title": "Customer ID", + "type": "number", + "sql": "${TABLE}.id", + "public": True + } + ], + "measures": [ + { + "name": "customer_count", + "title": "Customer Count", + "type": "count", + "aggType": "count", + "sql": "*", + "public": True + } + ] + }, + # Add a Cube view that should become a LookML explore + { + "name": "order_summary", + "title": "Order Summary Analysis", + "description": "Combined view of orders and customer data", + "dimensions": [ + { + "aliasMember": "orders.id", + "name": "order_summary.order_id", + "title": "Order ID", + "type": "number", + "public": True + }, + { + "aliasMember": "customers.customer_id", + "name": "order_summary.customer_id", + "title": "Customer ID", + "type": "number", + "public": True + } + ], + "measures": [ + { + "aliasMember": "orders.total_amount", + "name": "order_summary.total_revenue", + "title": "Total Revenue", + "type": "number", + "public": True + } + ] + } + ] + } + + @patch('lkml2cube.parser.cube_api.requests.get') + def test_explores_command_parseonly(self, mock_get): + """Test explores command with parseonly option.""" + # Mock the API response + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = self.sample_cube_meta + mock_get.return_value = mock_response + + result = self.runner.invoke(app, [ + "explores", + "http://localhost:4000/cubejs-api/v1/meta", + "--token", "test-token", + "--parseonly" + ]) + + assert result.exit_code == 0 + # Should contain the original cube meta data + assert "orders" in result.stdout + assert "customers" in result.stdout + + @patch('lkml2cube.parser.cube_api.requests.get') + def test_explores_command_printonly(self, mock_get): + """Test explores command with printonly option.""" + # Mock the API response + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = self.sample_cube_meta + mock_get.return_value = mock_response + + result = self.runner.invoke(app, [ + "explores", + "http://localhost:4000/cubejs-api/v1/meta", + "--token", "test-token", + "--printonly" + ]) + + assert result.exit_code == 0 + # Should contain the converted LookML model as YAML + assert "views:" in result.stdout + assert "explores:" in result.stdout + assert "name: orders" in result.stdout + assert "name: customers" in result.stdout + assert "name: order_summary" in result.stdout + + @patch('lkml2cube.parser.cube_api.requests.get') + def test_explores_command_write_files(self, mock_get): + """Test explores command writing files to output directory.""" + # Mock the API response + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = self.sample_cube_meta + mock_get.return_value = mock_response + + with tempfile.TemporaryDirectory() as temp_dir: + result = self.runner.invoke(app, [ + "explores", + "http://localhost:4000/cubejs-api/v1/meta", + "--token", "test-token", + "--outputdir", temp_dir + ]) + + assert result.exit_code == 0 + + # Check that files were created + views_dir = Path(temp_dir) / "views" + explores_dir = Path(temp_dir) / "explores" + assert views_dir.exists() + assert explores_dir.exists() + + orders_file = views_dir / "orders.view.lkml" + customers_file = views_dir / "customers.view.lkml" + order_summary_file = explores_dir / "order_summary.explore.lkml" + + assert orders_file.exists() + assert customers_file.exists() + assert order_summary_file.exists() + + # Check orders file content + with open(orders_file, "r") as f: + orders_content = f.read() + + assert "view orders {" in orders_content + assert 'label: "Orders Table"' in orders_content + assert "sql_table_name: public.orders ;;" in orders_content + assert "dimension: id {" in orders_content + assert "dimension: status {" in orders_content + assert "dimension: internal_code {" in orders_content + assert "measure: count {" in orders_content + assert "measure: total_amount {" in orders_content + assert "hidden: yes" in orders_content # For internal_code + assert 'description: "Status of the order"' in orders_content + assert 'description: "Total order amount"' in orders_content + + # Check customers file content + with open(customers_file, "r") as f: + customers_content = f.read() + + assert "view customers {" in customers_content + assert 'label: "Customer Information"' in customers_content + assert "derived_table: {" in customers_content + assert "sql: SELECT * FROM customers WHERE active = true ;;" in customers_content + assert "dimension: customer_id {" in customers_content + assert "measure: customer_count {" in customers_content + + # Check explore file content + with open(order_summary_file, "r") as f: + explore_content = f.read() + + assert "explore order_summary {" in explore_content + assert 'label: "Order Summary Analysis"' in explore_content + assert "view_name:" in explore_content + assert "join:" in explore_content + assert "type: left_outer" in explore_content + assert "sql_on:" in explore_content + + # Check summary output - Rich tables are output as objects in test mode + # Just verify the command completed successfully + assert result.exit_code == 0 + + @patch('lkml2cube.parser.cube_api.requests.get') + def test_explores_command_api_error(self, mock_get): + """Test explores command handling API errors.""" + # Mock a failed API response + mock_response = Mock() + mock_response.status_code = 401 + mock_response.text = "Unauthorized" + mock_get.return_value = mock_response + + result = self.runner.invoke(app, [ + "explores", + "http://localhost:4000/cubejs-api/v1/meta", + "--token", "invalid-token" + ]) + + assert result.exit_code == 1 + assert "Failed to fetch meta data" in str(result.exception) + + @patch('lkml2cube.parser.cube_api.requests.get') + def test_explores_command_no_response(self, mock_get): + """Test explores command handling when no response received.""" + # Mock no response (None return) + mock_get.side_effect = Exception("Connection failed") + + result = self.runner.invoke(app, [ + "explores", + "http://localhost:4000/cubejs-api/v1/meta", + "--token", "test-token" + ]) + + assert result.exit_code == 1 + + @patch('lkml2cube.parser.cube_api.requests.get') + def test_explores_command_extended_url(self, mock_get): + """Test that explores command adds ?extended to URL.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"cubes": []} + mock_get.return_value = mock_response + + result = self.runner.invoke(app, [ + "explores", + "http://localhost:4000/cubejs-api/v1/meta", + "--token", "test-token", + "--printonly" + ]) + + assert result.exit_code == 0 + # Verify the URL was called with ?extended + mock_get.assert_called_once() + call_args = mock_get.call_args + assert call_args[0][0] == "http://localhost:4000/cubejs-api/v1/meta?extended" + + @patch('lkml2cube.parser.cube_api.requests.get') + def test_explores_command_custom_output_dir(self, mock_get): + """Test explores command with custom output directory.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "cubes": [ + { + "name": "test_cube", + "dimensions": [{"name": "test_dim", "type": "string", "sql": "test", "public": True}], + "measures": [] + } + ] + } + mock_get.return_value = mock_response + + with tempfile.TemporaryDirectory() as temp_dir: + custom_output = Path(temp_dir) / "custom" / "output" + + result = self.runner.invoke(app, [ + "explores", + "http://localhost:4000/cubejs-api/v1/meta", + "--token", "test-token", + "--outputdir", str(custom_output) + ]) + + assert result.exit_code == 0 + + # Check that custom directory structure was created + views_dir = custom_output / "views" + assert views_dir.exists() + + test_file = views_dir / "test_cube.view.lkml" + assert test_file.exists() + + @patch('lkml2cube.parser.cube_api.requests.get') + def test_explores_command_complex_sql(self, mock_get): + """Test explores command with complex multiline SQL.""" + complex_cube_meta = { + "cubes": [ + { + "name": "complex_view", + "sql": "SELECT\n id,\n name,\n CASE\n WHEN status = 'A' THEN 'Active'\n ELSE 'Inactive'\n END as status_desc\nFROM users", + "dimensions": [ + { + "name": "complex_dimension", + "type": "string", + "sql": "CASE\n WHEN ${TABLE}.type = 'premium'\n THEN 'Premium User'\n ELSE 'Regular User'\nEND", + "public": True + } + ], + "measures": [] + } + ] + } + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = complex_cube_meta + mock_get.return_value = mock_response + + with tempfile.TemporaryDirectory() as temp_dir: + result = self.runner.invoke(app, [ + "explores", + "http://localhost:4000/cubejs-api/v1/meta", + "--token", "test-token", + "--outputdir", temp_dir + ]) + + assert result.exit_code == 0 + + # Check file content for proper multiline SQL handling + view_file = Path(temp_dir) / "views" / "complex_view.view.lkml" + assert view_file.exists() + + with open(view_file, "r") as f: + content = f.read() + + # Check derived table multiline SQL + assert "derived_table: {" in content + assert "sql:" in content + assert "SELECT" in content + assert "CASE" in content + assert "FROM users" in content + + # Check dimension multiline SQL + assert "dimension: complex_dimension {" in content + assert "WHEN ${TABLE}.type = 'premium'" in content + assert "THEN 'Premium User'" in content \ No newline at end of file diff --git a/tests/test_lookml_writer.py b/tests/test_lookml_writer.py new file mode 100644 index 0000000..3ec8b67 --- /dev/null +++ b/tests/test_lookml_writer.py @@ -0,0 +1,440 @@ +import pytest +import tempfile +import os +from pathlib import Path +from unittest.mock import patch + +from lkml2cube.parser.loader import ( + write_lookml_files, + _generate_lookml_content, + _generate_dimension_lines, + _generate_measure_lines, + _generate_filter_lines, +) + + +class TestLookMLWriter: + """Test suite for LookML file writing functionality.""" + + def test_write_lookml_files_with_views(self): + """Test writing LookML files for views.""" + lookml_model = { + "views": [ + { + "name": "orders", + "label": "Orders Table", + "sql_table_name": "public.orders", + "dimensions": [ + { + "name": "id", + "label": "Order ID", + "type": "number", + "sql": "${TABLE}.id" + } + ], + "measures": [ + { + "name": "count", + "label": "Count of Orders", + "type": "count", + "sql": "*" + } + ] + } + ] + } + + with tempfile.TemporaryDirectory() as temp_dir: + summary = write_lookml_files(lookml_model, temp_dir) + + # Check summary structure + assert "views" in summary + assert len(summary["views"]) == 1 + assert summary["views"][0]["name"] == "orders" + + # Check file was created + expected_path = Path(temp_dir) / "views" / "orders.view.lkml" + assert expected_path.exists() + + # Check file content + with open(expected_path, "r") as f: + content = f.read() + assert "view orders {" in content + assert 'label: "Orders Table"' in content + assert "sql_table_name: public.orders ;;" in content + assert "dimension: id {" in content + assert "measure: count {" in content + + def test_write_lookml_files_with_explores(self): + """Test writing LookML files for explores.""" + lookml_model = { + "explores": [ + { + "name": "order_analysis", + "label": "Order Analysis" + } + ] + } + + with tempfile.TemporaryDirectory() as temp_dir: + summary = write_lookml_files(lookml_model, temp_dir) + + # Check summary structure + assert "explores" in summary + assert len(summary["explores"]) == 1 + assert summary["explores"][0]["name"] == "order_analysis" + + # Check file was created + expected_path = Path(temp_dir) / "explores" / "order_analysis.explore.lkml" + assert expected_path.exists() + + # Check file content + with open(expected_path, "r") as f: + content = f.read() + assert "explore order_analysis {" in content + assert 'label: "Order Analysis"' in content + + def test_write_lookml_files_empty_model(self): + """Test handling of empty LookML model.""" + with pytest.raises(Exception, match="No LookML model available"): + write_lookml_files(None, "/tmp") + + with pytest.raises(Exception, match="No LookML model available"): + write_lookml_files({}, "/tmp") + + def test_write_lookml_files_creates_directories(self): + """Test that directories are created as needed.""" + lookml_model = { + "views": [{"name": "test_view"}], + "explores": [{"name": "test_explore"}] + } + + with tempfile.TemporaryDirectory() as temp_dir: + write_lookml_files(lookml_model, temp_dir) + + views_dir = Path(temp_dir) / "views" + explores_dir = Path(temp_dir) / "explores" + + assert views_dir.exists() and views_dir.is_dir() + assert explores_dir.exists() and explores_dir.is_dir() + + +class TestLookMLContentGeneration: + """Test suite for LookML content generation functions.""" + + def test_generate_lookml_content_view_basic(self): + """Test basic view content generation.""" + view_element = { + "name": "orders", + "label": "Orders Table" + } + + content = _generate_lookml_content(view_element, "view") + lines = content.split("\n") + + assert lines[0] == "view orders {" + assert ' label: "Orders Table"' in lines + assert lines[-1] == "}" + + def test_generate_lookml_content_view_with_sql_table(self): + """Test view content generation with SQL table.""" + view_element = { + "name": "orders", + "sql_table_name": "public.orders" + } + + content = _generate_lookml_content(view_element, "view") + assert "sql_table_name: public.orders ;;" in content + + def test_generate_lookml_content_view_with_derived_table(self): + """Test view content generation with derived table.""" + view_element = { + "name": "orders", + "derived_table": { + "sql": "SELECT * FROM orders WHERE status = 'active'" + } + } + + content = _generate_lookml_content(view_element, "view") + assert "derived_table: {" in content + assert "sql: SELECT * FROM orders WHERE status = 'active' ;;" in content + + def test_generate_lookml_content_view_with_multiline_sql(self): + """Test view content generation with multiline SQL.""" + view_element = { + "name": "orders", + "derived_table": { + "sql": "SELECT *\nFROM orders\nWHERE status = 'active'" + } + } + + content = _generate_lookml_content(view_element, "view") + lines = content.split("\n") + + assert "sql:" in content + assert "SELECT *" in content + assert "FROM orders" in content + assert "WHERE status = 'active'" in content + + def test_generate_lookml_content_view_with_extends(self): + """Test view content generation with extends.""" + view_element = { + "name": "orders", + "extends": ["base_table"] + } + + content = _generate_lookml_content(view_element, "view") + assert "extends: [base_table]" in content + + def test_generate_lookml_content_explore_basic(self): + """Test basic explore content generation.""" + explore_element = { + "name": "order_analysis", + "label": "Order Analysis" + } + + content = _generate_lookml_content(explore_element, "explore") + lines = content.split("\n") + + assert lines[0] == "explore order_analysis {" + assert ' label: "Order Analysis"' in lines + assert lines[-1] == "}" + + def test_generate_dimension_lines_basic(self): + """Test basic dimension line generation.""" + dimension = { + "name": "order_id", + "label": "Order ID", + "type": "number", + "sql": "${TABLE}.id" + } + + lines = _generate_dimension_lines(dimension) + content = "\n".join(lines) + + assert "dimension: order_id {" in content + assert 'label: "Order ID"' in content + assert "type: number" in content + assert "sql: ${TABLE}.id ;;" in content + + def test_generate_dimension_lines_with_description(self): + """Test dimension line generation with description.""" + dimension = { + "name": "order_id", + "description": "Unique identifier for orders", + "type": "number" + } + + lines = _generate_dimension_lines(dimension) + content = "\n".join(lines) + + assert 'description: "Unique identifier for orders"' in content + + def test_generate_dimension_lines_hidden(self): + """Test dimension line generation with hidden property.""" + dimension = { + "name": "internal_id", + "type": "number", + "hidden": "yes" + } + + lines = _generate_dimension_lines(dimension) + content = "\n".join(lines) + + assert "hidden: yes" in content + + def test_generate_dimension_lines_multiline_sql(self): + """Test dimension line generation with multiline SQL.""" + dimension = { + "name": "complex_field", + "type": "string", + "sql": "CASE\n WHEN status = 'A' THEN 'Active'\n ELSE 'Inactive'\nEND" + } + + lines = _generate_dimension_lines(dimension) + content = "\n".join(lines) + + assert "sql:" in content + assert "CASE" in content + assert "WHEN status = 'A' THEN 'Active'" in content + assert ";;" in content + + def test_generate_measure_lines_basic(self): + """Test basic measure line generation.""" + measure = { + "name": "total_sales", + "label": "Total Sales", + "type": "sum", + "sql": "${TABLE}.amount" + } + + lines = _generate_measure_lines(measure) + content = "\n".join(lines) + + assert "measure: total_sales {" in content + assert 'label: "Total Sales"' in content + assert "type: sum" in content + assert "sql: ${TABLE}.amount ;;" in content + + def test_generate_measure_lines_with_description_and_hidden(self): + """Test measure line generation with description and hidden.""" + measure = { + "name": "internal_metric", + "description": "Internal calculation metric", + "type": "number", + "hidden": "yes" + } + + lines = _generate_measure_lines(measure) + content = "\n".join(lines) + + assert 'description: "Internal calculation metric"' in content + assert "hidden: yes" in content + + def test_generate_filter_lines_basic(self): + """Test basic filter line generation.""" + filter_def = { + "name": "date_filter", + "label": "Date Filter", + "type": "date" + } + + lines = _generate_filter_lines(filter_def) + content = "\n".join(lines) + + assert "filter: date_filter {" in content + assert 'label: "Date Filter"' in content + assert "type: date" in content + + def test_generate_filter_lines_with_description(self): + """Test filter line generation with description.""" + filter_def = { + "name": "status_filter", + "description": "Filter by order status", + "type": "string" + } + + lines = _generate_filter_lines(filter_def) + content = "\n".join(lines) + + assert 'description: "Filter by order status"' in content + + +class TestLookMLIntegration: + """Integration tests for LookML writing functionality.""" + + def test_full_view_generation(self): + """Test complete view file generation with all components.""" + lookml_model = { + "views": [ + { + "name": "comprehensive_view", + "label": "Comprehensive Test View", + "sql_table_name": "schema.table", + "extends": ["base_view"], + "dimensions": [ + { + "name": "id", + "label": "ID", + "type": "number", + "sql": "${TABLE}.id", + "description": "Primary key" + }, + { + "name": "hidden_field", + "type": "string", + "sql": "${TABLE}.internal", + "hidden": "yes" + } + ], + "measures": [ + { + "name": "count", + "label": "Count", + "type": "count", + "sql": "*" + } + ], + "filters": [ + { + "name": "date_range", + "label": "Date Range", + "type": "date" + } + ] + } + ] + } + + with tempfile.TemporaryDirectory() as temp_dir: + summary = write_lookml_files(lookml_model, temp_dir) + + # Verify file creation and content + file_path = Path(temp_dir) / "views" / "comprehensive_view.view.lkml" + assert file_path.exists() + + with open(file_path, "r") as f: + content = f.read() + + # Check all components are present + assert "view comprehensive_view {" in content + assert 'label: "Comprehensive Test View"' in content + assert "sql_table_name: schema.table ;;" in content + assert "extends: [base_view]" in content + assert "dimension: id {" in content + assert "dimension: hidden_field {" in content + assert "measure: count {" in content + assert "filter: date_range {" in content + assert "hidden: yes" in content + + def test_multiple_views_and_explores(self): + """Test writing multiple views and explores.""" + lookml_model = { + "views": [ + {"name": "view1", "label": "First View"}, + {"name": "view2", "label": "Second View"} + ], + "explores": [ + {"name": "explore1", "label": "First Explore"}, + {"name": "explore2", "label": "Second Explore"} + ] + } + + with tempfile.TemporaryDirectory() as temp_dir: + summary = write_lookml_files(lookml_model, temp_dir) + + # Check summary + assert len(summary["views"]) == 2 + assert len(summary["explores"]) == 2 + + # Check all files exist + view1_path = Path(temp_dir) / "views" / "view1.view.lkml" + view2_path = Path(temp_dir) / "views" / "view2.view.lkml" + explore1_path = Path(temp_dir) / "explores" / "explore1.explore.lkml" + explore2_path = Path(temp_dir) / "explores" / "explore2.explore.lkml" + + assert view1_path.exists() + assert view2_path.exists() + assert explore1_path.exists() + assert explore2_path.exists() + + def test_empty_elements_handling(self): + """Test handling of elements without names.""" + lookml_model = { + "views": [ + {"name": "valid_view"}, + {"label": "No name view"}, # Missing name + {"name": ""} # Empty name + ] + } + + with tempfile.TemporaryDirectory() as temp_dir: + summary = write_lookml_files(lookml_model, temp_dir) + + # Should only create file for valid view + assert len(summary["views"]) == 1 + assert summary["views"][0]["name"] == "valid_view" + + # Only one file should exist + files = list(Path(temp_dir).rglob("*.lkml")) + assert len(files) == 1 \ No newline at end of file From 8cd95f2737c791fd1b9309eaff5ae589c11c107a Mon Sep 17 00:00:00 2001 From: Paco Valdez Date: Mon, 14 Jul 2025 15:31:06 -0700 Subject: [PATCH 3/4] add more parameters for explores --- CLAUDE.md | 1 + lkml2cube/parser/cube_api.py | 4 ++- lkml2cube/parser/loader.py | 66 +++++++++++++++++++++++++++++++++--- 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2e98840..ffc7866 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,7 @@ The tool provides three main commands: - **Cube API Mapping**: - Cube cubes (with `sql_table`/`sql`) → LookML views - Cube views (with `aliasMember` joins) → LookML explores with join definitions +- **LookML Enhancement**: Generates production-ready LookML with includes, proper joins, primary keys, and drill fields ### File Structure - `examples/` - Contains sample output files (cubes and views) diff --git a/lkml2cube/parser/cube_api.py b/lkml2cube/parser/cube_api.py index 277d428..ed5d87d 100644 --- a/lkml2cube/parser/cube_api.py +++ b/lkml2cube/parser/cube_api.py @@ -184,7 +184,9 @@ def _parse_cube_view_to_explore(model: dict) -> dict: for cube_name in sorted(joined_cubes): join = { "name": cube_name, - "type": "left_outer", # Default join type + "view_label": cube_name.replace("_", " ").title(), + "type": "left_outer", # Default join type + "relationship": "many_to_one", # Default relationship # In a real implementation, you'd extract actual join conditions # from the Cube model's join definitions "sql_on": f"${{{primary_cube}.id}} = ${{{cube_name}.id}}" diff --git a/lkml2cube/parser/loader.py b/lkml2cube/parser/loader.py index d054d18..6833581 100644 --- a/lkml2cube/parser/loader.py +++ b/lkml2cube/parser/loader.py @@ -149,8 +149,13 @@ def write_lookml_files(lookml_model, outputdir): file_name = f"{element_name}.{lookml_root_element[:-1]}.lkml" file_path = join(outputdir, lookml_root_element, file_name) + # Generate includes for explores + includes = None + if lookml_root_element == "explores": + includes = _generate_includes_for_explore(element, lookml_model) + # Generate LookML content - lookml_content = _generate_lookml_content(element, lookml_root_element[:-1]) + lookml_content = _generate_lookml_content(element, lookml_root_element[:-1], includes) with open(file_path, "w") as f: f.write(lookml_content) @@ -163,13 +168,19 @@ def write_lookml_files(lookml_model, outputdir): return summary -def _generate_lookml_content(element, element_type): +def _generate_lookml_content(element, element_type, includes=None): """ Generate LookML content for a view or explore element. """ lines = [] name = element.get("name", "unnamed") + # Add includes for explores + if element_type == "explore" and includes: + for include in includes: + lines.append(f'include: "{include}"') + lines.append("") # Empty line after includes + lines.append(f"{element_type} {name} {{") if element_type == "view": @@ -216,6 +227,12 @@ def _generate_lookml_content(element, element_type): if "label" in element: lines.append(f' label: "{element["label"]}"') + if "description" in element: + lines.append(f' description: "{element["description"]}"') + + if "hidden" in element and element["hidden"]: + lines.append(f" hidden: yes") + # Add view_name if specified if "view_name" in element: lines.append(f" view_name: {element['view_name']}") @@ -246,6 +263,12 @@ def _generate_dimension_lines(dimension): if "type" in dimension: lines.append(f' type: {dimension["type"]}') + # Add primary_key if this looks like an ID field + if "primary_key" in dimension and dimension["primary_key"]: + lines.append(" primary_key: yes") + elif name.lower().endswith("_id") or name.lower() == "id": + lines.append(" primary_key: yes") + if "sql" in dimension: sql_content = dimension["sql"] if isinstance(sql_content, str) and "\n" in sql_content: @@ -290,6 +313,10 @@ def _generate_measure_lines(measure): else: lines.append(f" sql: {sql_content} ;;") + # Add drill_fields for count measures + if measure.get("type") == "count": + lines.append(" drill_fields: [id, name]") + if "hidden" in measure and measure["hidden"] == "yes": lines.append(" hidden: yes") @@ -326,19 +353,48 @@ def _generate_join_lines(join): name = join.get("name", "unnamed") lines.append(f" join: {name} {{") + if "view_label" in join: + lines.append(f' view_label: "{join["view_label"]}"') + if "type" in join: lines.append(f" type: {join['type']}") - if "sql_on" in join: - lines.append(f" sql_on: {join['sql_on']} ;;") - if "relationship" in join: lines.append(f" relationship: {join['relationship']}") + if "sql_on" in join: + lines.append(f" sql_on: {join['sql_on']} ;;") + lines.append(" }") return lines +def _generate_includes_for_explore(explore, lookml_model): + """ + Generate include statements for an explore based on the views it references. + """ + includes = [] + referenced_views = set() + + # Add the base view + if "view_name" in explore: + referenced_views.add(explore["view_name"]) + + # Add joined views + if "joins" in explore: + for join in explore["joins"]: + referenced_views.add(join["name"]) + + # Generate include paths for referenced views + for view_name in referenced_views: + # Check if the view exists in our model + view_exists = any(view["name"] == view_name for view in lookml_model.get("views", [])) + if view_exists: + includes.append(f"/views/{view_name}.view.lkml") + + return includes + + def print_summary(summary): # Use the proper Rich console for table rendering rich_console = rich.console.Console() From f683d0cf4493c52428666f254123d11c1e9b1721 Mon Sep 17 00:00:00 2001 From: Paco Valdez Date: Mon, 14 Jul 2025 15:39:26 -0700 Subject: [PATCH 4/4] Add requests --- pdm.lock | 110 ++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + requirements.txt | 3 +- 3 files changed, 112 insertions(+), 2 deletions(-) diff --git a/pdm.lock b/pdm.lock index 495a091..5d5ad8e 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,11 +5,83 @@ groups = ["default", "test"] strategy = ["cross_platform"] lock_version = "4.5.0" -content_hash = "sha256:55af3a669132a1facf2c999b3e1e72ec37b5a7003b0900534da9ab84d4b758d3" +content_hash = "sha256:bcc1a3aab3bab7a4b535dec41ee5a87d23eb296cb705064437db21ca37daad76" [[metadata.targets]] requires_python = ">=3.10" +[[package]] +name = "certifi" +version = "2025.7.14" +requires_python = ">=3.7" +summary = "Python package for providing Mozilla's CA Bundle." +files = [ + {file = "certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2"}, + {file = "certifi-2025.7.14.tar.gz", hash = "sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.2" +requires_python = ">=3.7" +summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +files = [ + {file = "charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980"}, + {file = "charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0"}, + {file = "charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63"}, +] + [[package]] name = "click" version = "8.2.1" @@ -46,6 +118,16 @@ files = [ {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, ] +[[package]] +name = "idna" +version = "3.10" +requires_python = ">=3.6" +summary = "Internationalized Domain Names in Applications (IDNA)" +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -182,6 +264,22 @@ files = [ {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, ] +[[package]] +name = "requests" +version = "2.32.4" +requires_python = ">=3.8" +summary = "Python HTTP for Humans." +dependencies = [ + "certifi>=2017.4.17", + "charset-normalizer<4,>=2", + "idna<4,>=2.5", + "urllib3<3,>=1.21.1", +] +files = [ + {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, + {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, +] + [[package]] name = "rich" version = "14.0.0" @@ -286,3 +384,13 @@ files = [ {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, ] + +[[package]] +name = "urllib3" +version = "2.5.0" +requires_python = ">=3.9" +summary = "HTTP library with thread-safe connection pooling, file post, and more." +files = [ + {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, + {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, +] diff --git a/pyproject.toml b/pyproject.toml index b3277b8..c367819 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "lkml>=1.3.1", "pyyaml>=6.0.1", "rich>=13.7.1", + "requests>=2.32.4", ] requires-python = ">=3.10" readme = "README.md" diff --git a/requirements.txt b/requirements.txt index 576c1c0..805ed63 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ typer[all]>=0.9.0 lkml>=1.3.1 pyyaml>=6.0.1 -rich>=13.7.1 \ No newline at end of file +rich>=13.7.1 +requests>=2.32.4