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 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()