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/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)
@@ -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
+ )