cassis-cli 1.1.1__tar.gz → 1.2.0__tar.gz
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.
- {cassis_cli-1.1.1 → cassis_cli-1.2.0}/PKG-INFO +14 -5
- {cassis_cli-1.1.1 → cassis_cli-1.2.0}/README.md +13 -4
- {cassis_cli-1.1.1 → cassis_cli-1.2.0}/cassis_cli/__init__.py +1 -1
- {cassis_cli-1.1.1 → cassis_cli-1.2.0}/cassis_cli/api.py +102 -2
- {cassis_cli-1.1.1 → cassis_cli-1.2.0}/cassis_cli/common.py +11 -4
- {cassis_cli-1.1.1 → cassis_cli-1.2.0}/cassis_cli/eval.py +132 -0
- {cassis_cli-1.1.1 → cassis_cli-1.2.0}/cassis_cli/guide.py +1 -1
- {cassis_cli-1.1.1 → cassis_cli-1.2.0}/cassis_cli/main.py +2 -0
- {cassis_cli-1.1.1 → cassis_cli-1.2.0}/cassis_cli/ontology.py +43 -3
- {cassis_cli-1.1.1 → cassis_cli-1.2.0}/cassis_cli/ontology_design_guide.md +10 -0
- cassis_cli-1.2.0/cassis_cli/schema.py +129 -0
- {cassis_cli-1.1.1 → cassis_cli-1.2.0}/pyproject.toml +1 -1
- {cassis_cli-1.1.1 → cassis_cli-1.2.0}/LICENSE +0 -0
- {cassis_cli-1.1.1 → cassis_cli-1.2.0}/NOTICE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cassis-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Cassis CLI — run Cassis actions (ontology validation, upload and publish) from your CI pipelines
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
License-File: LICENSE
|
|
@@ -24,7 +24,8 @@ Description-Content-Type: text/markdown
|
|
|
24
24
|
|
|
25
25
|
Run Cassis actions from your CI pipelines:
|
|
26
26
|
|
|
27
|
-
- `cassis ontology check` validates the ontology files in your repository with the exact same checks as the Cassis GitHub PR check (YAML parsing, round-trip, import validation) — so you can gate merges in any CI system, not just GitHub.
|
|
27
|
+
- `cassis ontology check` validates the ontology files in your repository with the exact same checks as the Cassis GitHub PR check (YAML parsing, round-trip, import validation) — so you can gate merges in any CI system, not just GitHub. In a checkout bound to a project (`project.yml`, `--project`, or `CASSIS_PROJECT_ID`), it also cross-checks the tree against the project's source schema: references to tables or columns the warehouse doesn't have print as **warnings** — advisory only (the object may simply not be built or synced yet), never a failed check.
|
|
28
|
+
- `cassis schema pull` downloads the data source's full source schema (as Cassis last introspected it) into `<base-path>/.schema.json` — a **gitignored** local snapshot (the command maintains the ignore entry) with a `pulled_at` stamp. The warehouse stays authoritative; the snapshot is a cache for offline/bulk work — e.g. a coding agent grepping table and column names during a modeling pass instead of paging through the MCP `get_source_schema` tool. Re-run to refresh.
|
|
28
29
|
- `cassis ontology fmt` rewrites the ontology files in canonical form (think `black`/`gofmt` for the ontology), so hand or agent edits pass the round-trip check.
|
|
29
30
|
- `cassis ontology upload` uploads the ontology files to a Cassis project (full replace) and, by default, publishes them immediately as a new version — so a merge to your main branch can go live in one CI step.
|
|
30
31
|
- `cassis ontology pull` downloads the project's unpublished ontology into your repository checkout (full sync — stale local ontology files are pruned), so you can start editing from the current state, or bootstrap a repo that isn't git-synced (e.g. Bitbucket).
|
|
@@ -33,6 +34,7 @@ Run Cassis actions from your CI pipelines:
|
|
|
33
34
|
- `cassis eval run` runs the project's eval suite against your local ontology files (scored in-memory — nothing is pushed to Cassis) and prints per-question results, so you can test the changes on your git branch before merging.
|
|
34
35
|
- `cassis ontology test` runs individual questions through the text-to-SQL agent using your local ontology files, so you can check that a change actually works (e.g. a new column gets picked) — where `eval run` only checks for regressions on existing eval cases.
|
|
35
36
|
- `cassis eval add-case` adds a gold question/SQL case to the project's eval suite — after fixing an ontology issue, add the question users were failing on so `eval run` guards it from regressing.
|
|
37
|
+
- `cassis eval list-cases` and `cassis eval delete-case` maintain the suite: list the current cases with their ids, and prune one that is stale or wrong (e.g. its gold SQL encodes a definition the ontology has since changed).
|
|
36
38
|
|
|
37
39
|
## Install
|
|
38
40
|
|
|
@@ -54,7 +56,7 @@ The ontology tree under `<base-path>` (default `cassis/`) is:
|
|
|
54
56
|
|
|
55
57
|
1. Create an API key in Cassis under **Organization settings → API keys** (keys start with `sk-k6-`).
|
|
56
58
|
2. Store it as a CI secret and expose it as `CASSIS_API_KEY`.
|
|
57
|
-
3. For `pull`, `upload`, `eval run`, `ontology test`, and `eval add-case
|
|
59
|
+
3. For `pull`, `upload`, `schema pull`, `eval run`, `ontology test`, and the `eval` case commands (`add-case`, `list-cases`, `delete-case`): the project ID (UUID) is taken from `<base-path>/project.yml` in the checkout (written by `pull` and by publishing) — so once a repo is pulled you don't need to pass it. To override, or before the first pull, set `CASSIS_PROJECT_ID` or pass `--project` (find the UUID in the project's URL). `ontology check` uses the same resolution but treats it as optional: unbound checkouts get the project-less validation (no schema reference warnings).
|
|
58
60
|
|
|
59
61
|
## Usage
|
|
60
62
|
|
|
@@ -101,6 +103,13 @@ cassis ontology test --project ... -q "How much was refunded last month?" -q "Ne
|
|
|
101
103
|
# Add a gold case to the eval suite (rejected if the exact question already exists):
|
|
102
104
|
cassis eval add-case --project ... -q "How much was refunded last month?" \
|
|
103
105
|
--gold-sql "SELECT SUM(refunded_cents) / 100.0 FROM public.orders WHERE ..."
|
|
106
|
+
|
|
107
|
+
# List the suite's cases (id + question; --json adds the gold SQL), then prune one:
|
|
108
|
+
cassis eval list-cases --project ...
|
|
109
|
+
cassis eval delete-case 019f0000-0000-7000-8000-0000000000ca --project ...
|
|
110
|
+
|
|
111
|
+
# Pull the source schema into <base-path>/.schema.json (gitignored local snapshot):
|
|
112
|
+
cassis schema pull
|
|
104
113
|
```
|
|
105
114
|
|
|
106
115
|
Configuration (flags take precedence over env vars):
|
|
@@ -110,7 +119,7 @@ Configuration (flags take precedence over env vars):
|
|
|
110
119
|
| `--api-key` | `CASSIS_API_KEY` | — (required) |
|
|
111
120
|
| `--api-url` | `CASSIS_API_URL` | `https://app.getcassis.com` |
|
|
112
121
|
| `--base-path` | `CASSIS_BASE_PATH` | `cassis` — must match the project's git-sync "Path" setting |
|
|
113
|
-
| `--project` (pull, upload, eval run, eval add-case, test) | `CASSIS_PROJECT_ID` |
|
|
122
|
+
| `--project` (check, pull, upload, schema pull, eval run, eval add-case, eval list-cases, eval delete-case, test) | `CASSIS_PROJECT_ID` | the id in `<base-path>/project.yml` (required before the first pull; `check` alone falls back to the project-less validation when unbound) |
|
|
114
123
|
|
|
115
124
|
`cassis eval run` also accepts `--label` (run label in the Evals page; defaults
|
|
116
125
|
to the branch name from the CI environment or the local git checkout; rejected
|
|
@@ -139,7 +148,7 @@ cassis ontology fmt --check
|
|
|
139
148
|
| Code | Meaning |
|
|
140
149
|
| ---- | ------------------------------------------------------------------------------ |
|
|
141
150
|
| 0 | Ontology is valid (check) / pulled (pull) / uploaded (upload) / eval run completed all-passed (eval run) / every probe completed (test — whatever its outcome; probes are informational, don't gate CI on them) |
|
|
142
|
-
| 1 | Validation failed (check: findings printed; upload: nothing imported; eval run: invalid tree, failed cases, or failed/cancelled run; test: invalid tree or a probe failed) |
|
|
151
|
+
| 1 | Validation failed (check: findings printed; upload: nothing imported; eval run: invalid tree, failed cases, or failed/cancelled run; test: invalid tree or a probe failed; add-case: duplicate question or gold SQL that does not run; delete-case: no such case in the project) |
|
|
143
152
|
| 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits) |
|
|
144
153
|
| 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another run already active, out of credits, or `--timeout` reached |
|
|
145
154
|
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Run Cassis actions from your CI pipelines:
|
|
4
4
|
|
|
5
|
-
- `cassis ontology check` validates the ontology files in your repository with the exact same checks as the Cassis GitHub PR check (YAML parsing, round-trip, import validation) — so you can gate merges in any CI system, not just GitHub.
|
|
5
|
+
- `cassis ontology check` validates the ontology files in your repository with the exact same checks as the Cassis GitHub PR check (YAML parsing, round-trip, import validation) — so you can gate merges in any CI system, not just GitHub. In a checkout bound to a project (`project.yml`, `--project`, or `CASSIS_PROJECT_ID`), it also cross-checks the tree against the project's source schema: references to tables or columns the warehouse doesn't have print as **warnings** — advisory only (the object may simply not be built or synced yet), never a failed check.
|
|
6
|
+
- `cassis schema pull` downloads the data source's full source schema (as Cassis last introspected it) into `<base-path>/.schema.json` — a **gitignored** local snapshot (the command maintains the ignore entry) with a `pulled_at` stamp. The warehouse stays authoritative; the snapshot is a cache for offline/bulk work — e.g. a coding agent grepping table and column names during a modeling pass instead of paging through the MCP `get_source_schema` tool. Re-run to refresh.
|
|
6
7
|
- `cassis ontology fmt` rewrites the ontology files in canonical form (think `black`/`gofmt` for the ontology), so hand or agent edits pass the round-trip check.
|
|
7
8
|
- `cassis ontology upload` uploads the ontology files to a Cassis project (full replace) and, by default, publishes them immediately as a new version — so a merge to your main branch can go live in one CI step.
|
|
8
9
|
- `cassis ontology pull` downloads the project's unpublished ontology into your repository checkout (full sync — stale local ontology files are pruned), so you can start editing from the current state, or bootstrap a repo that isn't git-synced (e.g. Bitbucket).
|
|
@@ -11,6 +12,7 @@ Run Cassis actions from your CI pipelines:
|
|
|
11
12
|
- `cassis eval run` runs the project's eval suite against your local ontology files (scored in-memory — nothing is pushed to Cassis) and prints per-question results, so you can test the changes on your git branch before merging.
|
|
12
13
|
- `cassis ontology test` runs individual questions through the text-to-SQL agent using your local ontology files, so you can check that a change actually works (e.g. a new column gets picked) — where `eval run` only checks for regressions on existing eval cases.
|
|
13
14
|
- `cassis eval add-case` adds a gold question/SQL case to the project's eval suite — after fixing an ontology issue, add the question users were failing on so `eval run` guards it from regressing.
|
|
15
|
+
- `cassis eval list-cases` and `cassis eval delete-case` maintain the suite: list the current cases with their ids, and prune one that is stale or wrong (e.g. its gold SQL encodes a definition the ontology has since changed).
|
|
14
16
|
|
|
15
17
|
## Install
|
|
16
18
|
|
|
@@ -32,7 +34,7 @@ The ontology tree under `<base-path>` (default `cassis/`) is:
|
|
|
32
34
|
|
|
33
35
|
1. Create an API key in Cassis under **Organization settings → API keys** (keys start with `sk-k6-`).
|
|
34
36
|
2. Store it as a CI secret and expose it as `CASSIS_API_KEY`.
|
|
35
|
-
3. For `pull`, `upload`, `eval run`, `ontology test`, and `eval add-case
|
|
37
|
+
3. For `pull`, `upload`, `schema pull`, `eval run`, `ontology test`, and the `eval` case commands (`add-case`, `list-cases`, `delete-case`): the project ID (UUID) is taken from `<base-path>/project.yml` in the checkout (written by `pull` and by publishing) — so once a repo is pulled you don't need to pass it. To override, or before the first pull, set `CASSIS_PROJECT_ID` or pass `--project` (find the UUID in the project's URL). `ontology check` uses the same resolution but treats it as optional: unbound checkouts get the project-less validation (no schema reference warnings).
|
|
36
38
|
|
|
37
39
|
## Usage
|
|
38
40
|
|
|
@@ -79,6 +81,13 @@ cassis ontology test --project ... -q "How much was refunded last month?" -q "Ne
|
|
|
79
81
|
# Add a gold case to the eval suite (rejected if the exact question already exists):
|
|
80
82
|
cassis eval add-case --project ... -q "How much was refunded last month?" \
|
|
81
83
|
--gold-sql "SELECT SUM(refunded_cents) / 100.0 FROM public.orders WHERE ..."
|
|
84
|
+
|
|
85
|
+
# List the suite's cases (id + question; --json adds the gold SQL), then prune one:
|
|
86
|
+
cassis eval list-cases --project ...
|
|
87
|
+
cassis eval delete-case 019f0000-0000-7000-8000-0000000000ca --project ...
|
|
88
|
+
|
|
89
|
+
# Pull the source schema into <base-path>/.schema.json (gitignored local snapshot):
|
|
90
|
+
cassis schema pull
|
|
82
91
|
```
|
|
83
92
|
|
|
84
93
|
Configuration (flags take precedence over env vars):
|
|
@@ -88,7 +97,7 @@ Configuration (flags take precedence over env vars):
|
|
|
88
97
|
| `--api-key` | `CASSIS_API_KEY` | — (required) |
|
|
89
98
|
| `--api-url` | `CASSIS_API_URL` | `https://app.getcassis.com` |
|
|
90
99
|
| `--base-path` | `CASSIS_BASE_PATH` | `cassis` — must match the project's git-sync "Path" setting |
|
|
91
|
-
| `--project` (pull, upload, eval run, eval add-case, test) | `CASSIS_PROJECT_ID` |
|
|
100
|
+
| `--project` (check, pull, upload, schema pull, eval run, eval add-case, eval list-cases, eval delete-case, test) | `CASSIS_PROJECT_ID` | the id in `<base-path>/project.yml` (required before the first pull; `check` alone falls back to the project-less validation when unbound) |
|
|
92
101
|
|
|
93
102
|
`cassis eval run` also accepts `--label` (run label in the Evals page; defaults
|
|
94
103
|
to the branch name from the CI environment or the local git checkout; rejected
|
|
@@ -117,7 +126,7 @@ cassis ontology fmt --check
|
|
|
117
126
|
| Code | Meaning |
|
|
118
127
|
| ---- | ------------------------------------------------------------------------------ |
|
|
119
128
|
| 0 | Ontology is valid (check) / pulled (pull) / uploaded (upload) / eval run completed all-passed (eval run) / every probe completed (test — whatever its outcome; probes are informational, don't gate CI on them) |
|
|
120
|
-
| 1 | Validation failed (check: findings printed; upload: nothing imported; eval run: invalid tree, failed cases, or failed/cancelled run; test: invalid tree or a probe failed) |
|
|
129
|
+
| 1 | Validation failed (check: findings printed; upload: nothing imported; eval run: invalid tree, failed cases, or failed/cancelled run; test: invalid tree or a probe failed; add-case: duplicate question or gold SQL that does not run; delete-case: no such case in the project) |
|
|
121
130
|
| 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits) |
|
|
122
131
|
| 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another run already active, out of credits, or `--timeout` reached |
|
|
123
132
|
|
|
@@ -134,10 +134,19 @@ def post_ontology_check(
|
|
|
134
134
|
api_url: str,
|
|
135
135
|
api_key: str,
|
|
136
136
|
files: dict[str, str],
|
|
137
|
+
project_id: Optional[str] = None,
|
|
137
138
|
transport: Optional[httpx.BaseTransport] = None,
|
|
138
139
|
) -> dict[str, Any]:
|
|
139
|
-
"""POST the ontology tree to
|
|
140
|
-
|
|
140
|
+
"""POST the ontology tree to the check endpoint and return the response body.
|
|
141
|
+
|
|
142
|
+
With ``project_id``, calls the project-scoped route, which additionally
|
|
143
|
+
cross-checks the tree against the project's source schema and returns
|
|
144
|
+
advisory ``warnings``; without it, the pure tree check.
|
|
145
|
+
"""
|
|
146
|
+
if project_id:
|
|
147
|
+
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/ontology/check"
|
|
148
|
+
else:
|
|
149
|
+
url = api_url.rstrip("/") + "/api/ci/ontology-check"
|
|
141
150
|
try:
|
|
142
151
|
with _client(transport=transport) as client:
|
|
143
152
|
response = client.post(
|
|
@@ -150,6 +159,8 @@ def post_ontology_check(
|
|
|
150
159
|
|
|
151
160
|
if response.status_code == 401:
|
|
152
161
|
raise AuthError("The Cassis API rejected the API key (invalid or expired).")
|
|
162
|
+
if project_id and response.status_code in (403, 404):
|
|
163
|
+
raise _project_scope_error(response)
|
|
153
164
|
if response.status_code >= 400:
|
|
154
165
|
raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
|
|
155
166
|
result = _parse_json_response(response, url)
|
|
@@ -232,6 +243,47 @@ def get_ontology_export(
|
|
|
232
243
|
return result["files"]
|
|
233
244
|
|
|
234
245
|
|
|
246
|
+
class NoSourceSchemaError(ApiError):
|
|
247
|
+
"""The project's data source has no introspected schema to pull."""
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def get_schema_export(
|
|
251
|
+
*,
|
|
252
|
+
api_url: str,
|
|
253
|
+
api_key: str,
|
|
254
|
+
project_id: str,
|
|
255
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
256
|
+
) -> dict[str, Any]:
|
|
257
|
+
"""GET /api/ci/projects/{project_id}/schema and return the response body."""
|
|
258
|
+
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/schema"
|
|
259
|
+
try:
|
|
260
|
+
with _client(transport=transport) as client:
|
|
261
|
+
response = client.get(url, headers={"Authorization": f"Bearer {api_key}"})
|
|
262
|
+
except httpx.HTTPError as exc:
|
|
263
|
+
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
264
|
+
|
|
265
|
+
if response.status_code == 401:
|
|
266
|
+
raise AuthError("The Cassis API rejected the API key (invalid or expired).")
|
|
267
|
+
# "No source schema" is the marker the server's export endpoint puts in its
|
|
268
|
+
# 404 detail (backend endpoints/ci.py::export_source_schema — reworded only
|
|
269
|
+
# with a paired CLI release). Without this routing, a schema-less project
|
|
270
|
+
# would surface as the misleading "check --project / key access" hint below.
|
|
271
|
+
if response.status_code == 404 and "No source schema" in response.text:
|
|
272
|
+
raise NoSourceSchemaError(str(_detail_or_text(response)))
|
|
273
|
+
if response.status_code in (400, 403, 404):
|
|
274
|
+
raise _project_scope_error(response)
|
|
275
|
+
if response.status_code >= 400:
|
|
276
|
+
raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
|
|
277
|
+
result = _parse_json_response(response, url)
|
|
278
|
+
if (
|
|
279
|
+
not isinstance(result, dict)
|
|
280
|
+
or not isinstance(result.get("tables"), list)
|
|
281
|
+
or not isinstance(result.get("schema_version"), dict)
|
|
282
|
+
):
|
|
283
|
+
raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
|
|
284
|
+
return result
|
|
285
|
+
|
|
286
|
+
|
|
235
287
|
def post_eval_run_start(
|
|
236
288
|
*,
|
|
237
289
|
api_url: str,
|
|
@@ -321,6 +373,54 @@ def post_eval_case_create(
|
|
|
321
373
|
return result
|
|
322
374
|
|
|
323
375
|
|
|
376
|
+
class EvalCaseNotFoundError(ApiError):
|
|
377
|
+
"""The project has no current eval case with this id."""
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def get_eval_cases(
|
|
381
|
+
*,
|
|
382
|
+
api_url: str,
|
|
383
|
+
api_key: str,
|
|
384
|
+
project_id: str,
|
|
385
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
386
|
+
) -> list[dict[str, Any]]:
|
|
387
|
+
"""GET /api/ci/projects/{project_id}/eval/cases and return the case list."""
|
|
388
|
+
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/eval/cases"
|
|
389
|
+
result = _get_eval_json(url, api_key, transport)
|
|
390
|
+
if not isinstance(result, list):
|
|
391
|
+
raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
|
|
392
|
+
return result
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def delete_eval_case(
|
|
396
|
+
*,
|
|
397
|
+
api_url: str,
|
|
398
|
+
api_key: str,
|
|
399
|
+
project_id: str,
|
|
400
|
+
case_id: str,
|
|
401
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
402
|
+
) -> None:
|
|
403
|
+
"""DELETE /api/ci/projects/{project_id}/eval/cases/{case_id}."""
|
|
404
|
+
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/eval/cases/{case_id}"
|
|
405
|
+
try:
|
|
406
|
+
with _client(transport=transport) as client:
|
|
407
|
+
response = client.delete(url, headers={"Authorization": f"Bearer {api_key}"})
|
|
408
|
+
except httpx.HTTPError as exc:
|
|
409
|
+
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
410
|
+
|
|
411
|
+
if response.status_code == 401:
|
|
412
|
+
raise AuthError("The Cassis API rejected the API key (invalid or expired).")
|
|
413
|
+
# Exact-match wire contract with the DELETE endpoint's 404 detail (see
|
|
414
|
+
# `delete_eval_case` in backend/app/endpoints/ci.py): it distinguishes a
|
|
415
|
+
# missing case (exit 1) from a project-scope 404 (exit 3).
|
|
416
|
+
if response.status_code == 404 and _detail_or_text(response) == "Eval case not found":
|
|
417
|
+
raise EvalCaseNotFoundError(f"No current eval case {case_id} in this project (already deleted, or wrong id?).")
|
|
418
|
+
if response.status_code in (403, 404):
|
|
419
|
+
raise _project_scope_error(response)
|
|
420
|
+
if response.status_code >= 400:
|
|
421
|
+
raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
|
|
422
|
+
|
|
423
|
+
|
|
324
424
|
def _get_eval_json(url: str, api_key: str, transport: Optional[httpx.BaseTransport]) -> Any:
|
|
325
425
|
"""GET an eval-run URL with the shared error mapping."""
|
|
326
426
|
try:
|
|
@@ -93,20 +93,27 @@ def read_project_id_from_dir(ontology_dir: Path) -> Optional[str]:
|
|
|
93
93
|
return None
|
|
94
94
|
|
|
95
95
|
|
|
96
|
-
def resolve_project_id(
|
|
96
|
+
def resolve_project_id(
|
|
97
|
+
project_id: Optional[str], ontology_dir: Path, *, optional: bool = False, quiet: bool = False
|
|
98
|
+
) -> Optional[str]:
|
|
97
99
|
"""Resolve the target project id, defaulting to the checkout's ``project.yml``.
|
|
98
100
|
|
|
99
101
|
Precedence: an explicit ``--project`` / ``CASSIS_PROJECT_ID`` wins; otherwise
|
|
100
102
|
the ``project_id`` recorded in ``<base-path>/project.yml`` (written by
|
|
101
103
|
``pull`` / publish) is used, and where it came from is noted on stderr so a
|
|
102
|
-
stale value in a copied repo is visible
|
|
103
|
-
|
|
104
|
+
stale value in a copied repo is visible (``quiet`` suppresses the note for
|
|
105
|
+
machine-readable output). Exits 2 (usage) when the value isn't a UUID, or —
|
|
106
|
+
unless ``optional`` — when no value is available at all; with ``optional``,
|
|
107
|
+
an unbound checkout returns None (``check`` falls back to the project-less
|
|
108
|
+
validation).
|
|
104
109
|
"""
|
|
105
110
|
from_file = False
|
|
106
111
|
if not project_id:
|
|
107
112
|
project_id = read_project_id_from_dir(ontology_dir)
|
|
108
113
|
from_file = project_id is not None
|
|
109
114
|
if not project_id:
|
|
115
|
+
if optional:
|
|
116
|
+
return None
|
|
110
117
|
typer.secho(
|
|
111
118
|
f"No project. Pass --project (or set CASSIS_PROJECT_ID), or run in a checkout whose "
|
|
112
119
|
f"{ontology_dir.name}/project.yml records it (written by `cassis ontology pull` or a publish).",
|
|
@@ -119,7 +126,7 @@ def resolve_project_id(project_id: Optional[str], ontology_dir: Path) -> str:
|
|
|
119
126
|
except ValueError:
|
|
120
127
|
typer.secho(f"--project must be a project ID (UUID), got {project_id!r}.", fg=typer.colors.RED, err=True)
|
|
121
128
|
raise typer.Exit(EXIT_USAGE)
|
|
122
|
-
if from_file:
|
|
129
|
+
if from_file and not quiet:
|
|
123
130
|
typer.secho(f"Using project {project_id} from {ontology_dir.name}/project.yml.", fg=typer.colors.CYAN, err=True)
|
|
124
131
|
return project_id
|
|
125
132
|
|
|
@@ -8,6 +8,7 @@ import subprocess
|
|
|
8
8
|
import time
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from typing import Any, Optional
|
|
11
|
+
from uuid import UUID
|
|
11
12
|
|
|
12
13
|
import typer
|
|
13
14
|
from cassis_cli.api import (
|
|
@@ -16,8 +17,11 @@ from cassis_cli.api import (
|
|
|
16
17
|
AuthError,
|
|
17
18
|
EvalCaseExistsError,
|
|
18
19
|
EvalCaseGoldSqlError,
|
|
20
|
+
EvalCaseNotFoundError,
|
|
19
21
|
EvalRunActiveError,
|
|
20
22
|
EvalStartValidationError,
|
|
23
|
+
delete_eval_case,
|
|
24
|
+
get_eval_cases,
|
|
21
25
|
get_eval_run,
|
|
22
26
|
get_eval_run_results,
|
|
23
27
|
post_eval_case_create,
|
|
@@ -221,6 +225,134 @@ def add_case(
|
|
|
221
225
|
raise typer.Exit(EXIT_OK)
|
|
222
226
|
|
|
223
227
|
|
|
228
|
+
@app.command(name="list-cases")
|
|
229
|
+
def list_cases(
|
|
230
|
+
path: Path = typer.Argument(
|
|
231
|
+
Path("."),
|
|
232
|
+
help="Repository checkout root (holds <base-path>/project.yml for the --project default).",
|
|
233
|
+
),
|
|
234
|
+
project_id: Optional[str] = typer.Option(
|
|
235
|
+
None,
|
|
236
|
+
"--project",
|
|
237
|
+
envvar="CASSIS_PROJECT_ID",
|
|
238
|
+
help="Target Cassis project ID (UUID). Defaults to the id in <base-path>/project.yml.",
|
|
239
|
+
),
|
|
240
|
+
api_key: Optional[str] = typer.Option(
|
|
241
|
+
None,
|
|
242
|
+
"--api-key",
|
|
243
|
+
envvar="CASSIS_API_KEY",
|
|
244
|
+
help="Cassis API key (sk-k6-...). Create one in Organization settings -> API keys.",
|
|
245
|
+
),
|
|
246
|
+
api_url: str = typer.Option(
|
|
247
|
+
DEFAULT_API_URL,
|
|
248
|
+
"--api-url",
|
|
249
|
+
envvar="CASSIS_API_URL",
|
|
250
|
+
help="Cassis API base URL.",
|
|
251
|
+
),
|
|
252
|
+
base_path: str = typer.Option(
|
|
253
|
+
DEFAULT_BASE_PATH,
|
|
254
|
+
"--base-path",
|
|
255
|
+
envvar="CASSIS_BASE_PATH",
|
|
256
|
+
help="Repository directory the ontology is exported under (holds project.yml for the --project default).",
|
|
257
|
+
),
|
|
258
|
+
json_output: bool = typer.Option(False, "--json", help="Print the cases as raw JSON (includes gold SQL)."),
|
|
259
|
+
) -> None:
|
|
260
|
+
"""List the project's eval cases: the suite `cassis eval run` scores.
|
|
261
|
+
|
|
262
|
+
Prints each case's id and question (--json adds the gold SQL); the id is
|
|
263
|
+
what `cassis eval delete-case` takes. Exits 0 on success, 2 on usage
|
|
264
|
+
errors, 3 on transport/API errors.
|
|
265
|
+
"""
|
|
266
|
+
api_key = require_api_key(api_key)
|
|
267
|
+
project_id = resolve_project_id(project_id, path / Path(base_path))
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
cases = get_eval_cases(api_url=api_url, api_key=api_key, project_id=project_id)
|
|
271
|
+
except AuthError as exc:
|
|
272
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
273
|
+
raise typer.Exit(EXIT_TRANSPORT) from exc
|
|
274
|
+
except ApiError as exc:
|
|
275
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
276
|
+
raise typer.Exit(EXIT_TRANSPORT) from exc
|
|
277
|
+
|
|
278
|
+
if json_output:
|
|
279
|
+
typer.echo(json.dumps(cases, indent=2))
|
|
280
|
+
else:
|
|
281
|
+
if not cases:
|
|
282
|
+
typer.echo("No eval cases yet — add one with `cassis eval add-case`.")
|
|
283
|
+
for case in cases:
|
|
284
|
+
question = str(case.get("question", "")).replace("\n", " ")
|
|
285
|
+
typer.echo(f"{case.get('id')} {question}")
|
|
286
|
+
raise typer.Exit(EXIT_OK)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@app.command(name="delete-case")
|
|
290
|
+
def delete_case(
|
|
291
|
+
case_id: str = typer.Argument(
|
|
292
|
+
...,
|
|
293
|
+
help="Id of the eval case to delete (shown by `cassis eval list-cases`).",
|
|
294
|
+
),
|
|
295
|
+
path: Path = typer.Option(
|
|
296
|
+
Path("."),
|
|
297
|
+
"--path",
|
|
298
|
+
help="Repository checkout root (holds <base-path>/project.yml for the --project default).",
|
|
299
|
+
),
|
|
300
|
+
project_id: Optional[str] = typer.Option(
|
|
301
|
+
None,
|
|
302
|
+
"--project",
|
|
303
|
+
envvar="CASSIS_PROJECT_ID",
|
|
304
|
+
help="Target Cassis project ID (UUID). Defaults to the id in <base-path>/project.yml.",
|
|
305
|
+
),
|
|
306
|
+
api_key: Optional[str] = typer.Option(
|
|
307
|
+
None,
|
|
308
|
+
"--api-key",
|
|
309
|
+
envvar="CASSIS_API_KEY",
|
|
310
|
+
help="Cassis API key (sk-k6-...). Create one in Organization settings -> API keys.",
|
|
311
|
+
),
|
|
312
|
+
api_url: str = typer.Option(
|
|
313
|
+
DEFAULT_API_URL,
|
|
314
|
+
"--api-url",
|
|
315
|
+
envvar="CASSIS_API_URL",
|
|
316
|
+
help="Cassis API base URL.",
|
|
317
|
+
),
|
|
318
|
+
base_path: str = typer.Option(
|
|
319
|
+
DEFAULT_BASE_PATH,
|
|
320
|
+
"--base-path",
|
|
321
|
+
envvar="CASSIS_BASE_PATH",
|
|
322
|
+
help="Repository directory the ontology is exported under (holds project.yml for the --project default).",
|
|
323
|
+
),
|
|
324
|
+
) -> None:
|
|
325
|
+
"""Delete an eval case from the project's suite.
|
|
326
|
+
|
|
327
|
+
For pruning a case that is stale or wrong — e.g. its gold SQL encodes a
|
|
328
|
+
definition the ontology has since changed. Exits 0 on deletion, 1 when
|
|
329
|
+
the case does not exist in the project, 2 on usage errors, 3 on
|
|
330
|
+
transport/API errors.
|
|
331
|
+
"""
|
|
332
|
+
api_key = require_api_key(api_key)
|
|
333
|
+
project_id = resolve_project_id(project_id, path / Path(base_path))
|
|
334
|
+
try:
|
|
335
|
+
UUID(case_id)
|
|
336
|
+
except ValueError:
|
|
337
|
+
typer.secho(f"CASE_ID must be a UUID, got {case_id!r}.", fg=typer.colors.RED, err=True)
|
|
338
|
+
raise typer.Exit(EXIT_USAGE)
|
|
339
|
+
|
|
340
|
+
try:
|
|
341
|
+
delete_eval_case(api_url=api_url, api_key=api_key, project_id=project_id, case_id=case_id)
|
|
342
|
+
except EvalCaseNotFoundError as exc:
|
|
343
|
+
typer.secho(str(exc), fg=typer.colors.YELLOW, err=True)
|
|
344
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED) from exc
|
|
345
|
+
except AuthError as exc:
|
|
346
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
347
|
+
raise typer.Exit(EXIT_TRANSPORT) from exc
|
|
348
|
+
except ApiError as exc:
|
|
349
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
350
|
+
raise typer.Exit(EXIT_TRANSPORT) from exc
|
|
351
|
+
|
|
352
|
+
typer.secho(f"✓ Deleted eval case {case_id}.", fg=typer.colors.GREEN)
|
|
353
|
+
raise typer.Exit(EXIT_OK)
|
|
354
|
+
|
|
355
|
+
|
|
224
356
|
@app.command()
|
|
225
357
|
def run(
|
|
226
358
|
path: Path = typer.Argument(
|
|
@@ -31,7 +31,7 @@ GUIDE_FILENAME = "AGENTS.md"
|
|
|
31
31
|
# Monotonic version of the doctrine text below. Bump it whenever
|
|
32
32
|
# ontology_design_guide.md changes (a backend test enforces the pairing) — it
|
|
33
33
|
# is what lets an older writer recognize a newer guide and leave it alone.
|
|
34
|
-
DOCTRINE_VERSION =
|
|
34
|
+
DOCTRINE_VERSION = 5
|
|
35
35
|
|
|
36
36
|
# Must stay byte-identical to backend/app/services/ontology_guide.py::_BANNER —
|
|
37
37
|
# the server-side git export writes the same file, and differing banners would
|
|
@@ -6,6 +6,7 @@ import typer
|
|
|
6
6
|
from cassis_cli import __version__
|
|
7
7
|
from cassis_cli.eval import app as eval_app
|
|
8
8
|
from cassis_cli.ontology import app as ontology_app
|
|
9
|
+
from cassis_cli.schema import app as schema_app
|
|
9
10
|
|
|
10
11
|
app = typer.Typer(
|
|
11
12
|
no_args_is_help=True,
|
|
@@ -13,6 +14,7 @@ app = typer.Typer(
|
|
|
13
14
|
)
|
|
14
15
|
app.add_typer(ontology_app, name="ontology")
|
|
15
16
|
app.add_typer(eval_app, name="eval")
|
|
17
|
+
app.add_typer(schema_app, name="schema")
|
|
16
18
|
|
|
17
19
|
|
|
18
20
|
@app.command()
|
|
@@ -56,6 +56,12 @@ def check(
|
|
|
56
56
|
Path("."),
|
|
57
57
|
help="Repository checkout root (the directory containing the ontology export path).",
|
|
58
58
|
),
|
|
59
|
+
project_id: Optional[str] = typer.Option(
|
|
60
|
+
None,
|
|
61
|
+
"--project",
|
|
62
|
+
envvar="CASSIS_PROJECT_ID",
|
|
63
|
+
help="Target Cassis project ID (UUID). Defaults to the id in <base-path>/project.yml.",
|
|
64
|
+
),
|
|
59
65
|
api_key: Optional[str] = typer.Option(
|
|
60
66
|
None,
|
|
61
67
|
"--api-key",
|
|
@@ -79,14 +85,27 @@ def check(
|
|
|
79
85
|
"""Validate the ontology files in a repository checkout.
|
|
80
86
|
|
|
81
87
|
Runs the same checks as the Cassis GitHub PR check (YAML parsing,
|
|
82
|
-
round-trip, import validation).
|
|
83
|
-
|
|
88
|
+
round-trip, import validation). When the checkout is bound to a project
|
|
89
|
+
(``project.yml``, ``--project``, or ``CASSIS_PROJECT_ID``), the tree is
|
|
90
|
+
additionally cross-checked against the project's source schema; unmatched
|
|
91
|
+
references print as warnings and never fail the check. Exits 0 when valid,
|
|
92
|
+
1 when validation fails, 2 on usage errors, 3 on transport/API errors.
|
|
84
93
|
"""
|
|
85
94
|
api_key = _require_api_key(api_key)
|
|
86
95
|
files, base_path = _collect_tree(path, base_path)
|
|
87
96
|
|
|
97
|
+
# Soft resolution: an unbound checkout is not an error — the check falls
|
|
98
|
+
# back to the project-less route (no schema reference stage).
|
|
99
|
+
project_id = _resolve_project_id(project_id, path / base_path, optional=True, quiet=json_output)
|
|
100
|
+
if not project_id and not json_output:
|
|
101
|
+
typer.secho(
|
|
102
|
+
"No project binding — schema reference checks skipped (bind with `cassis ontology pull` or --project).",
|
|
103
|
+
fg=typer.colors.CYAN,
|
|
104
|
+
err=True,
|
|
105
|
+
)
|
|
106
|
+
|
|
88
107
|
try:
|
|
89
|
-
result = post_ontology_check(api_url=api_url, api_key=api_key, files=files)
|
|
108
|
+
result = post_ontology_check(api_url=api_url, api_key=api_key, files=files, project_id=project_id)
|
|
90
109
|
except AuthError as exc:
|
|
91
110
|
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
92
111
|
raise typer.Exit(EXIT_TRANSPORT) from exc
|
|
@@ -94,10 +113,21 @@ def check(
|
|
|
94
113
|
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
95
114
|
raise typer.Exit(EXIT_TRANSPORT) from exc
|
|
96
115
|
|
|
116
|
+
warnings = result.get("warnings") or []
|
|
97
117
|
if json_output:
|
|
98
118
|
typer.echo(json.dumps(result, indent=2))
|
|
99
119
|
elif result["passed"]:
|
|
100
120
|
typer.secho(f"✓ {result['summary']}", fg=typer.colors.GREEN)
|
|
121
|
+
# Disambiguate silence: "no warnings" must never read as "references
|
|
122
|
+
# verified" when the stage didn't run.
|
|
123
|
+
if project_id and result.get("references_checked") and not warnings:
|
|
124
|
+
typer.secho("✓ Schema references resolve against the source schema.", fg=typer.colors.GREEN)
|
|
125
|
+
elif project_id and not result.get("references_checked"):
|
|
126
|
+
typer.secho(
|
|
127
|
+
"Schema reference check skipped — the project has no source schema yet.",
|
|
128
|
+
fg=typer.colors.YELLOW,
|
|
129
|
+
err=True,
|
|
130
|
+
)
|
|
101
131
|
else:
|
|
102
132
|
typer.secho(result["title"], fg=typer.colors.RED, bold=True)
|
|
103
133
|
typer.echo(result["summary"])
|
|
@@ -105,6 +135,16 @@ def check(
|
|
|
105
135
|
location = f"{base_path}/{finding.get('path')}: " if finding.get("path") else ""
|
|
106
136
|
typer.echo(f" {location}{finding.get('message', '')} ({finding.get('stage', '?')})")
|
|
107
137
|
|
|
138
|
+
if warnings and not json_output:
|
|
139
|
+
typer.secho(
|
|
140
|
+
f"{len(warnings)} schema reference warning(s) — advisory, expected if the objects "
|
|
141
|
+
"haven't been built or synced yet:",
|
|
142
|
+
fg=typer.colors.YELLOW,
|
|
143
|
+
bold=True,
|
|
144
|
+
)
|
|
145
|
+
for warning in warnings:
|
|
146
|
+
typer.secho(f" {warning.get('message', '')}", fg=typer.colors.YELLOW)
|
|
147
|
+
|
|
108
148
|
raise typer.Exit(EXIT_OK if result["passed"] else EXIT_VALIDATION_FAILED)
|
|
109
149
|
|
|
110
150
|
|
|
@@ -357,6 +357,16 @@ description, so a wrong example is worse than none.
|
|
|
357
357
|
real mechanic diverges.
|
|
358
358
|
- **Don't describe things that don't exist.** Every table, column, and value you
|
|
359
359
|
reference must be real.
|
|
360
|
+
- **A hedge is not a safeguard.** "Likely `'M:SS.mmm'`", "probably", "appears to
|
|
361
|
+
be" reach the agent as the claim itself; the qualifier does not survive into
|
|
362
|
+
its reasoning. If a storage format, a unit, or a reason for nullability is not
|
|
363
|
+
in the source, leave it out and record the open question instead.
|
|
364
|
+
- **Never invent a contrast with a column you did not read.** When a new column
|
|
365
|
+
shares a name with one on another table (`position_order`, `status_id`,
|
|
366
|
+
`lap`), the tempting sentence is "here it means X, unlike there". A schema
|
|
367
|
+
dump cannot support that. Open the sibling's description and reuse it if it
|
|
368
|
+
applies; if you can't confirm the relationship, say it's unverified. A false
|
|
369
|
+
distinction drawn confidently survives review as a documented decision.
|
|
360
370
|
|
|
361
371
|
---
|
|
362
372
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""`cassis schema` — local snapshot of the data source's source schema.
|
|
2
|
+
|
|
3
|
+
The source schema is OBSERVED state (the warehouse is authoritative), so the
|
|
4
|
+
snapshot is a gitignored cache, never a committed file: `pull` writes
|
|
5
|
+
`<base-path>/.schema.json` and keeps it out of git via the ontology dir's
|
|
6
|
+
`.gitignore`. Agents working in a checkout grep it instead of paging through
|
|
7
|
+
the MCP `get_source_schema` tool; `pulled_at` records how stale it is.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
from cassis_cli.api import DEFAULT_API_URL, ApiError, AuthError, get_schema_export
|
|
19
|
+
from cassis_cli.common import (
|
|
20
|
+
DEFAULT_BASE_PATH,
|
|
21
|
+
EXIT_OK,
|
|
22
|
+
EXIT_TRANSPORT,
|
|
23
|
+
EXIT_USAGE,
|
|
24
|
+
require_api_key,
|
|
25
|
+
resolve_project_id,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
app = typer.Typer(help="Pull a local, gitignored snapshot of the data source's schema.")
|
|
29
|
+
|
|
30
|
+
SNAPSHOT_FILENAME = ".schema.json"
|
|
31
|
+
_GITIGNORE_HEADER = "# Cassis local caches (observed state — never commit)"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command()
|
|
35
|
+
def pull(
|
|
36
|
+
path: Path = typer.Argument(
|
|
37
|
+
Path("."),
|
|
38
|
+
help="Repository checkout root (the directory containing the ontology export path).",
|
|
39
|
+
),
|
|
40
|
+
project_id: Optional[str] = typer.Option(
|
|
41
|
+
None,
|
|
42
|
+
"--project",
|
|
43
|
+
envvar="CASSIS_PROJECT_ID",
|
|
44
|
+
help="Target Cassis project ID (UUID). Defaults to the id in <base-path>/project.yml.",
|
|
45
|
+
),
|
|
46
|
+
api_key: Optional[str] = typer.Option(
|
|
47
|
+
None,
|
|
48
|
+
"--api-key",
|
|
49
|
+
envvar="CASSIS_API_KEY",
|
|
50
|
+
help="Cassis API key (sk-k6-...). Create one in Organization settings -> API keys.",
|
|
51
|
+
),
|
|
52
|
+
api_url: str = typer.Option(
|
|
53
|
+
DEFAULT_API_URL,
|
|
54
|
+
"--api-url",
|
|
55
|
+
envvar="CASSIS_API_URL",
|
|
56
|
+
help="Cassis API base URL.",
|
|
57
|
+
),
|
|
58
|
+
base_path: str = typer.Option(
|
|
59
|
+
DEFAULT_BASE_PATH,
|
|
60
|
+
"--base-path",
|
|
61
|
+
envvar="CASSIS_BASE_PATH",
|
|
62
|
+
help="Repository directory the ontology is exported under (the project's git-sync Path setting).",
|
|
63
|
+
),
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Download the source schema into `<base-path>/.schema.json` (gitignored).
|
|
66
|
+
|
|
67
|
+
The snapshot is the schema as Cassis last introspected it from the
|
|
68
|
+
warehouse (or parsed from an uploaded DDL) — every table with its columns
|
|
69
|
+
and types, plus a `pulled_at` stamp so staleness is visible. Re-run after
|
|
70
|
+
a warehouse sync to refresh. Exits 0 on success, 2 on usage errors, 3 on
|
|
71
|
+
transport/API errors.
|
|
72
|
+
"""
|
|
73
|
+
api_key = require_api_key(api_key)
|
|
74
|
+
ontology_dir = path / base_path
|
|
75
|
+
project_id = resolve_project_id(project_id, ontology_dir)
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
result = get_schema_export(api_url=api_url, api_key=api_key, project_id=project_id)
|
|
79
|
+
except (AuthError, ApiError) as exc:
|
|
80
|
+
# NoSourceSchemaError lands here too: the server's message already says
|
|
81
|
+
# what to do (sync or upload a DDL); the class exists so api.py doesn't
|
|
82
|
+
# bury it under the misleading project-scope hint.
|
|
83
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
84
|
+
raise typer.Exit(EXIT_TRANSPORT) from exc
|
|
85
|
+
|
|
86
|
+
snapshot = {
|
|
87
|
+
"pulled_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
88
|
+
"project_id": project_id,
|
|
89
|
+
"schema_version": result["schema_version"],
|
|
90
|
+
"tables": result["tables"],
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
ontology_dir.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
snapshot_path = ontology_dir / SNAPSHOT_FILENAME
|
|
96
|
+
snapshot_path.write_text(json.dumps(snapshot, indent=2) + "\n", encoding="utf-8")
|
|
97
|
+
ensure_gitignored(ontology_dir)
|
|
98
|
+
except OSError as exc:
|
|
99
|
+
# Usage-class exit (2), like the other local-file failures in the exit
|
|
100
|
+
# table — the API call succeeded, the checkout is what's broken.
|
|
101
|
+
typer.secho(f"Could not write the snapshot: {exc}", fg=typer.colors.RED, err=True)
|
|
102
|
+
raise typer.Exit(EXIT_USAGE) from exc
|
|
103
|
+
|
|
104
|
+
table_count = len(result["tables"])
|
|
105
|
+
column_count = sum(len(t.get("columns") or []) for t in result["tables"])
|
|
106
|
+
version = result["schema_version"].get("version")
|
|
107
|
+
typer.secho(
|
|
108
|
+
f"✓ Pulled source schema v{version}: {table_count} tables, {column_count} columns "
|
|
109
|
+
f"-> {snapshot_path} (gitignored)",
|
|
110
|
+
fg=typer.colors.GREEN,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def ensure_gitignored(ontology_dir: Path) -> None:
|
|
115
|
+
"""Make sure the snapshot never lands in git: keep `.gitignore` covering it.
|
|
116
|
+
|
|
117
|
+
Appends to (or creates) the ontology dir's own `.gitignore` — local to the
|
|
118
|
+
export directory, so it survives repo-level `.gitignore` rewrites and needs
|
|
119
|
+
no knowledge of the checkout layout.
|
|
120
|
+
"""
|
|
121
|
+
gitignore = ontology_dir / ".gitignore"
|
|
122
|
+
try:
|
|
123
|
+
existing = gitignore.read_text(encoding="utf-8")
|
|
124
|
+
except (OSError, UnicodeDecodeError) as _exc: # `as` keeps black from stripping the parens (3.14-only syntax)
|
|
125
|
+
existing = ""
|
|
126
|
+
if SNAPSHOT_FILENAME in existing.splitlines():
|
|
127
|
+
return
|
|
128
|
+
prefix = "" if not existing else existing.rstrip("\n") + "\n"
|
|
129
|
+
gitignore.write_text(f"{prefix}{_GITIGNORE_HEADER}\n{SNAPSHOT_FILENAME}\n", encoding="utf-8")
|
|
File without changes
|
|
File without changes
|