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.
@@ -0,0 +1,883 @@
1
+ Metadata-Version: 2.5
2
+ Name: lexis-cli
3
+ Version: 0.4.2
4
+ Summary: Ossie-native semantic layer: author models once, transpile to warehouse-native SQL and BI/AI consumer formats
5
+ Project-URL: Homepage, https://github.com/PuspenduBanerjee/Lexis
6
+ Project-URL: Repository, https://github.com/PuspenduBanerjee/Lexis
7
+ Project-URL: Issues, https://github.com/PuspenduBanerjee/Lexis/issues
8
+ Author: Puspendu Banerjee
9
+ License-Expression: Apache-2.0
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Database
20
+ Classifier: Topic :: Software Development :: Code Generators
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: click>=8.1
23
+ Requires-Dist: pydantic>=2.0
24
+ Requires-Dist: pyyaml>=6.0
25
+ Provides-Extra: api
26
+ Requires-Dist: alembic>=1.13; extra == 'api'
27
+ Requires-Dist: duckdb>=1.0; extra == 'api'
28
+ Requires-Dist: fastapi>=0.115; extra == 'api'
29
+ Requires-Dist: httpx>=0.27; extra == 'api'
30
+ Requires-Dist: mcp<2,>=1.9; extra == 'api'
31
+ Requires-Dist: pydantic-settings>=2.0; extra == 'api'
32
+ Requires-Dist: python-multipart>=0.0.9; extra == 'api'
33
+ Requires-Dist: snowflake-connector-python>=3.12; extra == 'api'
34
+ Requires-Dist: sqlalchemy>=2.0; extra == 'api'
35
+ Requires-Dist: uvicorn[standard]>=0.30; extra == 'api'
36
+ Provides-Extra: dev
37
+ Requires-Dist: duckdb>=1.0; extra == 'dev'
38
+ Requires-Dist: httpx2>=2.0; extra == 'dev'
39
+ Requires-Dist: hypothesis>=6.100; extra == 'dev'
40
+ Requires-Dist: jsonschema>=4.0; extra == 'dev'
41
+ Requires-Dist: pytest>=8.0; extra == 'dev'
42
+ Provides-Extra: mcp
43
+ Requires-Dist: anyio>=4.0; extra == 'mcp'
44
+ Requires-Dist: duckdb>=1.0; extra == 'mcp'
45
+ Requires-Dist: mcp<2,>=1.9; extra == 'mcp'
46
+ Requires-Dist: snowflake-connector-python>=3.12; extra == 'mcp'
47
+ Description-Content-Type: text/markdown
48
+
49
+ # Lexis
50
+
51
+ An open, [Apache Ossie](https://github.com/apache/ossie)-native semantic layer:
52
+ author a data model once in Ossie YAML, then transpile it to warehouse-native SQL
53
+ (Snowflake, BigQuery, Databricks, DuckDB, Postgres) and to formats BI/AI consumers
54
+ understand (Cube.js schema, dbt-core Ossie documents, MCP tool manifests grounded in
55
+ `ai_context`).
56
+
57
+ Two ways to use it: a `lexis` CLI/library, and a web UI (FastAPI + React) with a
58
+ persisted multi-model workspace, role-based access, and live semantic query execution.
59
+
60
+ See [docs/architecture-plan.md](docs/architecture-plan.md) for the full architecture
61
+ writeup and design rationale.
62
+
63
+ This repo tracks the upstream [Ossie spec](https://github.com/apache/ossie)
64
+ as a git submodule at `third_party/ossie` (schema, converters docs, examples) so our
65
+ vendored model classes (`src/lexis/_vendor/ossie/`) can be kept in sync with it -
66
+ see "Keeping Ossie in sync" below.
67
+
68
+ ## Quickstart: Install from PyPI
69
+
70
+ ```bash
71
+ pip install lexis-cli
72
+ ```
73
+
74
+ Save this as `model.yaml`:
75
+
76
+ ```yaml
77
+ version: "0.2.0.dev0"
78
+ semantic_model:
79
+ - name: shop
80
+ datasets:
81
+ - name: orders
82
+ source: analytics.public.orders
83
+ fields:
84
+ - name: amount
85
+ expression:
86
+ dialects:
87
+ - dialect: ANSI_SQL
88
+ expression: amount
89
+ metrics:
90
+ - name: total_revenue
91
+ expression:
92
+ dialects:
93
+ - dialect: ANSI_SQL
94
+ expression: SUM(orders.amount)
95
+ ```
96
+
97
+ Then transpile it:
98
+
99
+ ```bash
100
+ lexis transpile model.yaml --target duckdb --metric total_revenue
101
+ ```
102
+
103
+ ```sql
104
+ SELECT SUM(orders.amount) AS "total_revenue"
105
+ FROM analytics.public.orders AS "orders"
106
+ ```
107
+
108
+ Same command works for every target below — see [Quickstart: CLI (from source)](#quickstart-cli-from-source) for the full target list and the repo's own TPC-DS-based example fixture.
109
+
110
+ ## Quickstart: CLI (from source)
111
+
112
+ Requires Python 3.11+.
113
+
114
+ ```bash
115
+ git submodule update --init # first time only, or after a fresh clone
116
+ pip install -e .
117
+ lexis transpile tests/fixtures/tpcds_semantic_model.yaml --target duckdb --metric total_sales
118
+ ```
119
+
120
+ ```sql
121
+ SELECT SUM(store_sales.ss_ext_sales_price) AS "total_sales"
122
+ FROM tpcds.public.store_sales AS "store_sales"
123
+ ```
124
+
125
+ Other targets: `postgres`, `bigquery`, `databricks`, `snowflake` (all take `--metric`,
126
+ and an optional repeatable `--group-by dataset.field`), plus `cube`, `dbt`, `mcp`, and
127
+ `snowflake_semantic_view` (whole-model outputs, no `--metric` needed):
128
+
129
+ ```bash
130
+ lexis transpile tests/fixtures/tpcds_semantic_model.yaml --target mcp
131
+ lexis transpile tests/fixtures/tpcds_semantic_model.yaml \
132
+ --target duckdb --metric customer_lifetime_value --group-by item.i_category
133
+ ```
134
+
135
+ `snowflake_semantic_view` emits a `CREATE OR REPLACE SEMANTIC VIEW` DDL statement
136
+ (Snowflake's native Cortex Analyst semantic view) with `TABLES`/`RELATIONSHIPS`/
137
+ `FACTS`/`DIMENSIONS`/`METRICS` clauses built from the model's datasets, relationships,
138
+ fields, and metrics — fields with a `dimension` block become `DIMENSIONS`, fields
139
+ without one become `FACTS`, and `ai_context` synonyms/descriptions map to `WITH
140
+ SYNONYMS`/`COMMENT`:
141
+
142
+ ```bash
143
+ lexis transpile tests/fixtures/tpcds_semantic_model.yaml --target snowflake_semantic_view
144
+ ```
145
+
146
+ Add `--out <file>` to write to a file instead of stdout.
147
+
148
+ A bundled demo dataset (the same data the web UI's "Demo dataset" run mode uses
149
+ in-memory) can be exported to a real `.duckdb` file, handy as a seed file for the
150
+ Upload run mode or a `duckdb_file` connection:
151
+
152
+ ```bash
153
+ pip install -e ".[mcp]" # needs the optional duckdb dependency
154
+ lexis export-demo-dataset --out demo.duckdb # small TPC-DS fixture (default)
155
+ lexis export-demo-dataset --dataset retail --out retail.duckdb # 10,000-fact retail analytics dataset
156
+ ```
157
+
158
+ Pass `--force` to overwrite an existing file at `--out`. See
159
+ [The bundled demo datasets](#the-bundled-demo-datasets) below for what each one contains.
160
+
161
+ ## The bundled demo datasets
162
+
163
+ Lexis ships two in-memory demo datasets so any model can be run for real without a
164
+ warehouse. The web UI's and API's **"Demo dataset"** run mode picks whichever one
165
+ matches the model's `source` catalog automatically; the CLI (`export-demo-dataset`)
166
+ selects it with `--dataset`.
167
+
168
+ | `--dataset` | Bundled model | Catalog | Size | Best for |
169
+ |---|---|---|---|---|
170
+ | `tpcds` *(default)* | `tpcds_retail_model` — [`tests/fixtures/tpcds_semantic_model.yaml`](tests/fixtures/tpcds_semantic_model.yaml) | `tpcds.public.*` | 7 `store_sales` rows, 2 items, 2 customers, 6 dates | small deterministic examples, emitter/JOIN behaviour |
171
+ | `retail` | `retail_analytics` — [`src/lexis_api/sample_data/retail_analytics_model.yaml`](src/lexis_api/sample_data/retail_analytics_model.yaml) | `retail.public.*` | **10,000 sales facts** + 1,500 returns, 800 customers, 300 items, 25 stores, 40 promotions, 3 years of dates | realistic analytics — segmentation, seasonality, basket/AOV, promo lift, returns |
172
+
173
+ ### The `retail` dataset
174
+
175
+ A multi-fact star schema: two fact tables sharing five conformed dimensions, all
176
+ generated from a fixed seed ([`src/lexis/retail_demo_data.py`](src/lexis/retail_demo_data.py)),
177
+ so every number is reproducible.
178
+
179
+ | Table | Rows | Notable columns |
180
+ |---|---|---|
181
+ | `fct_store_sales` | 10,000 (one per sold line item; a basket shares `ss_ticket_number`) | `ss_ext_sales_price`, `ss_quantity`, `ss_net_paid`, `ss_ext_wholesale_cost`, `ss_ext_discount_amt` |
182
+ | `fct_store_returns` | 1,500 | `sr_return_amt`, `sr_return_quantity`, `sr_net_loss`, `sr_reason` |
183
+ | `dim_date` | 1,096 (2022-01-01 … 2024-12-31) | `d_date_sk` = `YYYYMMDD`, `d_year`, `d_quarter_name`, `d_month_name`, `d_is_weekend`, `d_holiday_name` |
184
+ | `dim_customer` | 800 | `c_gender`, `c_age_band`, `c_income_band`, `c_education_status`, `c_loyalty_tier`, `c_preferred_channel`, `c_state` |
185
+ | `dim_item` | 300 | `i_category` (10) → `i_class` (6 each) → `i_brand` (40), `i_manufacturer`, `i_color`, `i_size` |
186
+ | `dim_store` | 25 | `s_store_type` (Flagship/Standard/Express/Outlet), `s_number_employees`, `s_floor_space`, `s_division_name` |
187
+ | `dim_promotion` | 41 | `p_channel`, `p_discount_pct`; `p_promo_sk = 0` is the "No Promotion" member |
188
+
189
+ Sales are deliberately skewed toward recent years, Q4, and weekends, so time-series
190
+ and seasonality queries show a real shape. The `retail_analytics` model exposes 17
191
+ metrics over it (`total_revenue`, `gross_margin_pct`, `units_sold`,
192
+ `transaction_count`, `avg_basket_value`, `distinct_customers`, `discount_rate_pct`,
193
+ `sales_per_employee`, `return_amount`, `net_loss_from_returns`, …). No single metric
194
+ spans both fact tables — query sales and returns separately.
195
+
196
+ **From the CLI:**
197
+
198
+ ```bash
199
+ pip install -e ".[mcp]" # needs the optional duckdb dependency
200
+
201
+ # transpile one metric, grouped by a dimension attribute
202
+ lexis transpile src/lexis_api/sample_data/retail_analytics_model.yaml \
203
+ --target duckdb --metric total_revenue --group-by dim_item.i_category
204
+
205
+ # export the data to a real .duckdb file (reuse it in Upload mode or a duckdb_file connection)
206
+ lexis export-demo-dataset --dataset retail --out retail-demo.duckdb
207
+
208
+ # serve the retail model's 17 metrics as live MCP tools against this data
209
+ lexis mcp-serve src/lexis_api/sample_data/retail_analytics_model.yaml --demo
210
+ ```
211
+
212
+ **In the web UI:** `retail_analytics` is preloaded on first run. Open it → **Test
213
+ Metrics** tab → keep **Demo dataset** mode → pick a metric (optionally "Group by" an
214
+ item / store / customer / promotion / date attribute) and **Run**, or switch to
215
+ **Time series** for a year → quarter → month → day drill-down on `dim_date.d_date`.
216
+ The **Export demo dataset (.duckdb)** button downloads exactly this data.
217
+
218
+ **Over the remote MCP endpoint:** the mounted `/mcp` endpoint has no demo mode, so
219
+ register the exported file as a connection first, then point a client at the retail
220
+ model's id:
221
+
222
+ ```bash
223
+ lexis export-demo-dataset --dataset retail --out /tmp/retail-demo.duckdb --force
224
+ curl -X POST http://localhost:8000/api/connections \
225
+ -H "X-Account-Id: 2" -H "Content-Type: application/json" \
226
+ -d '{"name":"retail-demo","type":"duckdb_file","config":{"path":"/tmp/retail-demo.duckdb"}}'
227
+ # -> use the returned "id" as connection_id on /api/models/<retail-model-id>/mcp
228
+ ```
229
+
230
+ ## Quickstart: Web UI
231
+
232
+ Two servers: a FastAPI backend and a Vite/React frontend.
233
+
234
+ **Backend** (from the repo root):
235
+
236
+ ```bash
237
+ pip install -e ".[dev,api]"
238
+ alembic upgrade head # creates lexis_dev.db and its schema
239
+ uvicorn lexis_api.main:app --reload --port 8000
240
+ ```
241
+
242
+ Startup automatically seeds 3 demo users (`admin`, `editor1`, `viewer1` — ids 1/2/3,
243
+ roles Admin/Editor/Viewer). There's no login screen yet: requests are attributed to a
244
+ user via an `X-Account-Id` header (defaults to `1`/admin if omitted) — a deliberate stub,
245
+ see [docs/architecture-plan.md](docs/architecture-plan.md) for why and what a real
246
+ auth swap-in looks like.
247
+
248
+ **Frontend** (in a second terminal):
249
+
250
+ ```bash
251
+ cd frontend
252
+ npm install
253
+ npm run dev # http://localhost:5173, proxies /api -> :8000
254
+ ```
255
+
256
+ Or run both together with one script, from the repo root (needs `uvicorn`/`alembic`
257
+ on PATH already, e.g. via the pyenv/venv `pip install -e ".[dev,api]"` above):
258
+
259
+ ```bash
260
+ ./scripts/dev.sh start # runs migrations, launches both, backgrounded
261
+ ./scripts/dev.sh status # is either running, and which pid
262
+ ./scripts/dev.sh stop # stops both (and their child processes)
263
+ ./scripts/dev.sh restart
264
+ ```
265
+
266
+ Set `LEXIS_DEV_SETUP_DEMO=1` (env var, honoured by the backend however it's
267
+ launched) to skip the manual `curl` above: on startup the API writes the bundled
268
+ demo datasets to `tpcds-demo.duckdb` / `retail-demo.duckdb` in the system temp dir
269
+ (`/tmp` on Linux) and registers a `duckdb_file` connection for each (`tpcds-demo`,
270
+ `retail-demo`), so the MCP endpoint and Run tab work immediately. Both steps are
271
+ idempotent and self-heal after a reboot clears the temp dir. Relocate the files
272
+ with `LEXIS_DEMO_DATA_DIR`. Leave the flag off in production.
273
+
274
+ `./scripts/demo-dev.sh <start|stop|restart|status>` is a wrapper that runs
275
+ `dev.sh` with `LEXIS_DEV_SETUP_DEMO=1` and `LEXIS_DEV_UI_PROXY=1` (single-port:
276
+ the backend also serves the UI) preset — one command for a demo/tunnel setup.
277
+
278
+ Logs go to `.dev/api.log` / `.dev/web.log`; override ports with `LEXIS_API_PORT`/
279
+ `LEXIS_WEB_PORT` env vars. The API writes one line per request to its log
280
+ (`METHOD /path -> status`), including the `X-User-Email` / `X-User-Id` /
281
+ `X-User-Name` headers — if a tunnel's OAuth traffic policy injects them from the
282
+ authenticated identity, they show up here; `-` otherwise.
283
+
284
+ Open `http://localhost:5173`, use the "Acting as" switcher in the header to pick a
285
+ role, paste an Ossie YAML document (e.g. `tests/fixtures/tpcds_semantic_model.yaml`) to
286
+ create a model, then use the **Browse** / **Design** / **Transpile** / **Test Metrics**
287
+ tabs on the model's page (two sample models are preloaded automatically on first run,
288
+ so there's already something to open: `tpcds_retail_model` on the small TPC-DS
289
+ fixture, and `retail_analytics`, a larger multi-fact star schema backed by a
290
+ 10,000-fact generated demo dataset). "Design" is a node-graph canvas (owner/admin only)
291
+ for visually adding/editing datasets, fields, and relationships — drag between the
292
+ dots on a dataset box to draw a relationship. Metrics appear as their own node,
293
+ connected by dashed edges to every dataset their expression references (a "Show
294
+ metrics" toggle hides them); "+ Add metric" creates one, and clicking a metric opens
295
+ a panel to edit its expression/description or delete it (name is fixed after
296
+ creation, like a dataset's) — owner/admin only, same as the rest of the Design tab. A
297
+ metric's panel also shows a live **time-series preview** (against the demo dataset,
298
+ reflecting the last *saved* version) when the model has any field marked
299
+ `dimension.is_time: true` — click a row to drill into the next finer grain (year →
300
+ quarter → month → day), or "Roll up" to go back.
301
+ The canvas preserves anything it has no control for (`ai_context`, `custom_extensions`,
302
+ non-ANSI_SQL dialect expressions) by merging onto the existing
303
+ parsed model rather than regenerating YAML from scratch; see
304
+ `src/lexis_api/graph_edit.py`. "Test Metrics" executes the generated SQL for real,
305
+ against the model's bundled demo dataset (TPC-DS or retail analytics, chosen from the
306
+ model's source catalog), an uploaded `.duckdb`/`.db` file, or a saved
307
+ connection (see "Connecting to Snowflake or an external DuckDB file" below) — pick
308
+ "Time series" there for the full drill-down/roll-up view (with metric, time-field, and
309
+ starting-grain pickers), or "Metric query" for the original metric+group-by mode. In
310
+ "Demo dataset" mode, an "Export demo dataset (.duckdb)" button downloads that same
311
+ data as a real file - the CLI equivalent of `lexis export-demo-dataset` above.
312
+
313
+ ## Quickstart: Docker or Podman
314
+
315
+ Two images: `lexis-api` (FastAPI backend, migrations run automatically on
316
+ container start) and `lexis-web` (the built SPA served by nginx, which also
317
+ reverse-proxies `/api/*` to the backend - same same-origin-`/api` pattern the Vite
318
+ dev proxy uses, just in production).
319
+
320
+ ```bash
321
+ docker compose up -d --build
322
+ ```
323
+
324
+ Open `http://localhost:8000` (the `web` container publishes nginx's port 8080 on
325
+ host `8000` - see `docker-compose.yml`). The SQLite database lives on a named
326
+ volume (`lexis-data`, mounted at `/data` in the API container), so it survives
327
+ `docker compose down`/`up` and container restarts - only `docker compose down -v`
328
+ removes it. Override `LEXIS_CORS_ORIGINS`/`LEXIS_MAX_DUCKDB_UPLOAD_MB`/etc.
329
+ (see `src/lexis_api/config.py`) via `environment:` in `docker-compose.yml` if
330
+ needed - `LEXIS_CORS_ORIGINS` must list the origin you open in the browser, so
331
+ change it too if you remap the published port; if you raise the upload cap, also
332
+ raise nginx's `client_max_body_size` in `docker/nginx.conf` to match.
333
+
334
+ nginx re-resolves the `api` service at runtime (a `resolver` generated from the
335
+ container's DNS config at start, see `docker/nginx-resolver.sh`), so recreating
336
+ just the API container - `compose up -d --force-recreate api`, which gives it a
337
+ new IP - no longer 502s the frontend until `web` is restarted too.
338
+
339
+ **Demo data + connections:** layer `docker-compose.demo.yml` on top to set
340
+ `LEXIS_DEV_SETUP_DEMO=1` — the API then writes the bundled demo datasets and
341
+ registers a `duckdb_file` connection for each (`tpcds-demo`, `retail-demo`) on
342
+ startup, so the MCP endpoint / Run tab work with no manual `curl`. The `.duckdb`
343
+ files go to `/data/demo` on the `lexis-data` volume (kept across restarts).
344
+
345
+ ```bash
346
+ podman compose -f docker-compose.yml -f docker-compose.demo.yml up -d --build
347
+ # docker compose -f docker-compose.yml -f docker-compose.demo.yml up -d --build
348
+ ```
349
+
350
+ **Prebuilt images:** `docker-compose-dockerhub.yml` / `docker-compose-ghcr.yml`
351
+ run the published images instead of building locally — `.github/workflows/publish-images.yml`
352
+ builds once per `vX.Y.Z` tag and pushes the same image to both
353
+ `docker.io/puspendubanerjee/lexis-{api,web}` and `ghcr.io/puspendubanerjee/lexis-{api,web}`:
354
+
355
+ ```bash
356
+ podman compose -f docker-compose-dockerhub.yml up -d # Docker Hub
357
+ podman compose -f docker-compose-ghcr.yml up -d # GHCR
358
+ LEXIS_IMAGE_TAG=0.2.0 podman compose -f docker-compose-ghcr.yml up -d # pin a release
359
+ podman compose -f docker-compose-ghcr.yml -f docker-compose.demo.yml up -d # + demo data
360
+ ```
361
+
362
+ To build the images without compose (e.g. for pushing to a registry):
363
+
364
+ ```bash
365
+ ./scripts/docker-build.sh # lexis-api + lexis-web, tag :latest
366
+ ./scripts/docker-build.sh 0.3.0 # ... tagged :0.3.0
367
+ ./scripts/docker-build.sh --type uber # the single-container lexis-uber image instead
368
+ ./scripts/docker-build.sh --type all # all three
369
+ ./scripts/docker-build.sh --help # full usage
370
+ ```
371
+
372
+ The **`uber`** build (`docker/uber.Dockerfile`, `docker-compose.uber.yml`) bundles
373
+ the SPA and the API in one image on one port — no nginx. Use the default split
374
+ build when you want to scale or deploy the UI and API separately.
375
+
376
+ Both containers currently run as root and there's no HTTPS/reverse-auth in front of
377
+ them - fine for local/trusted-network use, but harden before exposing publicly.
378
+
379
+ ### Using Podman instead
380
+
381
+ The Dockerfiles pin fully-qualified base images (`docker.io/...`) so rootless
382
+ Podman resolves them without prompting, and `docker-compose.yml` is a plain
383
+ Compose file both engines read. Use **`podman compose`** (Podman 4.7+), which
384
+ shells out to the Compose CLI (`docker compose` / `docker-compose`) pointed at the
385
+ Podman socket - so healthchecks and `depends_on: condition: service_healthy`
386
+ behave exactly as with Docker. The older standalone `podman-compose` package
387
+ honours neither and is not recommended here. The API healthcheck runs a script
388
+ file (`docker/healthcheck.py`) rather than an inline `python -c "..."` because
389
+ Podman mangles multi-word exec-form healthcheck commands.
390
+
391
+ ```bash
392
+ # one-time: start the rootless API socket the Compose provider talks to
393
+ systemctl --user enable --now podman.socket
394
+
395
+ export DOCKER_HOST="unix://${XDG_RUNTIME_DIR}/podman/podman.sock"
396
+ podman compose up -d --build # same flags as `docker compose`
397
+ ```
398
+
399
+ Or build the images directly with Podman (no socket needed):
400
+
401
+ ```bash
402
+ CONTAINER_ENGINE=podman ./scripts/docker-build.sh # also auto-detected if docker isn't on PATH
403
+ ```
404
+
405
+ Notes for rootless Podman: the `lexis-data` named volume lives under
406
+ `~/.local/share/containers/storage/volumes/` (not a Docker volume); published
407
+ ports `8080`/`8000` are >1024 so no privileged-port config is needed; and the
408
+ in-container root user maps to your host UID, so the SQLite file on the volume is
409
+ owned by you.
410
+
411
+ ## Connecting to Snowflake or an external DuckDB file
412
+
413
+ Beyond the demo dataset and one-off `.duckdb`/`.db` uploads, you can register a
414
+ named, reusable **connection** and point any model's "Run" at it instead. Two
415
+ types are supported: `duckdb_file` (a DuckDB database file already sitting on the
416
+ API server's filesystem) and `snowflake`.
417
+
418
+ Any authenticated user can view/test/run against any connection (same
419
+ workspace-wide visibility as models); creating, updating, or deleting one
420
+ requires the Editor or Admin role, and only the connection's owner (or an Admin)
421
+ can update/delete it. Requests are attributed via the `X-Account-Id` header, same as
422
+ everywhere else in the API (see Quickstart: Web UI above).
423
+
424
+ **Create a DuckDB-file connection:**
425
+
426
+ ```bash
427
+ curl -X POST http://localhost:8000/api/connections \
428
+ -H "X-Account-Id: 2" -H "Content-Type: application/json" \
429
+ -d '{
430
+ "name": "local-warehouse",
431
+ "type": "duckdb_file",
432
+ "config": {"path": "/data/warehouse.duckdb"}
433
+ }'
434
+ ```
435
+
436
+ `path` is resolved on the **API server** (or, in Docker, inside the
437
+ `lexis-api` container) - it's not a client-side file picker. If you're
438
+ running via `docker compose`, mount the directory containing the file into the
439
+ container (alongside the existing `lexis-data` volume in
440
+ `docker-compose.yml`) so the path is reachable there.
441
+
442
+ **Create a Snowflake connection:**
443
+
444
+ ```bash
445
+ curl -X POST http://localhost:8000/api/connections \
446
+ -H "X-Account-Id: 2" -H "Content-Type: application/json" \
447
+ -d '{
448
+ "name": "prod-snowflake",
449
+ "type": "snowflake",
450
+ "config": {
451
+ "account": "xy12345.us-east-1",
452
+ "user": "LEXIS_SVC",
453
+ "password_env": "SNOWFLAKE_PASSWORD",
454
+ "warehouse": "COMPUTE_WH",
455
+ "database": "ANALYTICS",
456
+ "schema": "PUBLIC",
457
+ "role": "ANALYST"
458
+ }
459
+ }'
460
+ ```
461
+
462
+ `account`/`user`/`password_env` are required; `warehouse`/`database`/`schema`/
463
+ `role` are optional. Secrets are never stored in the database: `password_env` is
464
+ the *name* of an environment variable, and the API process reads the actual
465
+ password from its own environment (`export SNOWFLAKE_PASSWORD=...`, or an
466
+ `environment:` entry in `docker-compose.yml`) at connect time - so that variable
467
+ must be set wherever the API process runs, not passed in the request body.
468
+
469
+ **Test connectivity** (opens a real connection, no query run):
470
+
471
+ ```bash
472
+ curl -X POST http://localhost:8000/api/connections/1/test -H "X-Account-Id: 2"
473
+ # {"ok": true, "detail": "connected successfully"}
474
+ ```
475
+
476
+ **Run a model's metric against a connection** (`connection_id` is the id from
477
+ the create response above; same `/run` endpoint used for demo/upload, with
478
+ `mode=connection`):
479
+
480
+ ```bash
481
+ curl -X POST http://localhost:8000/api/models/1/run \
482
+ -H "X-Account-Id: 2" \
483
+ -F "mode=connection" -F "connection_id=1" \
484
+ -F "metric=total_sales" -F 'group_by_json=["item.i_category"]'
485
+ ```
486
+
487
+ The time-series endpoint (`/api/models/{id}/run/timeseries`) takes the same
488
+ `mode=connection`/`connection_id` fields alongside its usual `time_dataset`/
489
+ `time_field`/`grain`/`filter_grain`/`filter_value` form fields. For a
490
+ `duckdb_file` connection, every dataset referenced by the metric/group-by must
491
+ share one catalog name (the first `.`-segment of the dataset's `source` in the
492
+ Ossie model) - the file is attached under that name, mirroring how the demo/upload
493
+ modes work. Snowflake has no such restriction: `source` is used as-is, so it can
494
+ reference any `database.schema.table` the connection's role can see.
495
+
496
+ The web UI's **Connections** page (linked from the header) covers all of the above
497
+ graphically - create/edit/delete/test a connection, with the same RBAC - and the
498
+ model "Run" tab's "Saved connection" mode lets you pick one to run against.
499
+
500
+ ## Using the live MCP server
501
+
502
+ The `--target mcp` transpile output above is schema-only — it describes the tools but
503
+ doesn't run anything. For an AI tool to actually call a metric and get real query
504
+ results back, Lexis can also serve a model as a **live** MCP server, one
505
+ `query_<metric>` tool per metric, resolved against the demo dataset, a local DuckDB
506
+ file, or Snowflake.
507
+
508
+ Each `query_<metric>` tool takes `group_by` (dimension refs, constrained to an enum)
509
+ **or** `time_grain` (`day`/`week`/`month`/`quarter`/`year`) to get a period-by-period
510
+ trend instead of a single total — e.g. "sales by week". `time_grain` buckets are ISO
511
+ 8601 periods (weeks start Monday); a model whose calendar differs (e.g. a US retail
512
+ Sunday–Saturday week) says so in its `ai_context`, surfaced to the client as the MCP
513
+ server's `instructions` and in `list_metrics`. `time_field` picks the date axis when
514
+ a model has more than one.
515
+
516
+ There are three ways to wire a client up to it, depending on what you're doing:
517
+
518
+ | # | Approach | Reaches localhost? | Needs public exposure? |
519
+ |---|---|---|---|
520
+ | 1 | **Local (stdio)** — the client spawns `lexis mcp-serve` itself | n/a (same process tree) | No |
521
+ | 2 | **`mcp-remote` bridge** — the client spawns `mcp-remote`, which proxies to `lexis_api` over plain local HTTP | Yes, from the same machine | **No** |
522
+ | 3 | **Public tunnel** (cloudflared/ngrok) — for Claude's own *remote connector* UI, which calls from Anthropic's cloud, not your laptop | No — genuinely public | **Yes** |
523
+
524
+ Approaches 2 and 3 both talk to the same [remote HTTP endpoint](#the-remote-http-endpoint-approaches-2-and-3);
525
+ approach 2 is the better choice whenever you just want to exercise that endpoint
526
+ yourself, since it never leaves your machine.
527
+
528
+ ### Approach 1: local (stdio) — e.g. Claude Desktop or any MCP client that launches a subprocess
529
+
530
+ ```bash
531
+ pip install -e ".[mcp]"
532
+ lexis mcp-serve tests/fixtures/tpcds_semantic_model.yaml --demo
533
+ # larger bundled dataset (10,000 facts, 17 metrics):
534
+ # lexis mcp-serve src/lexis_api/sample_data/retail_analytics_model.yaml --demo
535
+ # or: --duckdb-file /path/to/warehouse.duckdb
536
+ # or: --snowflake-account ... --snowflake-user ... --snowflake-password-env ...
537
+ ```
538
+
539
+ `--demo` serves the bundled dataset whose catalog matches the model
540
+ (`tpcds.*` → the TPC-DS fixture, `retail.*` → the retail analytics dataset); see
541
+ [The bundled demo datasets](#the-bundled-demo-datasets).
542
+
543
+ Point a client's config at it, e.g. Claude Desktop's `claude_desktop_config.json`:
544
+
545
+ ```json
546
+ {
547
+ "mcpServers": {
548
+ "lexis": {
549
+ "command": "lexis",
550
+ "args": ["mcp-serve", "/path/to/model.yaml", "--demo"]
551
+ }
552
+ }
553
+ }
554
+ ```
555
+
556
+ `--duckdb-file`/`--snowflake-*` requires every dataset the model's metrics touch to
557
+ be reachable the same way the corresponding **Connection** run mode already requires
558
+ (see "Connecting to Snowflake or an external DuckDB file" above) — a `--duckdb-file`
559
+ model's datasets must all share one catalog name. If a metric references a table your
560
+ dataset doesn't have (e.g. running `--demo` against a model with a `store` dataset,
561
+ which isn't in the bundled demo data), that one tool call fails with the underlying
562
+ DB error — other metrics keep working.
563
+
564
+ ### The remote HTTP endpoint (approaches 2 and 3)
565
+
566
+ Mounted on the API, bound to an existing saved Connection:
567
+
568
+ ```text
569
+ POST/GET/DELETE /api/models/{model_id}/mcp?connection_id=<id>
570
+ ```
571
+
572
+ Requires the same `X-Account-Id` header as the rest of the API, and a `connection_id`
573
+ for a Connection you've already created (see above) — the endpoint has no demo/upload
574
+ mode, since a remote MCP client can't provide a file per request. `connection_id` is
575
+ **not** the model's id, and there's no connection until you create one — a fresh
576
+ install has none, so calling this endpoint before creating a connection fails with
577
+ `{"detail":"connection not found"}`. If you just want to try it against the bundled
578
+ demo data:
579
+
580
+ ```bash
581
+ lexis export-demo-dataset --out /tmp/tpcds-demo.duckdb --force
582
+
583
+ curl -X POST http://localhost:8000/api/connections \
584
+ -H "X-Account-Id: 2" -H "Content-Type: application/json" \
585
+ -d '{"name":"demo","type":"duckdb_file","config":{"path":"/tmp/tpcds-demo.duckdb"}}'
586
+ # -> note the "id" in the response, use it as connection_id below
587
+ ```
588
+
589
+ Then point any MCP client that supports a remote HTTP server at the model's `/mcp`
590
+ URL; it speaks the standard MCP Streamable HTTP transport, e.g.:
591
+
592
+ ```bash
593
+ curl -X POST "http://localhost:8000/api/models/1/mcp?connection_id=<id-from-above>" \
594
+ -H "X-Account-Id: 2" -H "Content-Type: application/json" \
595
+ -H "Accept: application/json, text/event-stream" \
596
+ -d '{"jsonrpc":"2.0","id":1,"method":"initialize",
597
+ "params":{"protocolVersion":"2025-06-18","capabilities":{},
598
+ "clientInfo":{"name":"curl","version":"0"}}}'
599
+ ```
600
+
601
+ Each tool call opens the connection fresh (same per-request cost model the `/run`
602
+ endpoint already has) and returns the metric's real result rows, not just the schema.
603
+
604
+ #### A workspace-wide alternative: switch models/connections without reconnecting
605
+
606
+ The endpoint above fixes one model+connection in the URL - reasonable for a client
607
+ that only ever cares about one model, but it means picking a different model or
608
+ connection means reconnecting to a different URL. `POST/GET/DELETE /api/mcp` (no
609
+ `model_id`/`connection_id` in the URL at all) is the alternative: one connection,
610
+ four generic tools, `model_id`/`connection_id` supplied as **tool-call arguments**
611
+ instead:
612
+
613
+ - `list_models` - every model in the workspace (id, name, description)
614
+ - `list_connections` - every connection (id, name, type)
615
+ - `list_metrics(model_id)` - a model's metrics, each with its description and valid
616
+ `group_by` references, plus the model's `time_fields` / `time_grains` and any
617
+ model-level `instructions` (the same data the per-model endpoint bakes into each
618
+ `query_<metric>` tool's schema, just returned as data here instead)
619
+ - `query_metric(model_id, metric, connection_id, group_by? | time_grain? + time_field?)` - runs it
620
+
621
+ The trade-off: the per-model endpoint's one-governed-tool-per-metric design (a
622
+ distinct `query_<metric>` tool, `group_by` constrained to a real enum in the JSON
623
+ schema itself) becomes one generic `query_metric` tool instead, since the tool
624
+ schema can no longer depend on which `model_id` shows up in a given call - an
625
+ agent has to call `list_metrics` first to discover what's valid rather than having
626
+ it enforced by the schema. Point either the `mcp-remote` bridge (below) or a
627
+ tunnel at `http://localhost:8000/api/mcp` instead of the per-model URL to use it.
628
+
629
+ ### Approach 2: `mcp-remote` as a local stdio bridge (no public exposure)
630
+
631
+ When you add a **custom (remote) connector** in Claude Desktop's Settings → Connectors
632
+ or claude.ai, Anthropic's cloud infrastructure — not your local Desktop app — is what
633
+ actually opens the HTTP connection to the URL you give it. `localhost` from their
634
+ servers' point of view means *their own server*, not your laptop, so that path
635
+ genuinely requires a publicly reachable URL (approach 3, below).
636
+
637
+ If you just want to exercise the remote HTTP endpoint above without any public
638
+ exposure, [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) is a small local
639
+ bridge: Claude spawns it as an ordinary local (stdio) subprocess — same mechanism as
640
+ approach 1 — and *it* makes the HTTP call to `lexis_api` from your own machine, where
641
+ `localhost` means exactly what you'd expect. No tunnel, no TLS, no public exposure:
642
+
643
+ ```json
644
+ {
645
+ "mcpServers": {
646
+ "lexis-remote": {
647
+ "command": "npx",
648
+ "args": [
649
+ "mcp-remote",
650
+ "http://localhost:8000/api/models/1/mcp?connection_id=1",
651
+ "--allow-http",
652
+ "--header",
653
+ "X-Account-Id: 2"
654
+ ]
655
+ }
656
+ }
657
+ }
658
+ ```
659
+
660
+ `--allow-http` is required since the endpoint here is plain HTTP (`mcp-remote` refuses
661
+ non-HTTPS URLs by default, for good reason on a real network — this one never leaves
662
+ your machine). `--header` supplies the same `X-Account-Id` auth the API needs everywhere
663
+ else. Verified working end-to-end: `initialize` → `notifications/initialized` →
664
+ `tools/call query_total_sales` all round-trip correctly through the bridge.
665
+
666
+ ### Approach 3: exposing the endpoint over HTTPS (for Claude's own remote connector)
667
+
668
+ If you specifically want to use Claude's built-in remote-connector UI (rather than
669
+ `mcp-remote`), you do need a genuinely public HTTPS URL, for the reason above. For
670
+ local development, the quickest way to get one is a tunnel that terminates TLS for you
671
+ and forwards to your local port, with no cert management needed:
672
+
673
+ ```bash
674
+ # install once (see cloudflare's docs for your OS if `brew`/`apt` aren't available)
675
+ brew install cloudflared # or: apt install cloudflared
676
+
677
+ # with lexis_api already running on :8000 - logging to .dev/ alongside
678
+ # scripts/dev.sh's own api.log/web.log, backgrounded so the shell stays free:
679
+ nohup cloudflared tunnel --url http://localhost:8000 > .dev/cloudflared.log 2>&1 &
680
+ sleep 5 && grep -o 'https://[a-zA-Z0-9.-]*trycloudflare\.com' .dev/cloudflared.log
681
+ ```
682
+
683
+ That prints the random `https://<something>.trycloudflare.com` URL straight from the
684
+ log. Your full connector URL is then:
685
+
686
+ ```text
687
+ https://<something>.trycloudflare.com/api/models/<model_id>/mcp?connection_id=<connection_id>
688
+ ```
689
+
690
+ **ngrok** is an equivalent alternative if you'd rather use that:
691
+
692
+ ```bash
693
+ ngrok http 8000
694
+ ```
695
+
696
+ This gives a random `https://<random>.ngrok-free.app` URL, same trade-off as
697
+ `cloudflared`'s quick tunnel above — it changes every restart, so the connector URL
698
+ needs re-entering each time (and since Claude's custom connectors have no edit
699
+ option, that means delete-and-recreate, not just editing a field).
700
+
701
+ ngrok's free tier includes **one static/reserved domain per account**, which avoids
702
+ that entirely - the URL stays the same across restarts:
703
+
704
+ 1. Claim it once: ngrok dashboard → Universal Edge → Domains → **+ New Domain**
705
+ (gives you something like `engaging-expose-annex.ngrok-free.dev`).
706
+ 2. Bind it directly with `--domain` - no config file needed for a single stable tunnel:
707
+
708
+ ```bash
709
+ ngrok http --domain=engaging-expose-annex.ngrok-free.dev 8000
710
+ ```
711
+
712
+ Or, for a named, reusable config (`ngrok start <name>`), add it under `endpoints`/
713
+ `tunnels` in `~/.config/ngrok/ngrok.yml` with that domain + port, then
714
+ `ngrok start <name>`.
715
+ 3. Your connector URL is now fixed:
716
+ `https://engaging-expose-annex.ngrok-free.dev/api/models/<model_id>/mcp?connection_id=<connection_id>`
717
+ (or `/api/mcp` for the [workspace-wide endpoint](#a-workspace-wide-alternative-switch-modelsconnections-without-reconnecting) —
718
+ never needs updating either way, since the domain doesn't change).
719
+
720
+ One gotcha specific to a *named* tunnel: ngrok only allows one running agent per
721
+ static domain at a time, across every machine on your account - if you get
722
+ `ERR_NGROK_334` ("endpoint is already online"), an earlier session (a different
723
+ terminal, a different device) still has it claimed; stop that one first, or check
724
+ the ngrok dashboard's Agents/Endpoints page to disconnect it remotely. [ngrok's
725
+ static domains blog post](https://ngrok.com/blog/free-static-domains-ngrok-users)
726
+
727
+ #### Tunneling the UI too, on the same port
728
+
729
+ ngrok's (and most tunnel providers') free tier gives you exactly one exposed
730
+ port/endpoint. If you also want to share the running web UI - not just the MCP
731
+ endpoint - through that same single tunnel, `lexis_api` can proxy its own port to
732
+ the Vite dev server, so one `uvicorn` port serves both:
733
+
734
+ ```bash
735
+ # via scripts/dev.sh (derives the target from LEXIS_WEB_PORT automatically):
736
+ LEXIS_DEV_UI_PROXY=1 ./scripts/dev.sh start
737
+
738
+ # or standalone:
739
+ LEXIS_DEV_UI_PROXY_TARGET=http://localhost:5173 uvicorn lexis_api.main:app --port 8000
740
+ ```
741
+
742
+ With that set, `:8000` serves `/api/...` as usual and forwards everything
743
+ else (including Vite's HMR WebSocket) to the dev server - so `ngrok http 8000` (or
744
+ the cloudflared tunnel above) now exposes the whole app, not just the API. It's
745
+ off unless that env var is set, and it's dev-only by design - production
746
+ (`docker-compose`) already has this same job done by `nginx` instead
747
+ (`docker/nginx.conf`), which this proxy doesn't replace or touch. This also
748
+ sidesteps Vite's own `Host`-header check (the "Blocked request... add to
749
+ `server.allowedHosts`" error) automatically, since the proxy always presents
750
+ itself to Vite as `localhost:5173` regardless of the tunnel's public hostname.
751
+
752
+ **Before you expose it**: `X-Account-Id` is a development-only auth stub in this codebase
753
+ (see `lexis_api/deps.py`) — anyone who reaches the tunnel URL can act as *any* user id
754
+ just by setting that header themselves, no password or token required. Only run the
755
+ tunnel while you're actively testing against your own machine, don't point it at a
756
+ database with real data, and kill it (`pkill cloudflared`, since the command above
757
+ backgrounds it) as soon as you're done — the hostname is random and will change on
758
+ every restart anyway, so there's no persistent URL to protect.
759
+
760
+ ### Connecting Claude's remote connector to it (approach 3 only)
761
+
762
+ 1. Claude Desktop: `Ctrl+,` (or the top-left menu → File → Settings) → **Connectors**
763
+ in the sidebar → **Add custom connector**.
764
+ 2. Paste in the tunnel URL from above, including the `/api/models/<model_id>/mcp?connection_id=<connection_id>` path.
765
+ 3. Look for a **Request headers** section in that same dialog and add `X-Account-Id` →
766
+ your user id (e.g. `2`). Claude stores it as the connector's credential and sends it
767
+ on every request — this is what satisfies the endpoint's auth requirement.
768
+
769
+ Request-header support for custom connectors is currently a **beta feature limited to
770
+ some organizations** — if you don't see that section, the dialog will only offer OAuth,
771
+ which this endpoint doesn't implement, and you won't be able to connect this
772
+ particular remote endpoint from Claude's UI without a small proxy in front of it that
773
+ injects the header for you. Approaches 1 and 2 above have no such limitation — neither
774
+ goes through this connector-UI auth path at all, since both configure the header (or
775
+ skip auth entirely) directly in `claude_desktop_config.json`.
776
+
777
+ ### Sample questions to ask
778
+
779
+ Once connected (any of the three approaches), the model's `ai_context` synonyms let
780
+ you ask in plain language instead of naming the metric exactly.
781
+
782
+ Against the **`tpcds`** model (`lexis mcp-serve tests/fixtures/tpcds_semantic_model.yaml --demo`):
783
+
784
+ - "How's revenue breaking down by product category?"
785
+ - "What's our customer lifetime value?"
786
+ - "Break total sales down by brand."
787
+ - "Show me sales by year."
788
+ - "How productive are our stores?" (exercises `store_productivity`, a ratio metric)
789
+
790
+ Against the **`retail`** model (`lexis mcp-serve src/lexis_api/sample_data/retail_analytics_model.yaml --demo`),
791
+ which has enough data for the answers to be interesting:
792
+
793
+ - "What's total revenue and gross margin percent by product category?"
794
+ - "Show revenue by month — is there a Q4 bump?"
795
+ - "Which store format has the highest revenue per employee?"
796
+ - "Break average basket value down by customer loyalty tier."
797
+ - "How much are we losing to returns, and what's the top return reason?"
798
+
799
+ Two things worth knowing about the small **`tpcds`** `--demo` data specifically, so
800
+ unexpected answers don't read as bugs: it's only 2 items/2 customers, so per-item
801
+ attributes like brand and category are perfectly correlated (slicing by either gives
802
+ the same split); and a handful of `store_sales` rows deliberately reference an
803
+ item/customer id that doesn't exist, so an item- or customer-sliced metric will show a
804
+ smaller total than one sliced by date alone — that's correct `INNER JOIN` behavior on
805
+ intentionally incomplete sample data. The **`retail`** dataset has none of these
806
+ quirks: every fact row's foreign keys resolve, and the dimensions are fully populated.
807
+
808
+ ## Running tests
809
+
810
+ ```bash
811
+ pip install -e ".[dev]" # core library + CLI tests only
812
+ pytest tests --ignore=tests/api
813
+
814
+ pip install -e ".[dev,api]" # everything, including the API test suite
815
+ pytest
816
+ ```
817
+
818
+ ## Project structure
819
+
820
+ ```text
821
+ src/lexis/ core library: Ossie parsing, join-graph resolution, transpilers, CLI
822
+ src/lexis/demo_data.py small fixed TPC-DS demo dataset (in-memory / exported .duckdb)
823
+ src/lexis/retail_demo_data.py generated 10,000-fact retail analytics demo dataset
824
+ src/lexis_api/ FastAPI backend (models, RBAC, transpile route, live query execution
825
+ against demo/upload DuckDB or a persisted connections.py connection)
826
+ src/lexis_api/sample_data/ bundled models seeded on first boot (tpcds + retail_analytics)
827
+ frontend/ Vite + React + TypeScript SPA
828
+ tests/ core library tests (fixtures under tests/fixtures/)
829
+ tests/api/ backend API tests
830
+ docs/architecture-plan.md architecture decisions and design rationale
831
+ docker/ Dockerfiles + nginx config (split: backend/frontend; single: uber)
832
+ scripts/docker-build.sh builds images directly with `docker build` (--type split|uber|all), no compose
833
+ docker-compose.uber.yml single-container variant (SPA + API in one image; + .uber.demo.yml overlay)
834
+ third_party/ossie/ git submodule: upstream Ossie spec/schema/converters docs/examples
835
+ ```
836
+
837
+ ## Keeping Ossie in sync
838
+
839
+ `src/lexis/_vendor/ossie/models.py` is a vendored (not pip-installed - `apache-ossie`
840
+ isn't on PyPI yet) copy of upstream's pydantic model classes, and `tests/fixtures/*.yaml`
841
+ are meant to conform to upstream's JSON Schema. Both are checked against the
842
+ `third_party/ossie` submodule by `tests/test_ossie_spec_conformance.py`, so a submodule bump
843
+ that changes either will fail loudly instead of silently drifting.
844
+
845
+ To pick up an upstream Ossie change:
846
+
847
+ ```bash
848
+ git submodule update --remote third_party/ossie # bump the submodule to upstream's latest main
849
+ scripts/sync_ossie_vendor.sh # re-vendor models.py + refresh NOTICE.md's commit pin
850
+ pytest tests/test_ossie_spec_conformance.py tests/test_parser.py tests/test_resolved_model.py
851
+ ```
852
+
853
+ `scripts/sync_ossie_vendor.sh` only overwrites `models.py` verbatim; `__init__.py` is
854
+ hand-adapted (relative import, own docstring) and the script just warns if a new
855
+ upstream class/name isn't re-exported yet, so it needs a manual one-line addition in
856
+ that case.
857
+
858
+ `third_party/ossie` is upstream's repo, not ours - never edit files inside it directly,
859
+ and never commit local changes to it (we have no push access, and a submodule pointer
860
+ referencing a commit we made locally but never pushed would break for everyone else
861
+ who clones this repo). Its `.gitmodules` entry sets `ignore = dirty`, so `git status`/
862
+ `git diff` won't even show local edits inside it; the only supported way to move it
863
+ forward is `git submodule update --remote third_party/ossie` followed by
864
+ `scripts/sync_ossie_vendor.sh`.
865
+
866
+ This is also enforced by two automated checks, both running
867
+ `scripts/check_ossie_submodule_pin.sh` (fails if `third_party/ossie` is pinned to a commit
868
+ that isn't reachable from any of its remote branches - i.e. a local-only commit made by
869
+ accidentally `cd`-ing into the submodule and committing there):
870
+
871
+ - **CI** (`.github/workflows/check-ossie-submodule.yml`) runs it on every push/PR - the
872
+ real backstop, since it can't be skipped.
873
+ - **A local pre-commit hook** (`.githooks/pre-commit`) runs it before any commit that
874
+ touches the submodule pin, so you find out before pushing rather than after CI fails.
875
+ Opt in once per clone (git doesn't version `.git/hooks`, so this isn't automatic):
876
+
877
+ ```bash
878
+ git config core.hooksPath .githooks
879
+ ```
880
+
881
+ ## License
882
+
883
+ Apache 2.0 - see [LICENSE](LICENSE) and [NOTICE](NOTICE).