lexis-cli 0.4.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lexis/__init__.py +10 -0
- lexis/_vendor/ossie/NOTICE.md +14 -0
- lexis/_vendor/ossie/__init__.py +44 -0
- lexis/_vendor/ossie/models.py +224 -0
- lexis/cli.py +298 -0
- lexis/demo_data.py +87 -0
- lexis/dispatch.py +66 -0
- lexis/mcp_server.py +158 -0
- lexis/parser.py +19 -0
- lexis/resolved_model.py +128 -0
- lexis/retail_demo_data.py +615 -0
- lexis/sml/__init__.py +10 -0
- lexis/sml/_common.py +224 -0
- lexis/sml/emit.py +403 -0
- lexis/sml/models.py +172 -0
- lexis/sml/parse.py +382 -0
- lexis/transpilers/base.py +11 -0
- lexis/transpilers/cube.py +119 -0
- lexis/transpilers/dbt_ossie.py +67 -0
- lexis/transpilers/mcp.py +182 -0
- lexis/transpilers/snowflake_semantic_view.py +126 -0
- lexis/transpilers/sql/__init__.py +22 -0
- lexis/transpilers/sql/base.py +172 -0
- lexis/transpilers/sql/bigquery.py +8 -0
- lexis/transpilers/sql/databricks.py +8 -0
- lexis/transpilers/sql/duckdb.py +8 -0
- lexis/transpilers/sql/postgres.py +10 -0
- lexis/transpilers/sql/snowflake.py +8 -0
- lexis_cli-0.4.2.dist-info/METADATA +883 -0
- lexis_cli-0.4.2.dist-info/RECORD +33 -0
- lexis_cli-0.4.2.dist-info/WHEEL +4 -0
- lexis_cli-0.4.2.dist-info/entry_points.txt +2 -0
- lexis_cli-0.4.2.dist-info/licenses/LICENSE +202 -0
lexis/dispatch.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Shared target-dispatch logic: pick the right emitter for a transpile request.
|
|
2
|
+
|
|
3
|
+
Used by both the CLI (`cli.py`) and the web API (`lexis_api`) so the mapping from
|
|
4
|
+
`--target` to an emitter lives in exactly one place.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from lexis._vendor.ossie import OssieDocument
|
|
10
|
+
from lexis.resolved_model import ResolvedModel
|
|
11
|
+
from lexis.sml.emit import emit_sml_files
|
|
12
|
+
from lexis.transpilers.cube import emit_cube_yaml
|
|
13
|
+
from lexis.transpilers.dbt_ossie import emit_dbt_ossie_document
|
|
14
|
+
from lexis.transpilers.mcp import emit_mcp_tool_manifest
|
|
15
|
+
from lexis.transpilers.snowflake_semantic_view import emit_snowflake_semantic_view
|
|
16
|
+
from lexis.transpilers.sql import EMITTERS as SQL_EMITTERS
|
|
17
|
+
|
|
18
|
+
TARGETS = [*SQL_EMITTERS.keys(), "cube", "dbt", "mcp", "snowflake_semantic_view", "sml"]
|
|
19
|
+
|
|
20
|
+
# Short alternate spellings accepted alongside the canonical TARGETS name - resolved
|
|
21
|
+
# to the canonical name before dispatch, so callers/tests only ever need to branch on
|
|
22
|
+
# the canonical spelling below.
|
|
23
|
+
TARGET_ALIASES = {"ssv": "snowflake_semantic_view"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class TranspileResult:
|
|
28
|
+
# `sml` is the one multi-file target - one YAML file per SML object - so
|
|
29
|
+
# `content` is a `dict[str, str]` (relative filename -> content) there;
|
|
30
|
+
# every other target still returns a single `str`.
|
|
31
|
+
content: str | dict[str, str]
|
|
32
|
+
warnings: list[str]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def transpile(
|
|
36
|
+
document: OssieDocument,
|
|
37
|
+
model: ResolvedModel,
|
|
38
|
+
target: str,
|
|
39
|
+
metric: str | None = None,
|
|
40
|
+
group_by: list[str] | None = None,
|
|
41
|
+
) -> TranspileResult:
|
|
42
|
+
"""Emit `model` (parsed from `document`) in the given `target` format.
|
|
43
|
+
|
|
44
|
+
Raises ValueError for a missing/unknown target or a missing metric on a SQL target.
|
|
45
|
+
"""
|
|
46
|
+
target = TARGET_ALIASES.get(target, target)
|
|
47
|
+
if target in SQL_EMITTERS:
|
|
48
|
+
if not metric:
|
|
49
|
+
raise ValueError(f"metric is required for target {target!r}")
|
|
50
|
+
emitter = SQL_EMITTERS[target]()
|
|
51
|
+
content = emitter.emit_metric_query(model, metric, group_by=group_by or None)
|
|
52
|
+
return TranspileResult(content=content, warnings=[])
|
|
53
|
+
elif target == "cube":
|
|
54
|
+
return TranspileResult(content=emit_cube_yaml(model), warnings=[])
|
|
55
|
+
elif target == "dbt":
|
|
56
|
+
result = emit_dbt_ossie_document(document)
|
|
57
|
+
return TranspileResult(content=result.artifact.content, warnings=result.warnings)
|
|
58
|
+
elif target == "mcp":
|
|
59
|
+
return TranspileResult(content=emit_mcp_tool_manifest(model), warnings=[])
|
|
60
|
+
elif target == "snowflake_semantic_view":
|
|
61
|
+
return TranspileResult(content=emit_snowflake_semantic_view(model), warnings=[])
|
|
62
|
+
elif target == "sml":
|
|
63
|
+
result = emit_sml_files(document)
|
|
64
|
+
return TranspileResult(content=result.files, warnings=result.warnings)
|
|
65
|
+
else:
|
|
66
|
+
raise ValueError(f"Unknown target {target!r}")
|
lexis/mcp_server.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Live MCP server: turn a resolved model's metrics into callable, query-executing
|
|
2
|
+
MCP tools (not just the static schema `transpilers/mcp.py` emits).
|
|
3
|
+
|
|
4
|
+
Callers (the CLI's `mcp-serve` command, the API's mounted HTTP endpoint) supply an
|
|
5
|
+
`ExecuteMetric` closure that knows how to actually run a metric query against
|
|
6
|
+
whichever connection they've opened - this module only knows about the MCP protocol
|
|
7
|
+
and the tool schema, never about DuckDB/Snowflake/FastAPI/SQLAlchemy, so it works the
|
|
8
|
+
same from a local stdio process or a per-request HTTP handler.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from datetime import date, datetime
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import anyio
|
|
16
|
+
from mcp import types
|
|
17
|
+
from mcp.server.lowlevel import Server
|
|
18
|
+
|
|
19
|
+
from lexis._vendor.ossie import OssieDialect
|
|
20
|
+
from lexis.resolved_model import ResolvedModel
|
|
21
|
+
from lexis.transpilers.mcp import build_metric_tool_specs, model_instructions, resolve_time_axis
|
|
22
|
+
from lexis.transpilers.sql.base import SqlDialectEmitter
|
|
23
|
+
|
|
24
|
+
# A metric result has no natural row limit of its own (it's an aggregate query, not a
|
|
25
|
+
# table scan) - this only guards against a pathological `group_by` fanning out to an
|
|
26
|
+
# enormous number of groups.
|
|
27
|
+
MAX_RESULT_ROWS = 1000
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _json_safe(value: Any) -> Any:
|
|
31
|
+
if isinstance(value, (date, datetime)):
|
|
32
|
+
return value.isoformat()
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run_metric_query(
|
|
37
|
+
con: Any,
|
|
38
|
+
emitter: SqlDialectEmitter,
|
|
39
|
+
model: ResolvedModel,
|
|
40
|
+
metric: str,
|
|
41
|
+
group_by: list[str] | None,
|
|
42
|
+
) -> dict:
|
|
43
|
+
"""Driver-agnostic metric execution - same shape as
|
|
44
|
+
`lexis_api.query_runtime.run_metric_query`, duplicated here (rather than
|
|
45
|
+
imported) so this core module stays free of the `lexis_api` (FastAPI/
|
|
46
|
+
SQLAlchemy) dependency chain; both copies are small enough that the duplication
|
|
47
|
+
is cheaper than relocating that module."""
|
|
48
|
+
sql = emitter.emit_metric_query(model, metric, group_by=group_by)
|
|
49
|
+
return _run(con, sql)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _run(con: Any, sql: str) -> dict:
|
|
53
|
+
cursor = con.cursor()
|
|
54
|
+
cursor.execute(sql)
|
|
55
|
+
columns = [d[0] for d in cursor.description]
|
|
56
|
+
rows = cursor.fetchmany(MAX_RESULT_ROWS)
|
|
57
|
+
return {
|
|
58
|
+
"columns": columns,
|
|
59
|
+
"rows": [[_json_safe(v) for v in r] for r in rows],
|
|
60
|
+
"row_count": len(rows),
|
|
61
|
+
"sql": sql,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def run_timeseries_query(
|
|
66
|
+
con: Any,
|
|
67
|
+
emitter: SqlDialectEmitter,
|
|
68
|
+
model: ResolvedModel,
|
|
69
|
+
metric: str,
|
|
70
|
+
time_dataset: str,
|
|
71
|
+
time_field: str,
|
|
72
|
+
grain: str,
|
|
73
|
+
) -> dict:
|
|
74
|
+
"""`metric` bucketed by `DATE_TRUNC(grain, time_dataset.time_field)`, one row per
|
|
75
|
+
period. Core mirror of `lexis_api.query_runtime.run_timeseries_query` (drill-down
|
|
76
|
+
filters omitted - the MCP tool doesn't expose them)."""
|
|
77
|
+
sql = emitter.emit_timeseries_query(model, metric, time_dataset, time_field, grain)
|
|
78
|
+
return _run(con, sql)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def query_datasets(
|
|
82
|
+
model: ResolvedModel,
|
|
83
|
+
metric: str,
|
|
84
|
+
group_by: list[str] | None,
|
|
85
|
+
time_grain: str | None = None,
|
|
86
|
+
time_field: str | None = None,
|
|
87
|
+
) -> set[str]:
|
|
88
|
+
"""Every dataset a `run_metric_or_timeseries` call touches - the caller passes
|
|
89
|
+
this to its connection opener so a duckdb_file connection ATTACHes the right
|
|
90
|
+
files first."""
|
|
91
|
+
metric_expr = model.resolve_expression(model.metrics[metric].expression, OssieDialect.ANSI_SQL)
|
|
92
|
+
datasets = set(model.referenced_datasets(metric_expr))
|
|
93
|
+
if time_grain:
|
|
94
|
+
datasets.add(resolve_time_axis(model, time_field)[0])
|
|
95
|
+
else:
|
|
96
|
+
datasets |= {ref.split(".", 1)[0] for ref in (group_by or [])}
|
|
97
|
+
return datasets
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def run_metric_or_timeseries(
|
|
101
|
+
con: Any,
|
|
102
|
+
emitter: SqlDialectEmitter,
|
|
103
|
+
model: ResolvedModel,
|
|
104
|
+
metric: str,
|
|
105
|
+
group_by: list[str] | None,
|
|
106
|
+
time_grain: str | None = None,
|
|
107
|
+
time_field: str | None = None,
|
|
108
|
+
) -> dict:
|
|
109
|
+
"""Dispatch a `query_<metric>` tool call: a `DATE_TRUNC`-bucketed timeseries when
|
|
110
|
+
`time_grain` is set, otherwise a plain (optionally grouped) total. Raises
|
|
111
|
+
`ValueError` (a clean MCP tool error) on an unsupported combination."""
|
|
112
|
+
if time_grain:
|
|
113
|
+
if group_by:
|
|
114
|
+
raise ValueError("time_grain and group_by cannot be combined in one query yet")
|
|
115
|
+
time_dataset, column = resolve_time_axis(model, time_field)
|
|
116
|
+
return run_timeseries_query(con, emitter, model, metric, time_dataset, column, time_grain)
|
|
117
|
+
return run_metric_query(con, emitter, model, metric, group_by)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# execute(metric, group_by, time_grain, time_field) -> result dict. `time_grain`
|
|
121
|
+
# (day/week/month/quarter/year) buckets the metric into consecutive periods via the
|
|
122
|
+
# emitter's timeseries query instead of a single total; `time_field` picks the date
|
|
123
|
+
# axis (see lexis.transpilers.mcp.resolve_time_axis). Both None for a plain total.
|
|
124
|
+
ExecuteMetric = Callable[[str, list[str] | None, str | None, str | None], dict]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def build_server(model: ResolvedModel, execute: ExecuteMetric, name: str | None = None) -> Server:
|
|
128
|
+
"""Build an MCP `Server` with one `query_<metric>` tool per Ossie metric, dispatching
|
|
129
|
+
tool calls to `execute(metric_name, group_by, time_grain, time_field)`."""
|
|
130
|
+
tools = [types.Tool(**spec) for spec in build_metric_tool_specs(model)]
|
|
131
|
+
server: Server = Server(
|
|
132
|
+
name or model.semantic_model.name, instructions=model_instructions(model) or None
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
@server.list_tools()
|
|
136
|
+
async def list_tools() -> list[types.Tool]:
|
|
137
|
+
return tools
|
|
138
|
+
|
|
139
|
+
@server.call_tool()
|
|
140
|
+
async def call_tool(name: str, arguments: dict) -> dict:
|
|
141
|
+
if not name.startswith("query_"):
|
|
142
|
+
raise ValueError(f"unknown tool {name!r}")
|
|
143
|
+
metric = name.removeprefix("query_")
|
|
144
|
+
if metric not in model.metrics:
|
|
145
|
+
raise ValueError(f"unknown metric {metric!r}")
|
|
146
|
+
# `execute` runs a blocking DB-API call (DuckDB/Snowflake) - offload it so it
|
|
147
|
+
# doesn't stall the event loop driving the MCP session (matters most for the
|
|
148
|
+
# HTTP transport, which - unlike a normal FastAPI route - isn't already
|
|
149
|
+
# running inside FastAPI's own sync-endpoint threadpool).
|
|
150
|
+
return await anyio.to_thread.run_sync(
|
|
151
|
+
execute,
|
|
152
|
+
metric,
|
|
153
|
+
arguments.get("group_by") or None,
|
|
154
|
+
arguments.get("time_grain"),
|
|
155
|
+
arguments.get("time_field"),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return server
|
lexis/parser.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Parse and validate Ossie YAML/JSON documents into OssieDocument objects."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
from lexis._vendor.ossie import OssieDocument
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_ossie_yaml(text: str) -> OssieDocument:
|
|
11
|
+
"""Parse Ossie YAML source text into a validated OssieDocument."""
|
|
12
|
+
data = yaml.safe_load(text)
|
|
13
|
+
return OssieDocument.model_validate(data)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_ossie_document(path: str | Path) -> OssieDocument:
|
|
17
|
+
"""Load and validate an Ossie document from a YAML file on disk."""
|
|
18
|
+
text = Path(path).read_text()
|
|
19
|
+
return parse_ossie_yaml(text)
|
lexis/resolved_model.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Index an OssieSemanticModel and resolve join paths / dialect expressions."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections import deque
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from lexis._vendor.ossie import (
|
|
8
|
+
OssieDataset,
|
|
9
|
+
OssieDialect,
|
|
10
|
+
OssieExpression,
|
|
11
|
+
OssieMetric,
|
|
12
|
+
OssieRelationship,
|
|
13
|
+
OssieSemanticModel,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
_QUALIFIED_REF_RE = re.compile(r"\b([A-Za-z_][A-Za-z0-9_]*)\.[A-Za-z_][A-Za-z0-9_]*\b")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class UnresolvedJoinError(ValueError):
|
|
20
|
+
"""Raised when a set of referenced datasets can't be connected via relationships."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MissingExpressionError(ValueError):
|
|
24
|
+
"""Raised when neither the requested dialect nor ANSI_SQL has an expression."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ResolvedModel:
|
|
29
|
+
"""Indexed view of an OssieSemanticModel: fast lookups + join-graph resolution."""
|
|
30
|
+
|
|
31
|
+
semantic_model: OssieSemanticModel
|
|
32
|
+
datasets: dict[str, OssieDataset] = field(default_factory=dict)
|
|
33
|
+
metrics: dict[str, OssieMetric] = field(default_factory=dict)
|
|
34
|
+
relationships: list[OssieRelationship] = field(default_factory=list)
|
|
35
|
+
_adjacency: dict[str, list[tuple[str, OssieRelationship]]] = field(default_factory=dict)
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def build(cls, semantic_model: OssieSemanticModel) -> "ResolvedModel":
|
|
39
|
+
model = cls(semantic_model=semantic_model)
|
|
40
|
+
for dataset in semantic_model.datasets:
|
|
41
|
+
model.datasets[dataset.name] = dataset
|
|
42
|
+
for metric in semantic_model.metrics or []:
|
|
43
|
+
model.metrics[metric.name] = metric
|
|
44
|
+
model.relationships = list(semantic_model.relationships or [])
|
|
45
|
+
|
|
46
|
+
adjacency: dict[str, list[tuple[str, OssieRelationship]]] = {
|
|
47
|
+
name: [] for name in model.datasets
|
|
48
|
+
}
|
|
49
|
+
for rel in model.relationships:
|
|
50
|
+
adjacency.setdefault(rel.from_dataset, []).append((rel.to, rel))
|
|
51
|
+
adjacency.setdefault(rel.to, []).append((rel.from_dataset, rel))
|
|
52
|
+
model._adjacency = adjacency
|
|
53
|
+
return model
|
|
54
|
+
|
|
55
|
+
def referenced_datasets(self, expression: str) -> list[str]:
|
|
56
|
+
"""Datasets referenced as `dataset.column` qualifiers, in first-occurrence order."""
|
|
57
|
+
seen: dict[str, None] = {}
|
|
58
|
+
for match in _QUALIFIED_REF_RE.findall(expression):
|
|
59
|
+
if match in self.datasets:
|
|
60
|
+
seen[match] = None
|
|
61
|
+
return list(seen)
|
|
62
|
+
|
|
63
|
+
def join_path(self, dataset_names: list[str] | set[str]) -> list[OssieRelationship]:
|
|
64
|
+
"""Relationships connecting the given datasets: union of shortest paths from
|
|
65
|
+
the root to each target dataset (not a full BFS spanning tree — irrelevant
|
|
66
|
+
datasets that merely happen to be closer to the root than a target are not
|
|
67
|
+
pulled in).
|
|
68
|
+
|
|
69
|
+
The first element of `dataset_names` (order preserved for lists; arbitrary for
|
|
70
|
+
sets) is used as the root, e.g. the base table in a generated FROM clause.
|
|
71
|
+
"""
|
|
72
|
+
ordered = list(dict.fromkeys(dataset_names))
|
|
73
|
+
if not ordered:
|
|
74
|
+
return []
|
|
75
|
+
for name in ordered:
|
|
76
|
+
if name not in self.datasets:
|
|
77
|
+
raise UnresolvedJoinError(f"Unknown dataset: {name!r}")
|
|
78
|
+
|
|
79
|
+
root = ordered[0]
|
|
80
|
+
targets = ordered[1:]
|
|
81
|
+
if not targets:
|
|
82
|
+
return []
|
|
83
|
+
|
|
84
|
+
# BFS from root, recording the edge/predecessor used to first reach each node.
|
|
85
|
+
predecessor_edge: dict[str, OssieRelationship] = {}
|
|
86
|
+
predecessor_node: dict[str, str] = {}
|
|
87
|
+
visited = {root}
|
|
88
|
+
queue: deque[str] = deque([root])
|
|
89
|
+
while queue:
|
|
90
|
+
current = queue.popleft()
|
|
91
|
+
for neighbor, rel in self._adjacency.get(current, []):
|
|
92
|
+
if neighbor in visited:
|
|
93
|
+
continue
|
|
94
|
+
visited.add(neighbor)
|
|
95
|
+
predecessor_edge[neighbor] = rel
|
|
96
|
+
predecessor_node[neighbor] = current
|
|
97
|
+
queue.append(neighbor)
|
|
98
|
+
|
|
99
|
+
missing = [t for t in targets if t not in visited]
|
|
100
|
+
if missing:
|
|
101
|
+
raise UnresolvedJoinError(
|
|
102
|
+
f"No relationship path connects dataset(s) {missing} to {root!r}"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Union the shortest root->target path edges, in root-to-leaf order, dedup'd.
|
|
106
|
+
edges: list[OssieRelationship] = []
|
|
107
|
+
added_nodes = {root}
|
|
108
|
+
for target in targets:
|
|
109
|
+
path_nodes: list[str] = []
|
|
110
|
+
node = target
|
|
111
|
+
while node not in added_nodes:
|
|
112
|
+
path_nodes.append(node)
|
|
113
|
+
node = predecessor_node[node]
|
|
114
|
+
for n in reversed(path_nodes):
|
|
115
|
+
edges.append(predecessor_edge[n])
|
|
116
|
+
added_nodes.add(n)
|
|
117
|
+
return edges
|
|
118
|
+
|
|
119
|
+
def resolve_expression(self, expression: OssieExpression, dialect: OssieDialect) -> str:
|
|
120
|
+
"""Pick the expression text for `dialect`, falling back to ANSI_SQL."""
|
|
121
|
+
by_dialect = {d.dialect: d.expression for d in expression.dialects}
|
|
122
|
+
if dialect in by_dialect:
|
|
123
|
+
return by_dialect[dialect]
|
|
124
|
+
if OssieDialect.ANSI_SQL in by_dialect:
|
|
125
|
+
return by_dialect[OssieDialect.ANSI_SQL]
|
|
126
|
+
raise MissingExpressionError(
|
|
127
|
+
f"No expression for dialect {dialect!r} or ANSI_SQL fallback"
|
|
128
|
+
)
|