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/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Lexis: Ossie-native semantic layer transpiler."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
# The installed package's real version - set from the git tag at release time
|
|
7
|
+
# (see .github/workflows/publish.yml), from pyproject.toml otherwise.
|
|
8
|
+
__version__ = version("lexis-cli")
|
|
9
|
+
except PackageNotFoundError: # a source tree that was never installed
|
|
10
|
+
__version__ = "0.0.0.dev0"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Vendored from https://github.com/apache/ossie
|
|
2
|
+
Path: python/src/ossie
|
|
3
|
+
Commit: 89bb502bf8e00567c58c439f573bb869a3621a90
|
|
4
|
+
License: Apache License 2.0 (c) The Apache Software Foundation
|
|
5
|
+
|
|
6
|
+
This code is vendored (not installed as a dependency) because `apache-ossie`
|
|
7
|
+
is not yet published to PyPI as of this writing. Once it is published,
|
|
8
|
+
replace this vendored copy with a real `apache-ossie` dependency in
|
|
9
|
+
pyproject.toml and delete this directory.
|
|
10
|
+
|
|
11
|
+
Kept in sync with the `third_party/ossie` git submodule - after bumping it
|
|
12
|
+
(`git submodule update --remote third_party/ossie`), run
|
|
13
|
+
`scripts/sync_ossie_vendor.sh` to re-vendor models.py and refresh this file's
|
|
14
|
+
commit pin.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Vendored copy of the Apache Ossie reference Python types.
|
|
2
|
+
|
|
3
|
+
Source: https://github.com/apache/ossie
|
|
4
|
+
python/src/ossie (commit ddb19f1b135a61c65603f4823a3526e2fab00cf1)
|
|
5
|
+
License: Apache-2.0 (c) The Apache Software Foundation
|
|
6
|
+
Vendored because `apache-ossie` is not yet published to PyPI. Replace this
|
|
7
|
+
package with the real dependency once it is published.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .models import (
|
|
11
|
+
OssieAIContext,
|
|
12
|
+
OssieAIContextObject,
|
|
13
|
+
OssieCustomExtension,
|
|
14
|
+
OssieDataset,
|
|
15
|
+
OssieDataType,
|
|
16
|
+
OssieDialect,
|
|
17
|
+
OssieDialectExpression,
|
|
18
|
+
OssieDimension,
|
|
19
|
+
OssieDocument,
|
|
20
|
+
OssieExpression,
|
|
21
|
+
OssieField,
|
|
22
|
+
OssieMetric,
|
|
23
|
+
OssieRelationship,
|
|
24
|
+
OssieSemanticModel,
|
|
25
|
+
OssieVendor,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"OssieAIContext",
|
|
30
|
+
"OssieAIContextObject",
|
|
31
|
+
"OssieCustomExtension",
|
|
32
|
+
"OssieDataset",
|
|
33
|
+
"OssieDataType",
|
|
34
|
+
"OssieDialect",
|
|
35
|
+
"OssieDialectExpression",
|
|
36
|
+
"OssieDimension",
|
|
37
|
+
"OssieDocument",
|
|
38
|
+
"OssieExpression",
|
|
39
|
+
"OssieField",
|
|
40
|
+
"OssieMetric",
|
|
41
|
+
"OssieRelationship",
|
|
42
|
+
"OssieSemanticModel",
|
|
43
|
+
"OssieVendor",
|
|
44
|
+
]
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# Licensed to the Apache Software Foundation (ASF) under one
|
|
2
|
+
# or more contributor license agreements. See the NOTICE file
|
|
3
|
+
# distributed with this work for additional information
|
|
4
|
+
# regarding copyright ownership. The ASF licenses this file
|
|
5
|
+
# to you under the Apache License, Version 2.0 (the
|
|
6
|
+
# "License"); you may not use this file except in compliance
|
|
7
|
+
# with the License. You may obtain a copy of the License at
|
|
8
|
+
#
|
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
#
|
|
11
|
+
# Unless required by applicable law or agreed to in writing,
|
|
12
|
+
# software distributed under the License is distributed on an
|
|
13
|
+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
14
|
+
# KIND, either express or implied. See the License for the
|
|
15
|
+
# specific language governing permissions and limitations
|
|
16
|
+
# under the License.
|
|
17
|
+
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from typing import Any, Optional, Union
|
|
20
|
+
|
|
21
|
+
import yaml
|
|
22
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class OssieDialect(str, Enum):
|
|
26
|
+
"""Supported SQL and expression language dialects."""
|
|
27
|
+
|
|
28
|
+
ANSI_SQL = "ANSI_SQL"
|
|
29
|
+
SNOWFLAKE = "SNOWFLAKE"
|
|
30
|
+
MDX = "MDX"
|
|
31
|
+
MAQL = "MAQL"
|
|
32
|
+
TABLEAU = "TABLEAU"
|
|
33
|
+
DATABRICKS = "DATABRICKS"
|
|
34
|
+
BIGQUERY = "BIGQUERY"
|
|
35
|
+
THOUGHTSPOT = "THOUGHTSPOT"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class OssieDataType(str, Enum):
|
|
39
|
+
"""Portable logical data types for fields and metric results."""
|
|
40
|
+
|
|
41
|
+
STRING = "String"
|
|
42
|
+
INTEGER = "Integer"
|
|
43
|
+
DECIMAL = "Decimal"
|
|
44
|
+
FLOAT = "Float"
|
|
45
|
+
BOOLEAN = "Boolean"
|
|
46
|
+
DATE = "Date"
|
|
47
|
+
TIME = "Time"
|
|
48
|
+
DATE_TIME = "DateTime"
|
|
49
|
+
DATE_TIME_TZ = "DateTimeTz"
|
|
50
|
+
OPAQUE = "Opaque"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_TEMPORAL_DATA_TYPES = frozenset(
|
|
54
|
+
{
|
|
55
|
+
OssieDataType.DATE,
|
|
56
|
+
OssieDataType.TIME,
|
|
57
|
+
OssieDataType.DATE_TIME,
|
|
58
|
+
OssieDataType.DATE_TIME_TZ,
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class OssieVendor(str, Enum):
|
|
64
|
+
"""Well-known vendor names for custom extensions."""
|
|
65
|
+
|
|
66
|
+
COMMON = "COMMON"
|
|
67
|
+
SNOWFLAKE = "SNOWFLAKE"
|
|
68
|
+
SALESFORCE = "SALESFORCE"
|
|
69
|
+
DBT = "DBT"
|
|
70
|
+
DATABRICKS = "DATABRICKS"
|
|
71
|
+
GOODDATA = "GOODDATA"
|
|
72
|
+
SEMANTIDO = "SEMANTIDO"
|
|
73
|
+
WISDOM = "WISDOM"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class OssieAIContextObject(BaseModel):
|
|
77
|
+
"""Structured AI context with instructions, synonyms, and examples."""
|
|
78
|
+
|
|
79
|
+
model_config = ConfigDict(frozen=True, extra="allow")
|
|
80
|
+
|
|
81
|
+
instructions: Optional[str] = None
|
|
82
|
+
synonyms: Optional[tuple[str, ...]] = None
|
|
83
|
+
examples: Optional[tuple[str, ...]] = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
OssieAIContext = Union[str, OssieAIContextObject]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class OssieCustomExtension(BaseModel):
|
|
90
|
+
"""Vendor-specific metadata as a serialized JSON string."""
|
|
91
|
+
|
|
92
|
+
model_config = ConfigDict(frozen=True)
|
|
93
|
+
|
|
94
|
+
vendor_name: str
|
|
95
|
+
data: str
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class OssieDialectExpression(BaseModel):
|
|
99
|
+
"""Expression in a specific dialect."""
|
|
100
|
+
|
|
101
|
+
model_config = ConfigDict(frozen=True)
|
|
102
|
+
|
|
103
|
+
dialect: OssieDialect
|
|
104
|
+
expression: str
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class OssieExpression(BaseModel):
|
|
108
|
+
"""Expression definition with multi-dialect support."""
|
|
109
|
+
|
|
110
|
+
model_config = ConfigDict(frozen=True)
|
|
111
|
+
|
|
112
|
+
dialects: list[OssieDialectExpression]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class OssieDimension(BaseModel):
|
|
116
|
+
"""Dimension metadata on a field."""
|
|
117
|
+
|
|
118
|
+
model_config = ConfigDict(frozen=True)
|
|
119
|
+
|
|
120
|
+
is_time: Optional[bool] = None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class OssieField(BaseModel):
|
|
124
|
+
"""Row-level attribute for grouping, filtering, and metric expressions."""
|
|
125
|
+
|
|
126
|
+
model_config = ConfigDict(frozen=True)
|
|
127
|
+
|
|
128
|
+
name: str
|
|
129
|
+
expression: OssieExpression
|
|
130
|
+
dimension: Optional[OssieDimension] = None
|
|
131
|
+
label: Optional[str] = None
|
|
132
|
+
description: Optional[str] = None
|
|
133
|
+
datatype: Optional[OssieDataType] = None
|
|
134
|
+
ai_context: Optional[OssieAIContext] = None
|
|
135
|
+
custom_extensions: Optional[list[OssieCustomExtension]] = None
|
|
136
|
+
|
|
137
|
+
def is_time_dimension(self) -> bool:
|
|
138
|
+
"""Return the field's effective temporal-dimension role.
|
|
139
|
+
|
|
140
|
+
A field must have dimension metadata to be a dimension. Within that
|
|
141
|
+
block, an explicit ``is_time`` value takes precedence; otherwise the
|
|
142
|
+
role defaults from a temporal ``datatype``.
|
|
143
|
+
"""
|
|
144
|
+
if self.dimension is None:
|
|
145
|
+
return False
|
|
146
|
+
if self.dimension.is_time is not None:
|
|
147
|
+
return self.dimension.is_time
|
|
148
|
+
return self.datatype in _TEMPORAL_DATA_TYPES
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class OssieDataset(BaseModel):
|
|
152
|
+
"""Logical dataset representing a business entity (fact or dimension table)."""
|
|
153
|
+
|
|
154
|
+
model_config = ConfigDict(frozen=True)
|
|
155
|
+
|
|
156
|
+
name: str
|
|
157
|
+
source: str
|
|
158
|
+
primary_key: Optional[list[str]] = None
|
|
159
|
+
unique_keys: Optional[list[list[str]]] = None
|
|
160
|
+
description: Optional[str] = None
|
|
161
|
+
ai_context: Optional[OssieAIContext] = None
|
|
162
|
+
fields: Optional[list[OssieField]] = None
|
|
163
|
+
custom_extensions: Optional[list[OssieCustomExtension]] = None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class OssieRelationship(BaseModel):
|
|
167
|
+
"""Foreign key relationship between datasets."""
|
|
168
|
+
|
|
169
|
+
model_config = ConfigDict(frozen=True, populate_by_name=True)
|
|
170
|
+
|
|
171
|
+
name: str
|
|
172
|
+
from_dataset: str = Field(..., alias="from")
|
|
173
|
+
to: str
|
|
174
|
+
from_columns: list[str]
|
|
175
|
+
to_columns: list[str]
|
|
176
|
+
ai_context: Optional[OssieAIContext] = None
|
|
177
|
+
custom_extensions: Optional[list[OssieCustomExtension]] = None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class OssieMetric(BaseModel):
|
|
181
|
+
"""Quantitative measure defined on business data."""
|
|
182
|
+
|
|
183
|
+
model_config = ConfigDict(frozen=True)
|
|
184
|
+
|
|
185
|
+
name: str
|
|
186
|
+
expression: OssieExpression
|
|
187
|
+
description: Optional[str] = None
|
|
188
|
+
datatype: Optional[OssieDataType] = None
|
|
189
|
+
ai_context: Optional[OssieAIContext] = None
|
|
190
|
+
custom_extensions: Optional[list[OssieCustomExtension]] = None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class OssieSemanticModel(BaseModel):
|
|
194
|
+
"""Top-level container representing a complete semantic model."""
|
|
195
|
+
|
|
196
|
+
model_config = ConfigDict(frozen=True)
|
|
197
|
+
|
|
198
|
+
name: str
|
|
199
|
+
description: Optional[str] = None
|
|
200
|
+
ai_context: Optional[OssieAIContext] = None
|
|
201
|
+
datasets: list[OssieDataset]
|
|
202
|
+
relationships: Optional[list[OssieRelationship]] = None
|
|
203
|
+
metrics: Optional[list[OssieMetric]] = None
|
|
204
|
+
custom_extensions: Optional[list[OssieCustomExtension]] = None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class OssieDocument(BaseModel):
|
|
208
|
+
"""Root Ossie document."""
|
|
209
|
+
|
|
210
|
+
model_config = ConfigDict(frozen=True)
|
|
211
|
+
|
|
212
|
+
version: str = "0.2.0.dev0"
|
|
213
|
+
dialects: Optional[list[OssieDialect]] = None
|
|
214
|
+
vendors: Optional[list[OssieVendor]] = None
|
|
215
|
+
semantic_model: list[OssieSemanticModel]
|
|
216
|
+
|
|
217
|
+
def to_ossie_yaml(self, **kwargs: Any) -> str:
|
|
218
|
+
"""Serialize to Ossie-compliant YAML (uses field aliases and excludes None values)."""
|
|
219
|
+
data = self.model_dump(by_alias=True, exclude_none=True, mode="json", **kwargs)
|
|
220
|
+
return yaml.dump(data, default_flow_style=False, sort_keys=False, allow_unicode=True)
|
|
221
|
+
|
|
222
|
+
def to_ossie_json(self, **kwargs: Any) -> str:
|
|
223
|
+
"""Serialize to Ossie-compliant JSON (uses field aliases and excludes None values)."""
|
|
224
|
+
return self.model_dump_json(by_alias=True, exclude_none=True, **kwargs)
|
lexis/cli.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Lexis CLI: `lexis transpile <model.yaml> --target <target> ...`"""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from lexis.dispatch import TARGET_ALIASES, TARGETS
|
|
11
|
+
from lexis.dispatch import transpile as dispatch_transpile
|
|
12
|
+
from lexis.parser import load_ossie_document
|
|
13
|
+
from lexis.resolved_model import ResolvedModel
|
|
14
|
+
|
|
15
|
+
_SAFE_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@click.group()
|
|
19
|
+
def main() -> None:
|
|
20
|
+
"""Lexis: transpile Ossie semantic models to warehouse SQL and BI/AI formats."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@main.command()
|
|
24
|
+
@click.argument("model_path", type=click.Path(exists=True, dir_okay=False))
|
|
25
|
+
@click.option("--target", type=click.Choice([*TARGETS, *TARGET_ALIASES]), required=True)
|
|
26
|
+
@click.option("--metric", help="Metric name (required for SQL targets)")
|
|
27
|
+
@click.option(
|
|
28
|
+
"--group-by",
|
|
29
|
+
multiple=True,
|
|
30
|
+
metavar="DATASET.FIELD",
|
|
31
|
+
help="Field to group by, e.g. item.i_category (SQL targets only, repeatable)",
|
|
32
|
+
)
|
|
33
|
+
@click.option(
|
|
34
|
+
"--out",
|
|
35
|
+
type=click.Path(),
|
|
36
|
+
help="Write output to a file (single-file targets) or a directory (multi-file "
|
|
37
|
+
"targets, e.g. sml) instead of stdout",
|
|
38
|
+
)
|
|
39
|
+
def transpile(model_path: str, target: str, metric: str | None, group_by: tuple[str, ...], out: str | None) -> None:
|
|
40
|
+
"""Parse an Ossie model and emit it in the given TARGET format."""
|
|
41
|
+
document = load_ossie_document(model_path)
|
|
42
|
+
semantic_model = document.semantic_model[0]
|
|
43
|
+
model = ResolvedModel.build(semantic_model)
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
result = dispatch_transpile(document, model, target, metric, list(group_by) or None)
|
|
47
|
+
except ValueError as exc:
|
|
48
|
+
raise click.UsageError(str(exc))
|
|
49
|
+
|
|
50
|
+
for warning in result.warnings:
|
|
51
|
+
click.echo(f"warning: {warning}", err=True)
|
|
52
|
+
|
|
53
|
+
if isinstance(result.content, dict):
|
|
54
|
+
if out:
|
|
55
|
+
out_dir = Path(out)
|
|
56
|
+
for filename, content in result.content.items():
|
|
57
|
+
path = out_dir / filename
|
|
58
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
path.write_text(content)
|
|
60
|
+
click.echo(f"Wrote {len(result.content)} file(s) to {out_dir}/", err=True)
|
|
61
|
+
else:
|
|
62
|
+
for filename, content in result.content.items():
|
|
63
|
+
click.echo(f"# --- {filename} ---")
|
|
64
|
+
click.echo(content)
|
|
65
|
+
elif out:
|
|
66
|
+
Path(out).write_text(result.content)
|
|
67
|
+
click.echo(f"Wrote {out}", err=True)
|
|
68
|
+
else:
|
|
69
|
+
click.echo(result.content)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@main.group("import")
|
|
73
|
+
def import_group() -> None:
|
|
74
|
+
"""Import a third-party semantic model format into an Ossie document."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@import_group.command("sml")
|
|
78
|
+
@click.argument("repo_dir", type=click.Path(exists=True, file_okay=False))
|
|
79
|
+
@click.option("--out", type=click.Path(), required=True, help="Path to write the resulting Ossie YAML document")
|
|
80
|
+
def import_sml(repo_dir: str, out: str) -> None:
|
|
81
|
+
"""Parse an SML repo directory (REPO_DIR) into an Ossie YAML document."""
|
|
82
|
+
from lexis.sml._common import ConversionError
|
|
83
|
+
from lexis.sml.parse import parse_sml_repo
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
result = parse_sml_repo(repo_dir)
|
|
87
|
+
except ConversionError as exc:
|
|
88
|
+
raise click.UsageError(str(exc))
|
|
89
|
+
|
|
90
|
+
for warning in result.warnings:
|
|
91
|
+
click.echo(f"warning: {warning}", err=True)
|
|
92
|
+
|
|
93
|
+
Path(out).write_text(result.document.to_ossie_yaml())
|
|
94
|
+
click.echo(f"Wrote {out}", err=True)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@main.command("export-demo-dataset")
|
|
98
|
+
@click.option("--out", type=click.Path(dir_okay=False), required=True, help="Path to write the .duckdb file")
|
|
99
|
+
@click.option(
|
|
100
|
+
"--dataset",
|
|
101
|
+
type=click.Choice(["tpcds", "retail"]),
|
|
102
|
+
default="tpcds",
|
|
103
|
+
show_default=True,
|
|
104
|
+
help="Which bundled demo dataset to export: the small TPC-DS fixture, or the "
|
|
105
|
+
"larger retail analytics dataset (10,000 sales facts + returns)",
|
|
106
|
+
)
|
|
107
|
+
@click.option("--force", is_flag=True, help="Overwrite --out if it already exists")
|
|
108
|
+
def export_demo_dataset_cmd(out: str, dataset: str, force: bool) -> None:
|
|
109
|
+
"""Write a bundled demo dataset - the same data the web UI's "Demo dataset" run
|
|
110
|
+
mode uses - to a real .duckdb file, so it can be re-uploaded (Run tab's Upload
|
|
111
|
+
mode) or registered as a duckdb_file connection."""
|
|
112
|
+
try:
|
|
113
|
+
from lexis.demo_data import export_demo_dataset
|
|
114
|
+
from lexis.retail_demo_data import export_retail_demo_dataset
|
|
115
|
+
except ImportError as exc:
|
|
116
|
+
raise click.UsageError(
|
|
117
|
+
'exporting the demo dataset requires duckdb - install with `pip install "lexis-cli[mcp]"`'
|
|
118
|
+
) from exc
|
|
119
|
+
|
|
120
|
+
exporter = export_retail_demo_dataset if dataset == "retail" else export_demo_dataset
|
|
121
|
+
try:
|
|
122
|
+
exporter(out, overwrite=force)
|
|
123
|
+
except FileExistsError as exc:
|
|
124
|
+
raise click.UsageError(f"{exc} (pass --force to overwrite)")
|
|
125
|
+
|
|
126
|
+
click.echo(f"Wrote {out}", err=True)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@contextmanager
|
|
130
|
+
def _demo_connection(model: ResolvedModel):
|
|
131
|
+
try:
|
|
132
|
+
from lexis.demo_data import build_tpcds_demo_connection
|
|
133
|
+
from lexis.retail_demo_data import build_retail_demo_connection
|
|
134
|
+
except ImportError as exc:
|
|
135
|
+
raise click.UsageError(
|
|
136
|
+
'--demo requires duckdb - install with `pip install "lexis-cli[mcp]"`'
|
|
137
|
+
) from exc
|
|
138
|
+
|
|
139
|
+
# Pick the bundled demo whose fixture schema matches the model's `source`
|
|
140
|
+
# catalog. Unlike the API's `/run` endpoint (which checks demo-compatibility
|
|
141
|
+
# per request, for just the one metric being queried), we don't know which
|
|
142
|
+
# metric will be called until a tool call arrives, so we can't reject an
|
|
143
|
+
# individual incompatible metric up front - let it fail naturally with DuckDB's
|
|
144
|
+
# own "table/catalog not found" error when it's actually invoked.
|
|
145
|
+
catalogs = {ds.source.split(".", 1)[0] for ds in model.datasets.values()}
|
|
146
|
+
builder = build_retail_demo_connection if catalogs == {"retail"} else build_tpcds_demo_connection
|
|
147
|
+
|
|
148
|
+
con = builder()
|
|
149
|
+
try:
|
|
150
|
+
yield con
|
|
151
|
+
finally:
|
|
152
|
+
con.close()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@contextmanager
|
|
156
|
+
def _duckdb_file_connection(model: ResolvedModel, path: str):
|
|
157
|
+
try:
|
|
158
|
+
import duckdb
|
|
159
|
+
except ImportError as exc:
|
|
160
|
+
raise click.UsageError(
|
|
161
|
+
'--duckdb-file requires duckdb - install with `pip install "lexis-cli[mcp]"`'
|
|
162
|
+
) from exc
|
|
163
|
+
|
|
164
|
+
catalogs = {ds.source.split(".", 1)[0] for ds in model.datasets.values()}
|
|
165
|
+
if len(catalogs) != 1:
|
|
166
|
+
raise click.UsageError(
|
|
167
|
+
"--duckdb-file requires every dataset in the model to share one catalog name "
|
|
168
|
+
f"in its `source`; found {sorted(catalogs)}"
|
|
169
|
+
)
|
|
170
|
+
catalog = catalogs.pop()
|
|
171
|
+
if not _SAFE_IDENTIFIER_RE.match(catalog):
|
|
172
|
+
raise click.UsageError(f"invalid catalog name in source: {catalog!r}")
|
|
173
|
+
|
|
174
|
+
con = duckdb.connect()
|
|
175
|
+
try:
|
|
176
|
+
con.execute(f"ATTACH '{path}' AS {catalog} (READ_ONLY)")
|
|
177
|
+
yield con
|
|
178
|
+
finally:
|
|
179
|
+
con.close()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@contextmanager
|
|
183
|
+
def _snowflake_connection(
|
|
184
|
+
account: str,
|
|
185
|
+
user: str,
|
|
186
|
+
password_env: str,
|
|
187
|
+
warehouse: str | None,
|
|
188
|
+
database: str | None,
|
|
189
|
+
schema: str | None,
|
|
190
|
+
role: str | None,
|
|
191
|
+
):
|
|
192
|
+
try:
|
|
193
|
+
import snowflake.connector
|
|
194
|
+
except ImportError as exc:
|
|
195
|
+
raise click.UsageError(
|
|
196
|
+
'--snowflake-account requires snowflake-connector-python - install with '
|
|
197
|
+
'`pip install "lexis-cli[mcp]"`'
|
|
198
|
+
) from exc
|
|
199
|
+
|
|
200
|
+
password = os.environ.get(password_env)
|
|
201
|
+
if not password:
|
|
202
|
+
raise click.UsageError(f"environment variable {password_env!r} (--snowflake-password-env) is not set")
|
|
203
|
+
|
|
204
|
+
con = snowflake.connector.connect(
|
|
205
|
+
account=account,
|
|
206
|
+
user=user,
|
|
207
|
+
password=password,
|
|
208
|
+
warehouse=warehouse,
|
|
209
|
+
database=database,
|
|
210
|
+
schema=schema,
|
|
211
|
+
role=role,
|
|
212
|
+
)
|
|
213
|
+
try:
|
|
214
|
+
yield con
|
|
215
|
+
finally:
|
|
216
|
+
con.close()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@main.command("mcp-serve")
|
|
220
|
+
@click.argument("model_path", type=click.Path(exists=True, dir_okay=False))
|
|
221
|
+
@click.option("--demo", is_flag=True, help="Run against the bundled TPC-DS demo dataset")
|
|
222
|
+
@click.option("--duckdb-file", type=click.Path(exists=True, dir_okay=False), help="Run against a local .duckdb file")
|
|
223
|
+
@click.option("--snowflake-account", help="Run against Snowflake (requires --snowflake-user/-password-env)")
|
|
224
|
+
@click.option("--snowflake-user")
|
|
225
|
+
@click.option("--snowflake-password-env", help="Env var holding the Snowflake password")
|
|
226
|
+
@click.option("--snowflake-warehouse")
|
|
227
|
+
@click.option("--snowflake-database")
|
|
228
|
+
@click.option("--snowflake-schema")
|
|
229
|
+
@click.option("--snowflake-role")
|
|
230
|
+
def mcp_serve(
|
|
231
|
+
model_path: str,
|
|
232
|
+
demo: bool,
|
|
233
|
+
duckdb_file: str | None,
|
|
234
|
+
snowflake_account: str | None,
|
|
235
|
+
snowflake_user: str | None,
|
|
236
|
+
snowflake_password_env: str | None,
|
|
237
|
+
snowflake_warehouse: str | None,
|
|
238
|
+
snowflake_database: str | None,
|
|
239
|
+
snowflake_schema: str | None,
|
|
240
|
+
snowflake_role: str | None,
|
|
241
|
+
) -> None:
|
|
242
|
+
"""Serve MODEL's metrics as live MCP tools over stdio (e.g. for Claude Desktop) -
|
|
243
|
+
one `query_<metric>` tool per metric, executed against the demo dataset, a local
|
|
244
|
+
DuckDB file, or Snowflake."""
|
|
245
|
+
try:
|
|
246
|
+
import anyio
|
|
247
|
+
from mcp.server.stdio import stdio_server
|
|
248
|
+
except ImportError as exc:
|
|
249
|
+
raise click.UsageError(
|
|
250
|
+
'mcp-serve requires the `mcp` package - install with `pip install "lexis-cli[mcp]"`'
|
|
251
|
+
) from exc
|
|
252
|
+
|
|
253
|
+
if sum(bool(x) for x in (demo, duckdb_file, snowflake_account)) != 1:
|
|
254
|
+
raise click.UsageError("pass exactly one of --demo, --duckdb-file, or --snowflake-account")
|
|
255
|
+
|
|
256
|
+
document = load_ossie_document(model_path)
|
|
257
|
+
model = ResolvedModel.build(document.semantic_model[0])
|
|
258
|
+
|
|
259
|
+
from lexis import mcp_server as mcp_server_module
|
|
260
|
+
from lexis.transpilers.sql import DuckDBEmitter, SnowflakeEmitter
|
|
261
|
+
|
|
262
|
+
if demo:
|
|
263
|
+
con_cm, emitter = _demo_connection(model), DuckDBEmitter()
|
|
264
|
+
elif duckdb_file:
|
|
265
|
+
con_cm, emitter = _duckdb_file_connection(model, duckdb_file), DuckDBEmitter()
|
|
266
|
+
else:
|
|
267
|
+
if not (snowflake_user and snowflake_password_env):
|
|
268
|
+
raise click.UsageError("--snowflake-account requires --snowflake-user and --snowflake-password-env")
|
|
269
|
+
con_cm = _snowflake_connection(
|
|
270
|
+
snowflake_account,
|
|
271
|
+
snowflake_user,
|
|
272
|
+
snowflake_password_env,
|
|
273
|
+
snowflake_warehouse,
|
|
274
|
+
snowflake_database,
|
|
275
|
+
snowflake_schema,
|
|
276
|
+
snowflake_role,
|
|
277
|
+
)
|
|
278
|
+
emitter = SnowflakeEmitter()
|
|
279
|
+
|
|
280
|
+
with con_cm as con:
|
|
281
|
+
|
|
282
|
+
def execute(
|
|
283
|
+
metric: str,
|
|
284
|
+
group_by: list[str] | None,
|
|
285
|
+
time_grain: str | None = None,
|
|
286
|
+
time_field: str | None = None,
|
|
287
|
+
) -> dict:
|
|
288
|
+
return mcp_server_module.run_metric_or_timeseries(
|
|
289
|
+
con, emitter, model, metric, group_by, time_grain, time_field
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
server = mcp_server_module.build_server(model, execute)
|
|
293
|
+
|
|
294
|
+
async def _run() -> None:
|
|
295
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
296
|
+
await server.run(read_stream, write_stream, server.create_initialization_options())
|
|
297
|
+
|
|
298
|
+
anyio.run(_run)
|
lexis/demo_data.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Bundled TPC-DS-shaped demo dataset: small enough to build in-memory, real enough
|
|
2
|
+
to exercise the join graph in `tests/fixtures/tpcds_semantic_model.yaml`. Shared by
|
|
3
|
+
the CLI's `export-demo-dataset` command and lexis_api's "Demo dataset" run mode,
|
|
4
|
+
so there's exactly one copy of the CREATE/INSERT statements.
|
|
5
|
+
|
|
6
|
+
Requires the optional `duckdb` dependency - not one of the core `lexis` package's
|
|
7
|
+
own dependencies, since the CLI's `transpile` command doesn't need it. Importing this
|
|
8
|
+
module (rather than just the `lexis` package) is what opts a caller into that
|
|
9
|
+
dependency; see `cli.py`'s lazy import in `export-demo-dataset`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import duckdb
|
|
15
|
+
|
|
16
|
+
TPCDS_DEMO_SOURCES = {
|
|
17
|
+
"tpcds.public.store_sales",
|
|
18
|
+
"tpcds.public.customer",
|
|
19
|
+
"tpcds.public.item",
|
|
20
|
+
"tpcds.public.date_dim",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_tpcds_demo_connection(target: str = ":memory:") -> duckdb.DuckDBPyConnection:
|
|
25
|
+
"""Attaches `target` (an in-memory database by default, or a real file path) under
|
|
26
|
+
catalog `tpcds`, so `tpcds.public.*`-sourced SQL runs against it unmodified - and
|
|
27
|
+
for a file `target`, closing the returned connection leaves that data on disk
|
|
28
|
+
(see `export_demo_dataset`, which is exactly this).
|
|
29
|
+
|
|
30
|
+
The `date_dim`/time-series rows use `ss_item_sk=12`/`ss_customer_sk=102`, which
|
|
31
|
+
deliberately don't match any `item`/`customer` row - so they're silently excluded
|
|
32
|
+
by the INNER JOINs in `test_demo_mode_matches_unit_test_result`'s Books/Electronics
|
|
33
|
+
query, and only show up for metrics that don't join those tables (e.g.
|
|
34
|
+
`total_sales`, grouped/drilled by date_dim's `d_date`).
|
|
35
|
+
"""
|
|
36
|
+
con = duckdb.connect()
|
|
37
|
+
con.execute(f"ATTACH '{target}' AS tpcds")
|
|
38
|
+
con.execute("CREATE SCHEMA tpcds.public")
|
|
39
|
+
con.execute(
|
|
40
|
+
"CREATE TABLE tpcds.public.store_sales ("
|
|
41
|
+
"ss_sold_date_sk INT, ss_item_sk INT, ss_customer_sk INT, ss_store_sk INT, "
|
|
42
|
+
"ss_ext_sales_price DOUBLE, ss_net_profit DOUBLE)"
|
|
43
|
+
)
|
|
44
|
+
con.execute("CREATE TABLE tpcds.public.customer (c_customer_sk INT)")
|
|
45
|
+
con.execute(
|
|
46
|
+
"CREATE TABLE tpcds.public.item ("
|
|
47
|
+
"i_item_sk INT, i_item_id VARCHAR, i_item_desc VARCHAR, i_brand VARCHAR, "
|
|
48
|
+
"i_category VARCHAR, i_current_price DOUBLE)"
|
|
49
|
+
)
|
|
50
|
+
con.execute(
|
|
51
|
+
"CREATE TABLE tpcds.public.date_dim ("
|
|
52
|
+
"d_date_sk INT, d_date DATE, d_year INT, d_quarter_name VARCHAR, d_month_name VARCHAR)"
|
|
53
|
+
)
|
|
54
|
+
con.execute(
|
|
55
|
+
"INSERT INTO tpcds.public.store_sales VALUES "
|
|
56
|
+
"(1,10,100,1000,50.0,5.0),(1,11,101,1000,30.0,3.0),(2,10,100,1001,20.0,2.0),"
|
|
57
|
+
"(3,12,102,1000,40.0,4.0),(4,12,102,1000,60.0,6.0),(5,12,102,1000,25.0,2.5),(6,12,102,1000,35.0,3.5)"
|
|
58
|
+
)
|
|
59
|
+
con.execute("INSERT INTO tpcds.public.customer VALUES (100),(101)")
|
|
60
|
+
con.execute(
|
|
61
|
+
"INSERT INTO tpcds.public.item VALUES "
|
|
62
|
+
"(10,'ITEM-10','Wireless noise-cancelling headphones','SoundWave','Electronics',129.99),"
|
|
63
|
+
"(11,'ITEM-11','Hardcover mystery novel','Northwind Press','Books',14.99)"
|
|
64
|
+
)
|
|
65
|
+
con.execute(
|
|
66
|
+
"INSERT INTO tpcds.public.date_dim VALUES "
|
|
67
|
+
"(1,DATE '2023-01-15',2023,'2023Q1','January'),"
|
|
68
|
+
"(2,DATE '2023-04-20',2023,'2023Q2','April'),"
|
|
69
|
+
"(3,DATE '2023-07-10',2023,'2023Q3','July'),"
|
|
70
|
+
"(4,DATE '2023-10-05',2023,'2023Q4','October'),"
|
|
71
|
+
"(5,DATE '2024-01-18',2024,'2024Q1','January'),"
|
|
72
|
+
"(6,DATE '2024-04-22',2024,'2024Q2','April')"
|
|
73
|
+
)
|
|
74
|
+
return con
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def export_demo_dataset(path: str | Path, *, overwrite: bool = False) -> None:
|
|
78
|
+
"""Write the demo dataset to a real .duckdb file at `path` - the same schema/rows
|
|
79
|
+
`build_tpcds_demo_connection()` builds in-memory, so the exported file can be
|
|
80
|
+
re-uploaded (the web UI's Upload run mode) or registered as a `duckdb_file`
|
|
81
|
+
connection and produce identical query results."""
|
|
82
|
+
path = Path(path)
|
|
83
|
+
if path.exists():
|
|
84
|
+
if not overwrite:
|
|
85
|
+
raise FileExistsError(f"{path} already exists")
|
|
86
|
+
path.unlink()
|
|
87
|
+
build_tpcds_demo_connection(target=str(path)).close()
|