cassis-cli 1.2.0__tar.gz → 1.3.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.2.0 → cassis_cli-1.3.0}/PKG-INFO +32 -3
- {cassis_cli-1.2.0 → cassis_cli-1.3.0}/README.md +31 -2
- {cassis_cli-1.2.0 → cassis_cli-1.3.0}/cassis_cli/api.py +116 -0
- {cassis_cli-1.2.0 → cassis_cli-1.3.0}/cassis_cli/eval.py +44 -7
- {cassis_cli-1.2.0 → cassis_cli-1.3.0}/cassis_cli/main.py +6 -0
- cassis_cli-1.3.0/cassis_cli/projects.py +69 -0
- cassis_cli-1.3.0/cassis_cli/schema.py +295 -0
- cassis_cli-1.3.0/cassis_cli/status.py +184 -0
- cassis_cli-1.3.0/cassis_cli/verify.py +123 -0
- {cassis_cli-1.2.0 → cassis_cli-1.3.0}/pyproject.toml +1 -1
- cassis_cli-1.2.0/cassis_cli/schema.py +0 -129
- {cassis_cli-1.2.0 → cassis_cli-1.3.0}/LICENSE +0 -0
- {cassis_cli-1.2.0 → cassis_cli-1.3.0}/NOTICE +0 -0
- {cassis_cli-1.2.0 → cassis_cli-1.3.0}/cassis_cli/__init__.py +0 -0
- {cassis_cli-1.2.0 → cassis_cli-1.3.0}/cassis_cli/common.py +0 -0
- {cassis_cli-1.2.0 → cassis_cli-1.3.0}/cassis_cli/guide.py +0 -0
- {cassis_cli-1.2.0 → cassis_cli-1.3.0}/cassis_cli/ontology.py +0 -0
- {cassis_cli-1.2.0 → cassis_cli-1.3.0}/cassis_cli/ontology_design_guide.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cassis-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.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
|
|
@@ -35,6 +35,10 @@ Run Cassis actions from your CI pipelines:
|
|
|
35
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.
|
|
36
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
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).
|
|
38
|
+
- `cassis schema push` uploads a DDL file to detect source-schema changes on a DDL-only project (same as the webapp's "Update from DDL" button): Cassis diffs the DDL against the ontology and surfaces added, dropped, and changed objects in Ontology > Review > Data source for approval. Waits for completion by default; `--no-wait` returns immediately.
|
|
39
|
+
- `cassis projects list` lists the projects your API key can reach — id (what `--project` and `CASSIS_PROJECT_ID` take), name, published ontology version, and data-source dialect — so a pipeline or agent can discover the project id from the terminal instead of fishing it out of a webapp URL.
|
|
40
|
+
- `cassis status` shows the project's published version (number, label, git commit), whether unpublished changes await publication, the git-sync binding, and how your local git HEAD relates to the published commit (in sync / N commits ahead / diverged). `cassis status --watch` polls until the published commit matches your local HEAD — e.g. right after merging a PR whose CI publishes the ontology — instead of watching the GitHub Actions tab.
|
|
41
|
+
- `cassis verify` runs the full local gate in one verb — `ontology fmt --check`, `ontology check`, `eval run` — stopping at the first failure. One command in a checkout ("is this change safe to merge?"), one job in CI. `--no-eval` skips the eval suite.
|
|
38
42
|
|
|
39
43
|
## Install
|
|
40
44
|
|
|
@@ -56,7 +60,7 @@ The ontology tree under `<base-path>` (default `cassis/`) is:
|
|
|
56
60
|
|
|
57
61
|
1. Create an API key in Cassis under **Organization settings → API keys** (keys start with `sk-k6-`).
|
|
58
62
|
2. Store it as a CI secret and expose it as `CASSIS_API_KEY`.
|
|
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).
|
|
63
|
+
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 with `cassis projects list`, or 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).
|
|
60
64
|
|
|
61
65
|
## Usage
|
|
62
66
|
|
|
@@ -93,6 +97,9 @@ cassis eval run --project ...
|
|
|
93
97
|
# Run against an existing Cassis ontology branch, or the unpublished ontology:
|
|
94
98
|
cassis eval run --project ... --branch feature-x
|
|
95
99
|
|
|
100
|
+
# Run only specific cases (repeatable) — e.g. prove a fresh add-case in seconds:
|
|
101
|
+
cassis eval run --project ... --case 019f0000-0000-7000-8000-0000000000ca
|
|
102
|
+
|
|
96
103
|
# Start the run and return immediately (poll in the webapp):
|
|
97
104
|
cassis eval run --project ... --no-wait
|
|
98
105
|
|
|
@@ -104,12 +111,33 @@ cassis ontology test --project ... -q "How much was refunded last month?" -q "Ne
|
|
|
104
111
|
cassis eval add-case --project ... -q "How much was refunded last month?" \
|
|
105
112
|
--gold-sql "SELECT SUM(refunded_cents) / 100.0 FROM public.orders WHERE ..."
|
|
106
113
|
|
|
114
|
+
# Multi-line gold SQL: read it from a file instead (no shell quoting pitfalls):
|
|
115
|
+
cassis eval add-case --project ... -q "How much was refunded last month?" \
|
|
116
|
+
--gold-sql-file refunds.sql
|
|
117
|
+
|
|
107
118
|
# List the suite's cases (id + question; --json adds the gold SQL), then prune one:
|
|
108
119
|
cassis eval list-cases --project ...
|
|
109
120
|
cassis eval delete-case 019f0000-0000-7000-8000-0000000000ca --project ...
|
|
110
121
|
|
|
111
122
|
# Pull the source schema into <base-path>/.schema.json (gitignored local snapshot):
|
|
112
123
|
cassis schema pull
|
|
124
|
+
|
|
125
|
+
# Push a DDL file to detect source-schema changes (DDL-only projects):
|
|
126
|
+
cassis schema push schema.sql
|
|
127
|
+
|
|
128
|
+
# Push and return immediately (poll in the webapp):
|
|
129
|
+
cassis schema push schema.sql --no-wait
|
|
130
|
+
|
|
131
|
+
# List the projects the API key can reach (id, name, published version, dialect):
|
|
132
|
+
cassis projects list
|
|
133
|
+
|
|
134
|
+
# Published version vs local checkout (add --watch to poll until your merge is published):
|
|
135
|
+
cassis status
|
|
136
|
+
cassis status --watch --timeout 600
|
|
137
|
+
|
|
138
|
+
# The full local gate in one verb (fmt --check, check, eval run; stops at the first failure):
|
|
139
|
+
cassis verify
|
|
140
|
+
cassis verify --no-eval
|
|
113
141
|
```
|
|
114
142
|
|
|
115
143
|
Configuration (flags take precedence over env vars):
|
|
@@ -121,7 +149,8 @@ Configuration (flags take precedence over env vars):
|
|
|
121
149
|
| `--base-path` | `CASSIS_BASE_PATH` | `cassis` — must match the project's git-sync "Path" setting |
|
|
122
150
|
| `--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) |
|
|
123
151
|
|
|
124
|
-
`cassis eval run` also accepts `--
|
|
152
|
+
`cassis eval run` also accepts `--case <id>` (repeatable; run only the named
|
|
153
|
+
cases, ids from `eval list-cases` or `add-case`), `--label` (run label in the Evals page; defaults
|
|
125
154
|
to the branch name from the CI environment or the local git checkout; rejected
|
|
126
155
|
with `--branch`, whose runs are labelled with the branch name), `--wait/--no-wait`, `--poll-interval` (5 s),
|
|
127
156
|
`--timeout` (30 min — the run keeps going server-side if the CLI stops waiting),
|
|
@@ -13,6 +13,10 @@ Run Cassis actions from your CI pipelines:
|
|
|
13
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.
|
|
14
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
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).
|
|
16
|
+
- `cassis schema push` uploads a DDL file to detect source-schema changes on a DDL-only project (same as the webapp's "Update from DDL" button): Cassis diffs the DDL against the ontology and surfaces added, dropped, and changed objects in Ontology > Review > Data source for approval. Waits for completion by default; `--no-wait` returns immediately.
|
|
17
|
+
- `cassis projects list` lists the projects your API key can reach — id (what `--project` and `CASSIS_PROJECT_ID` take), name, published ontology version, and data-source dialect — so a pipeline or agent can discover the project id from the terminal instead of fishing it out of a webapp URL.
|
|
18
|
+
- `cassis status` shows the project's published version (number, label, git commit), whether unpublished changes await publication, the git-sync binding, and how your local git HEAD relates to the published commit (in sync / N commits ahead / diverged). `cassis status --watch` polls until the published commit matches your local HEAD — e.g. right after merging a PR whose CI publishes the ontology — instead of watching the GitHub Actions tab.
|
|
19
|
+
- `cassis verify` runs the full local gate in one verb — `ontology fmt --check`, `ontology check`, `eval run` — stopping at the first failure. One command in a checkout ("is this change safe to merge?"), one job in CI. `--no-eval` skips the eval suite.
|
|
16
20
|
|
|
17
21
|
## Install
|
|
18
22
|
|
|
@@ -34,7 +38,7 @@ The ontology tree under `<base-path>` (default `cassis/`) is:
|
|
|
34
38
|
|
|
35
39
|
1. Create an API key in Cassis under **Organization settings → API keys** (keys start with `sk-k6-`).
|
|
36
40
|
2. Store it as a CI secret and expose it as `CASSIS_API_KEY`.
|
|
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).
|
|
41
|
+
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 with `cassis projects list`, or 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).
|
|
38
42
|
|
|
39
43
|
## Usage
|
|
40
44
|
|
|
@@ -71,6 +75,9 @@ cassis eval run --project ...
|
|
|
71
75
|
# Run against an existing Cassis ontology branch, or the unpublished ontology:
|
|
72
76
|
cassis eval run --project ... --branch feature-x
|
|
73
77
|
|
|
78
|
+
# Run only specific cases (repeatable) — e.g. prove a fresh add-case in seconds:
|
|
79
|
+
cassis eval run --project ... --case 019f0000-0000-7000-8000-0000000000ca
|
|
80
|
+
|
|
74
81
|
# Start the run and return immediately (poll in the webapp):
|
|
75
82
|
cassis eval run --project ... --no-wait
|
|
76
83
|
|
|
@@ -82,12 +89,33 @@ cassis ontology test --project ... -q "How much was refunded last month?" -q "Ne
|
|
|
82
89
|
cassis eval add-case --project ... -q "How much was refunded last month?" \
|
|
83
90
|
--gold-sql "SELECT SUM(refunded_cents) / 100.0 FROM public.orders WHERE ..."
|
|
84
91
|
|
|
92
|
+
# Multi-line gold SQL: read it from a file instead (no shell quoting pitfalls):
|
|
93
|
+
cassis eval add-case --project ... -q "How much was refunded last month?" \
|
|
94
|
+
--gold-sql-file refunds.sql
|
|
95
|
+
|
|
85
96
|
# List the suite's cases (id + question; --json adds the gold SQL), then prune one:
|
|
86
97
|
cassis eval list-cases --project ...
|
|
87
98
|
cassis eval delete-case 019f0000-0000-7000-8000-0000000000ca --project ...
|
|
88
99
|
|
|
89
100
|
# Pull the source schema into <base-path>/.schema.json (gitignored local snapshot):
|
|
90
101
|
cassis schema pull
|
|
102
|
+
|
|
103
|
+
# Push a DDL file to detect source-schema changes (DDL-only projects):
|
|
104
|
+
cassis schema push schema.sql
|
|
105
|
+
|
|
106
|
+
# Push and return immediately (poll in the webapp):
|
|
107
|
+
cassis schema push schema.sql --no-wait
|
|
108
|
+
|
|
109
|
+
# List the projects the API key can reach (id, name, published version, dialect):
|
|
110
|
+
cassis projects list
|
|
111
|
+
|
|
112
|
+
# Published version vs local checkout (add --watch to poll until your merge is published):
|
|
113
|
+
cassis status
|
|
114
|
+
cassis status --watch --timeout 600
|
|
115
|
+
|
|
116
|
+
# The full local gate in one verb (fmt --check, check, eval run; stops at the first failure):
|
|
117
|
+
cassis verify
|
|
118
|
+
cassis verify --no-eval
|
|
91
119
|
```
|
|
92
120
|
|
|
93
121
|
Configuration (flags take precedence over env vars):
|
|
@@ -99,7 +127,8 @@ Configuration (flags take precedence over env vars):
|
|
|
99
127
|
| `--base-path` | `CASSIS_BASE_PATH` | `cassis` — must match the project's git-sync "Path" setting |
|
|
100
128
|
| `--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) |
|
|
101
129
|
|
|
102
|
-
`cassis eval run` also accepts `--
|
|
130
|
+
`cassis eval run` also accepts `--case <id>` (repeatable; run only the named
|
|
131
|
+
cases, ids from `eval list-cases` or `add-case`), `--label` (run label in the Evals page; defaults
|
|
103
132
|
to the branch name from the CI environment or the local git checkout; rejected
|
|
104
133
|
with `--branch`, whose runs are labelled with the branch name), `--wait/--no-wait`, `--poll-interval` (5 s),
|
|
105
134
|
`--timeout` (30 min — the run keeps going server-side if the CLI stops waiting),
|
|
@@ -243,6 +243,57 @@ def get_ontology_export(
|
|
|
243
243
|
return result["files"]
|
|
244
244
|
|
|
245
245
|
|
|
246
|
+
def get_projects(
|
|
247
|
+
*,
|
|
248
|
+
api_url: str,
|
|
249
|
+
api_key: str,
|
|
250
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
251
|
+
) -> list[dict[str, Any]]:
|
|
252
|
+
"""GET /api/ci/projects and return the project list."""
|
|
253
|
+
url = api_url.rstrip("/") + "/api/ci/projects"
|
|
254
|
+
try:
|
|
255
|
+
with _client(transport=transport) as client:
|
|
256
|
+
response = client.get(url, headers={"Authorization": f"Bearer {api_key}"})
|
|
257
|
+
except httpx.HTTPError as exc:
|
|
258
|
+
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
259
|
+
|
|
260
|
+
if response.status_code == 401:
|
|
261
|
+
raise AuthError("The Cassis API rejected the API key (invalid or expired).")
|
|
262
|
+
if response.status_code >= 400:
|
|
263
|
+
raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
|
|
264
|
+
result = _parse_json_response(response, url)
|
|
265
|
+
if not isinstance(result, list) or not all(isinstance(p, dict) and "id" in p and "name" in p for p in result):
|
|
266
|
+
raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
|
|
267
|
+
return result
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def get_project_status(
|
|
271
|
+
*,
|
|
272
|
+
api_url: str,
|
|
273
|
+
api_key: str,
|
|
274
|
+
project_id: str,
|
|
275
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
276
|
+
) -> dict[str, Any]:
|
|
277
|
+
"""GET /api/ci/projects/{project_id}/status and return the status record."""
|
|
278
|
+
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/status"
|
|
279
|
+
try:
|
|
280
|
+
with _client(transport=transport) as client:
|
|
281
|
+
response = client.get(url, headers={"Authorization": f"Bearer {api_key}"})
|
|
282
|
+
except httpx.HTTPError as exc:
|
|
283
|
+
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
284
|
+
|
|
285
|
+
if response.status_code == 401:
|
|
286
|
+
raise AuthError("The Cassis API rejected the API key (invalid or expired).")
|
|
287
|
+
if response.status_code in (403, 404):
|
|
288
|
+
raise _project_scope_error(response)
|
|
289
|
+
if response.status_code >= 400:
|
|
290
|
+
raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
|
|
291
|
+
result = _parse_json_response(response, url)
|
|
292
|
+
if not isinstance(result, dict) or "has_unpublished_changes" not in result:
|
|
293
|
+
raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
|
|
294
|
+
return result
|
|
295
|
+
|
|
296
|
+
|
|
246
297
|
class NoSourceSchemaError(ApiError):
|
|
247
298
|
"""The project's data source has no introspected schema to pull."""
|
|
248
299
|
|
|
@@ -284,6 +335,68 @@ def get_schema_export(
|
|
|
284
335
|
return result
|
|
285
336
|
|
|
286
337
|
|
|
338
|
+
class SourceChangeConflictError(ApiError):
|
|
339
|
+
"""A concurrent detection run is already active, or the project has a connected data source."""
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def post_detect_from_ddl(
|
|
343
|
+
*,
|
|
344
|
+
api_url: str,
|
|
345
|
+
api_key: str,
|
|
346
|
+
project_id: str,
|
|
347
|
+
ddl: str,
|
|
348
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
349
|
+
) -> dict[str, Any]:
|
|
350
|
+
"""POST /api/ci/projects/{project_id}/source-changes/detect-from-ddl and return the run record."""
|
|
351
|
+
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/source-changes/detect-from-ddl"
|
|
352
|
+
try:
|
|
353
|
+
with _client(transport=transport) as client:
|
|
354
|
+
response = client.post(url, json={"ddl": ddl}, headers={"Authorization": f"Bearer {api_key}"})
|
|
355
|
+
except httpx.HTTPError as exc:
|
|
356
|
+
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
357
|
+
|
|
358
|
+
if response.status_code == 401:
|
|
359
|
+
raise AuthError("The Cassis API rejected the API key (invalid or expired).")
|
|
360
|
+
if response.status_code == 409:
|
|
361
|
+
raise SourceChangeConflictError(str(_detail_or_text(response)))
|
|
362
|
+
if response.status_code in (403, 404):
|
|
363
|
+
raise _project_scope_error(response)
|
|
364
|
+
if response.status_code >= 400:
|
|
365
|
+
raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
|
|
366
|
+
result = _parse_json_response(response, url)
|
|
367
|
+
if not isinstance(result, dict) or "run_id" not in result:
|
|
368
|
+
raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
|
|
369
|
+
return result
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def get_source_change_run(
|
|
373
|
+
*,
|
|
374
|
+
api_url: str,
|
|
375
|
+
api_key: str,
|
|
376
|
+
project_id: str,
|
|
377
|
+
run_id: str,
|
|
378
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
379
|
+
) -> dict[str, Any]:
|
|
380
|
+
"""GET /api/ci/projects/{project_id}/source-changes/runs/{run_id} and return the run record."""
|
|
381
|
+
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/source-changes/runs/{run_id}"
|
|
382
|
+
try:
|
|
383
|
+
with _client(transport=transport) as client:
|
|
384
|
+
response = client.get(url, headers={"Authorization": f"Bearer {api_key}"})
|
|
385
|
+
except httpx.HTTPError as exc:
|
|
386
|
+
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
387
|
+
|
|
388
|
+
if response.status_code == 401:
|
|
389
|
+
raise AuthError("The Cassis API rejected the API key (invalid or expired).")
|
|
390
|
+
if response.status_code in (403, 404):
|
|
391
|
+
raise _project_scope_error(response)
|
|
392
|
+
if response.status_code >= 400:
|
|
393
|
+
raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
|
|
394
|
+
result = _parse_json_response(response, url)
|
|
395
|
+
if not isinstance(result, dict) or "run_id" not in result:
|
|
396
|
+
raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
|
|
397
|
+
return result
|
|
398
|
+
|
|
399
|
+
|
|
287
400
|
def post_eval_run_start(
|
|
288
401
|
*,
|
|
289
402
|
api_url: str,
|
|
@@ -292,6 +405,7 @@ def post_eval_run_start(
|
|
|
292
405
|
files: Optional[dict[str, str]] = None,
|
|
293
406
|
branch: Optional[str] = None,
|
|
294
407
|
label: Optional[str] = None,
|
|
408
|
+
case_ids: Optional[list[str]] = None,
|
|
295
409
|
transport: Optional[httpx.BaseTransport] = None,
|
|
296
410
|
) -> dict[str, Any]:
|
|
297
411
|
"""POST to /api/ci/projects/{project_id}/eval/runs and return the run record."""
|
|
@@ -303,6 +417,8 @@ def post_eval_run_start(
|
|
|
303
417
|
body["branch"] = branch
|
|
304
418
|
if label is not None:
|
|
305
419
|
body["label"] = label
|
|
420
|
+
if case_ids is not None:
|
|
421
|
+
body["test_case_ids"] = case_ids
|
|
306
422
|
try:
|
|
307
423
|
with _client(transport=transport) as client:
|
|
308
424
|
response = client.post(url, json=body, headers={"Authorization": f"Bearer {api_key}"})
|
|
@@ -7,7 +7,7 @@ import os
|
|
|
7
7
|
import subprocess
|
|
8
8
|
import time
|
|
9
9
|
from pathlib import Path
|
|
10
|
-
from typing import Any, Optional
|
|
10
|
+
from typing import Any, List, Optional
|
|
11
11
|
from uuid import UUID
|
|
12
12
|
|
|
13
13
|
import typer
|
|
@@ -164,11 +164,16 @@ def add_case(
|
|
|
164
164
|
"--question",
|
|
165
165
|
help="The natural-language question the case guards.",
|
|
166
166
|
),
|
|
167
|
-
gold_sql: str = typer.Option(
|
|
168
|
-
|
|
167
|
+
gold_sql: Optional[str] = typer.Option(
|
|
168
|
+
None,
|
|
169
169
|
"--gold-sql",
|
|
170
170
|
help="The correct SQL for the question; executed at run time to produce the expected output.",
|
|
171
171
|
),
|
|
172
|
+
gold_sql_file: Optional[Path] = typer.Option(
|
|
173
|
+
None,
|
|
174
|
+
"--gold-sql-file",
|
|
175
|
+
help="Read the gold SQL from this file instead of --gold-sql (no shell quoting of multi-line SQL).",
|
|
176
|
+
),
|
|
172
177
|
api_key: Optional[str] = typer.Option(
|
|
173
178
|
None,
|
|
174
179
|
"--api-key",
|
|
@@ -195,13 +200,31 @@ def add_case(
|
|
|
195
200
|
failing on becomes a gold case, so `cassis eval run` guards it from
|
|
196
201
|
regressing. On an executable data source the gold SQL is run before the
|
|
197
202
|
case is stored, so a case that cannot execute never enters the suite.
|
|
203
|
+
The SQL comes from --gold-sql (inline) or --gold-sql-file (a file path;
|
|
204
|
+
prefer it for multi-line SQL, which shell quoting mangles inline).
|
|
198
205
|
Exits 0 on creation, 1 on a duplicate question or gold SQL that does not
|
|
199
206
|
run, 2 on usage errors, 3 on transport/API errors.
|
|
200
207
|
"""
|
|
201
208
|
api_key = require_api_key(api_key)
|
|
202
209
|
project_id = resolve_project_id(project_id, path / Path(base_path))
|
|
210
|
+
if (gold_sql is None) == (gold_sql_file is None):
|
|
211
|
+
typer.secho("Pass exactly one of --gold-sql or --gold-sql-file.", fg=typer.colors.RED, err=True)
|
|
212
|
+
raise typer.Exit(EXIT_USAGE)
|
|
213
|
+
if gold_sql_file is not None:
|
|
214
|
+
try:
|
|
215
|
+
gold_sql = gold_sql_file.read_text(encoding="utf-8")
|
|
216
|
+
# Two separate handlers: the repo-wide black targets py314 and would
|
|
217
|
+
# strip the parens off a tuple form, which is a SyntaxError on the
|
|
218
|
+
# CLI's supported Python (>=3.10).
|
|
219
|
+
except OSError as exc:
|
|
220
|
+
typer.secho(f"Cannot read {gold_sql_file}: {exc}", fg=typer.colors.RED, err=True)
|
|
221
|
+
raise typer.Exit(EXIT_USAGE) from exc
|
|
222
|
+
except UnicodeDecodeError as exc:
|
|
223
|
+
typer.secho(f"Cannot read {gold_sql_file}: {exc}", fg=typer.colors.RED, err=True)
|
|
224
|
+
raise typer.Exit(EXIT_USAGE) from exc
|
|
225
|
+
assert gold_sql is not None
|
|
203
226
|
if not question.strip() or not gold_sql.strip():
|
|
204
|
-
typer.secho("--question and
|
|
227
|
+
typer.secho("--question and the gold SQL must not be empty.", fg=typer.colors.RED, err=True)
|
|
205
228
|
raise typer.Exit(EXIT_USAGE)
|
|
206
229
|
|
|
207
230
|
try:
|
|
@@ -388,6 +411,11 @@ def run(
|
|
|
388
411
|
"--branch",
|
|
389
412
|
help="Run against an existing Cassis ontology branch by name instead of local files.",
|
|
390
413
|
),
|
|
414
|
+
case: Optional[List[str]] = typer.Option(
|
|
415
|
+
None,
|
|
416
|
+
"--case",
|
|
417
|
+
help="Run only this eval case id (repeatable; ids from `eval list-cases` or `add-case`).",
|
|
418
|
+
),
|
|
391
419
|
label: Optional[str] = typer.Option(
|
|
392
420
|
None,
|
|
393
421
|
"--label",
|
|
@@ -412,11 +440,19 @@ def run(
|
|
|
412
440
|
|
|
413
441
|
Uploads the local ontology file tree and scores it in-memory — nothing is pushed or
|
|
414
442
|
persisted in Cassis besides the eval run itself. With --branch, runs against
|
|
415
|
-
an existing Cassis branch instead (no files are sent).
|
|
416
|
-
|
|
417
|
-
|
|
443
|
+
an existing Cassis branch instead (no files are sent). With --case, only the
|
|
444
|
+
named case(s) run — e.g. proving one fresh `add-case` in seconds instead of
|
|
445
|
+
rerunning the whole suite. Exits 0 when the run completes with every case
|
|
446
|
+
passed, 1 on any failed case / failed run / invalid tree, 2 on usage
|
|
447
|
+
errors, 3 on transport errors or --timeout.
|
|
418
448
|
"""
|
|
419
449
|
api_key = require_api_key(api_key)
|
|
450
|
+
for case_id in case or []:
|
|
451
|
+
try:
|
|
452
|
+
UUID(case_id)
|
|
453
|
+
except ValueError:
|
|
454
|
+
typer.secho(f"--case must be an eval case ID (UUID), got {case_id!r}.", fg=typer.colors.RED, err=True)
|
|
455
|
+
raise typer.Exit(EXIT_USAGE)
|
|
420
456
|
if branch is not None and label is not None:
|
|
421
457
|
typer.secho(
|
|
422
458
|
"--label cannot be used with --branch: branch runs are labelled with the branch name.",
|
|
@@ -440,6 +476,7 @@ def run(
|
|
|
440
476
|
files=files,
|
|
441
477
|
branch=branch,
|
|
442
478
|
label=label,
|
|
479
|
+
case_ids=case,
|
|
443
480
|
)
|
|
444
481
|
except EvalStartValidationError as exc:
|
|
445
482
|
_print_validation_failure(exc.detail, base_path)
|
|
@@ -6,7 +6,10 @@ 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.projects import app as projects_app
|
|
9
10
|
from cassis_cli.schema import app as schema_app
|
|
11
|
+
from cassis_cli.status import status
|
|
12
|
+
from cassis_cli.verify import verify
|
|
10
13
|
|
|
11
14
|
app = typer.Typer(
|
|
12
15
|
no_args_is_help=True,
|
|
@@ -15,6 +18,9 @@ app = typer.Typer(
|
|
|
15
18
|
app.add_typer(ontology_app, name="ontology")
|
|
16
19
|
app.add_typer(eval_app, name="eval")
|
|
17
20
|
app.add_typer(schema_app, name="schema")
|
|
21
|
+
app.add_typer(projects_app, name="projects")
|
|
22
|
+
app.command()(status)
|
|
23
|
+
app.command()(verify)
|
|
18
24
|
|
|
19
25
|
|
|
20
26
|
@app.command()
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""`cassis projects` — discover the projects an API key can reach.
|
|
2
|
+
|
|
3
|
+
Every other CI route is project-scoped, so the very first thing a pipeline or
|
|
4
|
+
checkout agent needs is a project id. `projects list` answers that from the
|
|
5
|
+
terminal instead of fishing the UUID out of a webapp URL.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from cassis_cli.api import DEFAULT_API_URL, ApiError, AuthError, get_projects
|
|
15
|
+
from cassis_cli.common import EXIT_OK, EXIT_TRANSPORT, require_api_key
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(help="Discover the projects this API key can reach.")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command(name="list")
|
|
21
|
+
def list_projects(
|
|
22
|
+
api_key: Optional[str] = typer.Option(
|
|
23
|
+
None,
|
|
24
|
+
"--api-key",
|
|
25
|
+
envvar="CASSIS_API_KEY",
|
|
26
|
+
help="Cassis API key (sk-k6-...). Create one in Organization settings -> API keys.",
|
|
27
|
+
),
|
|
28
|
+
api_url: str = typer.Option(
|
|
29
|
+
DEFAULT_API_URL,
|
|
30
|
+
"--api-url",
|
|
31
|
+
envvar="CASSIS_API_URL",
|
|
32
|
+
help="Cassis API base URL.",
|
|
33
|
+
),
|
|
34
|
+
json_output: bool = typer.Option(False, "--json", help="Print the raw JSON response."),
|
|
35
|
+
) -> None:
|
|
36
|
+
"""List the projects available to the API key.
|
|
37
|
+
|
|
38
|
+
Prints each project's id (what --project and CASSIS_PROJECT_ID take), name,
|
|
39
|
+
published ontology version, and data-source dialect. A schema-only source
|
|
40
|
+
(no connection) is marked "not executable": SQL is generated but never run.
|
|
41
|
+
Exits 0 on success, 2 on usage errors, 3 on transport/API errors.
|
|
42
|
+
"""
|
|
43
|
+
api_key = require_api_key(api_key)
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
projects = get_projects(api_url=api_url, api_key=api_key)
|
|
47
|
+
except (AuthError, ApiError) as exc:
|
|
48
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
49
|
+
raise typer.Exit(EXIT_TRANSPORT) from exc
|
|
50
|
+
|
|
51
|
+
if json_output:
|
|
52
|
+
typer.echo(json.dumps(projects, indent=2))
|
|
53
|
+
raise typer.Exit(EXIT_OK)
|
|
54
|
+
|
|
55
|
+
if not projects:
|
|
56
|
+
typer.echo("No projects visible to this API key.")
|
|
57
|
+
raise typer.Exit(EXIT_OK)
|
|
58
|
+
|
|
59
|
+
for project in projects:
|
|
60
|
+
version = project.get("published_version")
|
|
61
|
+
version_text = f"v{version}" if version is not None else "unpublished"
|
|
62
|
+
source = project.get("data_source")
|
|
63
|
+
if source:
|
|
64
|
+
dialect = source.get("sql_dialect") or "?"
|
|
65
|
+
source_text = dialect if source.get("is_executable") else f"{dialect}, not executable"
|
|
66
|
+
else:
|
|
67
|
+
source_text = "no data source"
|
|
68
|
+
typer.echo(f"{project['id']} {project['name']} ({version_text}; {source_text})")
|
|
69
|
+
raise typer.Exit(EXIT_OK)
|
|
@@ -0,0 +1,295 @@
|
|
|
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
|
+
import time
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Optional
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
from cassis_cli.api import (
|
|
20
|
+
DEFAULT_API_URL,
|
|
21
|
+
ApiError,
|
|
22
|
+
AuthError,
|
|
23
|
+
SourceChangeConflictError,
|
|
24
|
+
get_schema_export,
|
|
25
|
+
get_source_change_run,
|
|
26
|
+
post_detect_from_ddl,
|
|
27
|
+
)
|
|
28
|
+
from cassis_cli.common import (
|
|
29
|
+
DEFAULT_BASE_PATH,
|
|
30
|
+
EXIT_OK,
|
|
31
|
+
EXIT_TRANSPORT,
|
|
32
|
+
EXIT_USAGE,
|
|
33
|
+
EXIT_VALIDATION_FAILED,
|
|
34
|
+
require_api_key,
|
|
35
|
+
resolve_project_id,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
app = typer.Typer(help="Pull or push the data source's schema.")
|
|
39
|
+
|
|
40
|
+
_TERMINAL_RUN_STATUSES = {"completed", "failed", "cancelled"}
|
|
41
|
+
_MAX_CONSECUTIVE_POLL_FAILURES = 5
|
|
42
|
+
|
|
43
|
+
SNAPSHOT_FILENAME = ".schema.json"
|
|
44
|
+
_GITIGNORE_HEADER = "# Cassis local caches (observed state — never commit)"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.command()
|
|
48
|
+
def pull(
|
|
49
|
+
path: Path = typer.Argument(
|
|
50
|
+
Path("."),
|
|
51
|
+
help="Repository checkout root (the directory containing the ontology export path).",
|
|
52
|
+
),
|
|
53
|
+
project_id: Optional[str] = typer.Option(
|
|
54
|
+
None,
|
|
55
|
+
"--project",
|
|
56
|
+
envvar="CASSIS_PROJECT_ID",
|
|
57
|
+
help="Target Cassis project ID (UUID). Defaults to the id in <base-path>/project.yml.",
|
|
58
|
+
),
|
|
59
|
+
api_key: Optional[str] = typer.Option(
|
|
60
|
+
None,
|
|
61
|
+
"--api-key",
|
|
62
|
+
envvar="CASSIS_API_KEY",
|
|
63
|
+
help="Cassis API key (sk-k6-...). Create one in Organization settings -> API keys.",
|
|
64
|
+
),
|
|
65
|
+
api_url: str = typer.Option(
|
|
66
|
+
DEFAULT_API_URL,
|
|
67
|
+
"--api-url",
|
|
68
|
+
envvar="CASSIS_API_URL",
|
|
69
|
+
help="Cassis API base URL.",
|
|
70
|
+
),
|
|
71
|
+
base_path: str = typer.Option(
|
|
72
|
+
DEFAULT_BASE_PATH,
|
|
73
|
+
"--base-path",
|
|
74
|
+
envvar="CASSIS_BASE_PATH",
|
|
75
|
+
help="Repository directory the ontology is exported under (the project's git-sync Path setting).",
|
|
76
|
+
),
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Download the source schema into `<base-path>/.schema.json` (gitignored).
|
|
79
|
+
|
|
80
|
+
The snapshot is the schema as Cassis last introspected it from the
|
|
81
|
+
warehouse (or parsed from an uploaded DDL) — every table with its columns
|
|
82
|
+
and types, plus a `pulled_at` stamp so staleness is visible. Re-run after
|
|
83
|
+
a warehouse sync to refresh. Exits 0 on success, 2 on usage errors, 3 on
|
|
84
|
+
transport/API errors.
|
|
85
|
+
"""
|
|
86
|
+
api_key = require_api_key(api_key)
|
|
87
|
+
ontology_dir = path / base_path
|
|
88
|
+
project_id = resolve_project_id(project_id, ontology_dir)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
result = get_schema_export(api_url=api_url, api_key=api_key, project_id=project_id)
|
|
92
|
+
except (AuthError, ApiError) as exc:
|
|
93
|
+
# NoSourceSchemaError lands here too: the server's message already says
|
|
94
|
+
# what to do (sync or upload a DDL); the class exists so api.py doesn't
|
|
95
|
+
# bury it under the misleading project-scope hint.
|
|
96
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
97
|
+
raise typer.Exit(EXIT_TRANSPORT) from exc
|
|
98
|
+
|
|
99
|
+
snapshot = {
|
|
100
|
+
"pulled_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
101
|
+
"project_id": project_id,
|
|
102
|
+
"schema_version": result["schema_version"],
|
|
103
|
+
"tables": result["tables"],
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
ontology_dir.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
snapshot_path = ontology_dir / SNAPSHOT_FILENAME
|
|
109
|
+
snapshot_path.write_text(json.dumps(snapshot, indent=2) + "\n", encoding="utf-8")
|
|
110
|
+
ensure_gitignored(ontology_dir)
|
|
111
|
+
except OSError as exc:
|
|
112
|
+
# Usage-class exit (2), like the other local-file failures in the exit
|
|
113
|
+
# table — the API call succeeded, the checkout is what's broken.
|
|
114
|
+
typer.secho(f"Could not write the snapshot: {exc}", fg=typer.colors.RED, err=True)
|
|
115
|
+
raise typer.Exit(EXIT_USAGE) from exc
|
|
116
|
+
|
|
117
|
+
table_count = len(result["tables"])
|
|
118
|
+
column_count = sum(len(t.get("columns") or []) for t in result["tables"])
|
|
119
|
+
version = result["schema_version"].get("version")
|
|
120
|
+
typer.secho(
|
|
121
|
+
f"✓ Pulled source schema v{version}: {table_count} tables, {column_count} columns "
|
|
122
|
+
f"-> {snapshot_path} (gitignored)",
|
|
123
|
+
fg=typer.colors.GREEN,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def ensure_gitignored(ontology_dir: Path) -> None:
|
|
128
|
+
"""Make sure the snapshot never lands in git: keep `.gitignore` covering it.
|
|
129
|
+
|
|
130
|
+
Appends to (or creates) the ontology dir's own `.gitignore` — local to the
|
|
131
|
+
export directory, so it survives repo-level `.gitignore` rewrites and needs
|
|
132
|
+
no knowledge of the checkout layout.
|
|
133
|
+
"""
|
|
134
|
+
gitignore = ontology_dir / ".gitignore"
|
|
135
|
+
try:
|
|
136
|
+
existing = gitignore.read_text(encoding="utf-8")
|
|
137
|
+
except (OSError, UnicodeDecodeError) as _exc: # `as` keeps black from stripping the parens (3.14-only syntax)
|
|
138
|
+
existing = ""
|
|
139
|
+
if SNAPSHOT_FILENAME in existing.splitlines():
|
|
140
|
+
return
|
|
141
|
+
prefix = "" if not existing else existing.rstrip("\n") + "\n"
|
|
142
|
+
gitignore.write_text(f"{prefix}{_GITIGNORE_HEADER}\n{SNAPSHOT_FILENAME}\n", encoding="utf-8")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@app.command()
|
|
146
|
+
def push(
|
|
147
|
+
ddl_file: Path = typer.Argument(
|
|
148
|
+
..., help="Path to the DDL file (.sql, .ddl, .txt) containing CREATE TABLE statements."
|
|
149
|
+
),
|
|
150
|
+
path: Path = typer.Option(
|
|
151
|
+
Path("."),
|
|
152
|
+
help="Repository checkout root (the directory containing the ontology export path).",
|
|
153
|
+
),
|
|
154
|
+
project_id: Optional[str] = typer.Option(
|
|
155
|
+
None,
|
|
156
|
+
"--project",
|
|
157
|
+
envvar="CASSIS_PROJECT_ID",
|
|
158
|
+
help="Target Cassis project ID (UUID). Defaults to the id in <base-path>/project.yml.",
|
|
159
|
+
),
|
|
160
|
+
api_key: Optional[str] = typer.Option(
|
|
161
|
+
None,
|
|
162
|
+
"--api-key",
|
|
163
|
+
envvar="CASSIS_API_KEY",
|
|
164
|
+
help="Cassis API key (sk-k6-...). Create one in Organization settings -> API keys.",
|
|
165
|
+
),
|
|
166
|
+
api_url: str = typer.Option(
|
|
167
|
+
DEFAULT_API_URL,
|
|
168
|
+
"--api-url",
|
|
169
|
+
envvar="CASSIS_API_URL",
|
|
170
|
+
help="Cassis API base URL.",
|
|
171
|
+
),
|
|
172
|
+
base_path: str = typer.Option(
|
|
173
|
+
DEFAULT_BASE_PATH,
|
|
174
|
+
"--base-path",
|
|
175
|
+
envvar="CASSIS_BASE_PATH",
|
|
176
|
+
help="Repository directory the ontology is exported under (the project's git-sync Path setting).",
|
|
177
|
+
),
|
|
178
|
+
wait: bool = typer.Option(True, "--wait/--no-wait", help="Wait for the detection run to complete."),
|
|
179
|
+
poll_interval: float = typer.Option(5.0, "--poll-interval", help="Seconds between polls with --wait."),
|
|
180
|
+
timeout: float = typer.Option(600.0, "--timeout", help="Give up waiting after this many seconds."),
|
|
181
|
+
json_output: bool = typer.Option(False, "--json", help="Print the run record as raw JSON."),
|
|
182
|
+
) -> None:
|
|
183
|
+
"""Upload a DDL file to detect source-schema changes (same as the webapp's "Update from DDL").
|
|
184
|
+
|
|
185
|
+
The DDL must contain at least one CREATE TABLE statement and represents the
|
|
186
|
+
project's complete source schema. Cassis diffs it against the ontology:
|
|
187
|
+
added, dropped, and changed objects appear in Ontology > Review > Data
|
|
188
|
+
source for approval. Re-uploading a corrected DDL supersedes the previous
|
|
189
|
+
one. Only works on DDL-only projects (no warehouse connection). Exits 0 on
|
|
190
|
+
success, 1 on a failed detection run, 2 on usage errors, 3 on transport/API
|
|
191
|
+
errors or a --wait timeout.
|
|
192
|
+
"""
|
|
193
|
+
api_key = require_api_key(api_key)
|
|
194
|
+
project_id = resolve_project_id(project_id, path / base_path)
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
ddl_text = ddl_file.read_text(encoding="utf-8")
|
|
198
|
+
except OSError as exc:
|
|
199
|
+
typer.secho(f"Cannot read {ddl_file}: {exc}", fg=typer.colors.RED, err=True)
|
|
200
|
+
raise typer.Exit(EXIT_USAGE) from exc
|
|
201
|
+
except UnicodeDecodeError as exc:
|
|
202
|
+
typer.secho(f"Cannot read {ddl_file}: {exc}", fg=typer.colors.RED, err=True)
|
|
203
|
+
raise typer.Exit(EXIT_USAGE) from exc
|
|
204
|
+
|
|
205
|
+
if not ddl_text.strip():
|
|
206
|
+
typer.secho("DDL file is empty.", fg=typer.colors.RED, err=True)
|
|
207
|
+
raise typer.Exit(EXIT_USAGE)
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
run = post_detect_from_ddl(api_url=api_url, api_key=api_key, project_id=project_id, ddl=ddl_text)
|
|
211
|
+
except SourceChangeConflictError as exc:
|
|
212
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
213
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED) from exc
|
|
214
|
+
except (AuthError, ApiError) as exc:
|
|
215
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
216
|
+
raise typer.Exit(EXIT_TRANSPORT) from exc
|
|
217
|
+
|
|
218
|
+
run_id = run["run_id"]
|
|
219
|
+
typer.echo(f"Detection run started: {run_id}")
|
|
220
|
+
|
|
221
|
+
if not wait:
|
|
222
|
+
if json_output:
|
|
223
|
+
typer.echo(json.dumps(run, indent=2))
|
|
224
|
+
raise typer.Exit(EXIT_OK)
|
|
225
|
+
|
|
226
|
+
run = _wait_for_detection_run(
|
|
227
|
+
api_url=api_url,
|
|
228
|
+
api_key=api_key,
|
|
229
|
+
project_id=project_id,
|
|
230
|
+
run_id=run_id,
|
|
231
|
+
poll_interval=poll_interval,
|
|
232
|
+
timeout=timeout,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
if json_output:
|
|
236
|
+
typer.echo(json.dumps(run, indent=2))
|
|
237
|
+
|
|
238
|
+
run_status = run.get("status")
|
|
239
|
+
if run_status == "completed":
|
|
240
|
+
summary = run.get("summary") or {}
|
|
241
|
+
total = summary.get("total_changes", 0)
|
|
242
|
+
typer.secho(
|
|
243
|
+
f"✓ Detection completed: {total} change(s) detected." if total else "✓ Detection completed: no changes.",
|
|
244
|
+
fg=typer.colors.GREEN,
|
|
245
|
+
)
|
|
246
|
+
raise typer.Exit(EXIT_OK)
|
|
247
|
+
if run_status == "failed":
|
|
248
|
+
error = run.get("error") or "unknown error"
|
|
249
|
+
typer.secho(f"Detection failed: {error}", fg=typer.colors.RED, err=True)
|
|
250
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED)
|
|
251
|
+
if run_status == "cancelled":
|
|
252
|
+
typer.secho("Detection run was cancelled.", fg=typer.colors.YELLOW, err=True)
|
|
253
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED)
|
|
254
|
+
typer.secho(f"Detection run ended with unexpected status: {run_status}", fg=typer.colors.RED, err=True)
|
|
255
|
+
raise typer.Exit(EXIT_TRANSPORT)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _wait_for_detection_run(
|
|
259
|
+
*,
|
|
260
|
+
api_url: str,
|
|
261
|
+
api_key: str,
|
|
262
|
+
project_id: str,
|
|
263
|
+
run_id: str,
|
|
264
|
+
poll_interval: float,
|
|
265
|
+
timeout: float,
|
|
266
|
+
) -> "dict[str, Any]":
|
|
267
|
+
"""Poll until the detection run reaches a terminal status."""
|
|
268
|
+
deadline = time.monotonic() + timeout
|
|
269
|
+
consecutive_failures = 0
|
|
270
|
+
while True:
|
|
271
|
+
try:
|
|
272
|
+
run = get_source_change_run(api_url=api_url, api_key=api_key, project_id=project_id, run_id=run_id)
|
|
273
|
+
consecutive_failures = 0
|
|
274
|
+
except AuthError as exc:
|
|
275
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
276
|
+
raise typer.Exit(EXIT_TRANSPORT) from exc
|
|
277
|
+
except ApiError as exc:
|
|
278
|
+
consecutive_failures += 1
|
|
279
|
+
if consecutive_failures >= _MAX_CONSECUTIVE_POLL_FAILURES:
|
|
280
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
281
|
+
raise typer.Exit(EXIT_TRANSPORT) from exc
|
|
282
|
+
time.sleep(poll_interval)
|
|
283
|
+
continue
|
|
284
|
+
|
|
285
|
+
if run.get("status") in _TERMINAL_RUN_STATUSES:
|
|
286
|
+
return run
|
|
287
|
+
|
|
288
|
+
if time.monotonic() >= deadline:
|
|
289
|
+
typer.secho(
|
|
290
|
+
f"Timed out after {timeout:.0f}s: the detection run is still {run.get('status', '?')}.",
|
|
291
|
+
fg=typer.colors.YELLOW,
|
|
292
|
+
err=True,
|
|
293
|
+
)
|
|
294
|
+
raise typer.Exit(EXIT_TRANSPORT)
|
|
295
|
+
time.sleep(poll_interval)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""`cassis status` — published version vs repository head, from the terminal.
|
|
2
|
+
|
|
3
|
+
One command answers what previously needed the webapp or the GitHub Actions
|
|
4
|
+
tab: which version is published, whether it matches the local checkout, and
|
|
5
|
+
whether anything is awaiting publication. `--watch` polls until the published
|
|
6
|
+
version catches up with the local head (e.g. right after merging a PR whose
|
|
7
|
+
CI publishes the ontology).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import subprocess
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
from cassis_cli.api import DEFAULT_API_URL, ApiError, AuthError, get_project_status
|
|
20
|
+
from cassis_cli.common import (
|
|
21
|
+
DEFAULT_BASE_PATH,
|
|
22
|
+
EXIT_OK,
|
|
23
|
+
EXIT_TRANSPORT,
|
|
24
|
+
EXIT_USAGE,
|
|
25
|
+
require_api_key,
|
|
26
|
+
resolve_project_id,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _git(path: Path, *args: str) -> Optional[str]:
|
|
31
|
+
"""Run a git command in the checkout; None on failure, stdout (possibly empty) on success."""
|
|
32
|
+
try:
|
|
33
|
+
proc = subprocess.run(
|
|
34
|
+
["git", *args],
|
|
35
|
+
cwd=path,
|
|
36
|
+
capture_output=True,
|
|
37
|
+
text=True,
|
|
38
|
+
timeout=5,
|
|
39
|
+
)
|
|
40
|
+
# Two separate handlers: the repo-wide black targets py314 and would strip
|
|
41
|
+
# the parens off a tuple form, which is a SyntaxError on the CLI's
|
|
42
|
+
# supported Python (>=3.10).
|
|
43
|
+
except OSError:
|
|
44
|
+
return None
|
|
45
|
+
except subprocess.TimeoutExpired:
|
|
46
|
+
return None
|
|
47
|
+
if proc.returncode != 0:
|
|
48
|
+
return None
|
|
49
|
+
return proc.stdout.strip()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _local_comparison(path: Path, head: Optional[str], published_sha: Optional[str]) -> "Tuple[str, bool]":
|
|
53
|
+
"""Describe the local checkout vs the published commit. Returns (text, in_sync)."""
|
|
54
|
+
if head is None:
|
|
55
|
+
return "not a git checkout (no local comparison)", False
|
|
56
|
+
if published_sha is None:
|
|
57
|
+
return f"at {head[:9]} (the published version records no git commit)", False
|
|
58
|
+
if head == published_sha:
|
|
59
|
+
return f"in sync with the published version ({head[:9]})", True
|
|
60
|
+
if _git(path, "merge-base", "--is-ancestor", published_sha, "HEAD") is not None:
|
|
61
|
+
count = _git(path, "rev-list", "--count", f"{published_sha}..HEAD") or "?"
|
|
62
|
+
return f"{count} commit(s) ahead of the published version ({published_sha[:9]})", False
|
|
63
|
+
if _git(path, "cat-file", "-e", f"{published_sha}^{{commit}}") is None:
|
|
64
|
+
return (
|
|
65
|
+
f"published commit {published_sha[:9]} not found locally (run git fetch, or the checkout is behind)",
|
|
66
|
+
False,
|
|
67
|
+
)
|
|
68
|
+
if _git(path, "merge-base", "--is-ancestor", "HEAD", published_sha) is not None:
|
|
69
|
+
count = _git(path, "rev-list", "--count", f"HEAD..{published_sha}") or "?"
|
|
70
|
+
return f"{count} commit(s) behind the published version ({published_sha[:9]}), run git pull", False
|
|
71
|
+
return f"diverged from the published commit {published_sha[:9]}", False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _render(status_record: "dict[str, Any]", comparison_text: str) -> None:
|
|
75
|
+
published = status_record.get("published_version")
|
|
76
|
+
if published:
|
|
77
|
+
label = f" {published['label']!r}" if published.get("label") else ""
|
|
78
|
+
sha = published.get("git_commit_sha")
|
|
79
|
+
sha_text = f", commit {sha[:9]}" if sha else ""
|
|
80
|
+
typer.echo(f"Published: v{published['version']}{label} ({published.get('published_at')}{sha_text})")
|
|
81
|
+
else:
|
|
82
|
+
typer.echo("Published: nothing yet")
|
|
83
|
+
changes = "yes" if status_record.get("has_unpublished_changes") else "no"
|
|
84
|
+
typer.echo(f"Unpublished changes: {changes}")
|
|
85
|
+
git_sync = status_record.get("git_sync")
|
|
86
|
+
if git_sync:
|
|
87
|
+
typer.echo(f"Git sync: {git_sync['provider']} {git_sync['repo']} (path {git_sync['base_path']})")
|
|
88
|
+
else:
|
|
89
|
+
typer.echo("Git sync: not configured")
|
|
90
|
+
typer.echo(f"Local checkout: {comparison_text}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def status(
|
|
94
|
+
path: Path = typer.Argument(
|
|
95
|
+
Path("."),
|
|
96
|
+
help="Repository checkout root (the directory containing the ontology export path).",
|
|
97
|
+
),
|
|
98
|
+
project_id: Optional[str] = typer.Option(
|
|
99
|
+
None,
|
|
100
|
+
"--project",
|
|
101
|
+
envvar="CASSIS_PROJECT_ID",
|
|
102
|
+
help="Target Cassis project ID (UUID). Defaults to the id in <base-path>/project.yml.",
|
|
103
|
+
),
|
|
104
|
+
api_key: Optional[str] = typer.Option(
|
|
105
|
+
None,
|
|
106
|
+
"--api-key",
|
|
107
|
+
envvar="CASSIS_API_KEY",
|
|
108
|
+
help="Cassis API key (sk-k6-...). Create one in Organization settings -> API keys.",
|
|
109
|
+
),
|
|
110
|
+
api_url: str = typer.Option(
|
|
111
|
+
DEFAULT_API_URL,
|
|
112
|
+
"--api-url",
|
|
113
|
+
envvar="CASSIS_API_URL",
|
|
114
|
+
help="Cassis API base URL.",
|
|
115
|
+
),
|
|
116
|
+
base_path: str = typer.Option(
|
|
117
|
+
DEFAULT_BASE_PATH,
|
|
118
|
+
"--base-path",
|
|
119
|
+
envvar="CASSIS_BASE_PATH",
|
|
120
|
+
help="Repository directory the ontology is exported under (the project's git-sync Path setting).",
|
|
121
|
+
),
|
|
122
|
+
watch: bool = typer.Option(
|
|
123
|
+
False,
|
|
124
|
+
"--watch",
|
|
125
|
+
help="Poll until the published version's commit matches the local git HEAD.",
|
|
126
|
+
),
|
|
127
|
+
poll_interval: float = typer.Option(5.0, "--poll-interval", help="Seconds between polls with --watch."),
|
|
128
|
+
timeout: float = typer.Option(600.0, "--timeout", help="Give up watching after this many seconds."),
|
|
129
|
+
json_output: bool = typer.Option(False, "--json", help="Print the status as raw JSON."),
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Show the project's published version vs the local checkout.
|
|
132
|
+
|
|
133
|
+
Prints the published head (version, label, commit), whether unpublished
|
|
134
|
+
changes are awaiting publication, the git-sync binding, and how the local
|
|
135
|
+
git HEAD relates to the published commit. With --watch, polls until the
|
|
136
|
+
published commit equals the local HEAD (a publish of your merge landing),
|
|
137
|
+
then exits 0. Exits 0 on success, 2 on usage errors, 3 on transport/API
|
|
138
|
+
errors or a --watch timeout.
|
|
139
|
+
"""
|
|
140
|
+
api_key = require_api_key(api_key)
|
|
141
|
+
project_id = resolve_project_id(project_id, path / base_path, quiet=json_output)
|
|
142
|
+
head = _git(path, "rev-parse", "HEAD")
|
|
143
|
+
|
|
144
|
+
if watch and head is None:
|
|
145
|
+
typer.secho("--watch needs a git checkout (no local HEAD to compare against).", fg=typer.colors.RED, err=True)
|
|
146
|
+
raise typer.Exit(EXIT_USAGE)
|
|
147
|
+
|
|
148
|
+
deadline = time.monotonic() + timeout
|
|
149
|
+
last_line: Optional[str] = None
|
|
150
|
+
while True:
|
|
151
|
+
try:
|
|
152
|
+
record = get_project_status(api_url=api_url, api_key=api_key, project_id=project_id)
|
|
153
|
+
except (AuthError, ApiError) as exc:
|
|
154
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
155
|
+
raise typer.Exit(EXIT_TRANSPORT) from exc
|
|
156
|
+
|
|
157
|
+
published = record.get("published_version") or {}
|
|
158
|
+
published_sha = published.get("git_commit_sha")
|
|
159
|
+
comparison_text, in_sync = _local_comparison(path, head, published_sha)
|
|
160
|
+
|
|
161
|
+
if json_output:
|
|
162
|
+
typer.echo(json.dumps({**record, "local": {"head": head, "in_sync": in_sync}}, indent=2))
|
|
163
|
+
elif not watch:
|
|
164
|
+
_render(record, comparison_text)
|
|
165
|
+
else:
|
|
166
|
+
version_text = f"v{published['version']}" if published else "nothing published"
|
|
167
|
+
line = f"{version_text} (commit {published_sha[:9] if published_sha else 'none'}); local: {comparison_text}"
|
|
168
|
+
if line != last_line:
|
|
169
|
+
typer.echo(line)
|
|
170
|
+
last_line = line
|
|
171
|
+
|
|
172
|
+
if not watch:
|
|
173
|
+
raise typer.Exit(EXIT_OK)
|
|
174
|
+
if in_sync:
|
|
175
|
+
typer.secho("✓ Published version matches the local HEAD.", fg=typer.colors.GREEN)
|
|
176
|
+
raise typer.Exit(EXIT_OK)
|
|
177
|
+
if time.monotonic() >= deadline:
|
|
178
|
+
typer.secho(
|
|
179
|
+
f"Timed out after {timeout:.0f}s: the published version still does not match the local HEAD.",
|
|
180
|
+
fg=typer.colors.YELLOW,
|
|
181
|
+
err=True,
|
|
182
|
+
)
|
|
183
|
+
raise typer.Exit(EXIT_TRANSPORT)
|
|
184
|
+
time.sleep(poll_interval)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""`cassis verify` — the full local gate in one verb: fmt --check, check, eval run.
|
|
2
|
+
|
|
3
|
+
Every edit session recites the same litany by hand, and the README's CI
|
|
4
|
+
examples chain the same three commands as separate jobs. `verify` runs them in
|
|
5
|
+
order and stops at the first failure, so "is this change safe to merge?" is
|
|
6
|
+
one command in a checkout and one job in CI.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Callable, Optional
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from cassis_cli.api import DEFAULT_API_URL
|
|
16
|
+
from cassis_cli.common import DEFAULT_BASE_PATH, EXIT_OK, require_api_key
|
|
17
|
+
from cassis_cli.eval import run as eval_run
|
|
18
|
+
from cassis_cli.ontology import check as ontology_check
|
|
19
|
+
from cassis_cli.ontology import fmt as ontology_fmt
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _step(title: str, fn: "Callable[[], None]") -> int:
|
|
23
|
+
"""Run one gate; return its exit code (commands exit via typer.Exit)."""
|
|
24
|
+
typer.secho(f"==> {title}", bold=True)
|
|
25
|
+
try:
|
|
26
|
+
fn()
|
|
27
|
+
except typer.Exit as exc:
|
|
28
|
+
return exc.exit_code
|
|
29
|
+
return EXIT_OK
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def verify(
|
|
33
|
+
path: Path = typer.Argument(
|
|
34
|
+
Path("."),
|
|
35
|
+
help="Repository checkout root (the directory containing the ontology export path).",
|
|
36
|
+
),
|
|
37
|
+
project_id: Optional[str] = typer.Option(
|
|
38
|
+
None,
|
|
39
|
+
"--project",
|
|
40
|
+
envvar="CASSIS_PROJECT_ID",
|
|
41
|
+
help="Target Cassis project ID (UUID). Defaults to the id in <base-path>/project.yml.",
|
|
42
|
+
),
|
|
43
|
+
api_key: Optional[str] = typer.Option(
|
|
44
|
+
None,
|
|
45
|
+
"--api-key",
|
|
46
|
+
envvar="CASSIS_API_KEY",
|
|
47
|
+
help="Cassis API key (sk-k6-...). Create one in Organization settings -> API keys.",
|
|
48
|
+
),
|
|
49
|
+
api_url: str = typer.Option(
|
|
50
|
+
DEFAULT_API_URL,
|
|
51
|
+
"--api-url",
|
|
52
|
+
envvar="CASSIS_API_URL",
|
|
53
|
+
help="Cassis API base URL.",
|
|
54
|
+
),
|
|
55
|
+
base_path: str = typer.Option(
|
|
56
|
+
DEFAULT_BASE_PATH,
|
|
57
|
+
"--base-path",
|
|
58
|
+
envvar="CASSIS_BASE_PATH",
|
|
59
|
+
help="Repository directory the ontology is exported under (the project's git-sync Path setting).",
|
|
60
|
+
),
|
|
61
|
+
run_eval: bool = typer.Option(
|
|
62
|
+
True,
|
|
63
|
+
"--eval/--no-eval",
|
|
64
|
+
help="Run the project's eval suite as the last gate (default: run it).",
|
|
65
|
+
),
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Run the three local gates in order: `ontology fmt --check`, `ontology check`, `eval run`.
|
|
68
|
+
|
|
69
|
+
The same sequence the README's CI examples chain as separate jobs, stopping
|
|
70
|
+
at the first failure. `fmt` runs in --check mode and writes nothing. Pass
|
|
71
|
+
--no-eval to skip the eval suite (e.g. a project with no cases yet).
|
|
72
|
+
Exits with the first failing gate's code: 0 all gates passed, 1 validation
|
|
73
|
+
or eval failure, 2 usage errors, 3 transport/API errors, 130 interrupted.
|
|
74
|
+
"""
|
|
75
|
+
api_key = require_api_key(api_key)
|
|
76
|
+
|
|
77
|
+
code = _step(
|
|
78
|
+
"cassis ontology fmt --check",
|
|
79
|
+
lambda: ontology_fmt(path=path, api_key=api_key, api_url=api_url, base_path=base_path, check_only=True),
|
|
80
|
+
)
|
|
81
|
+
if code != EXIT_OK:
|
|
82
|
+
typer.secho("Not canonical: run `cassis ontology fmt` and review the diff.", fg=typer.colors.RED, err=True)
|
|
83
|
+
raise typer.Exit(code)
|
|
84
|
+
|
|
85
|
+
code = _step(
|
|
86
|
+
"cassis ontology check",
|
|
87
|
+
lambda: ontology_check(
|
|
88
|
+
path=path,
|
|
89
|
+
project_id=project_id,
|
|
90
|
+
api_key=api_key,
|
|
91
|
+
api_url=api_url,
|
|
92
|
+
base_path=base_path,
|
|
93
|
+
json_output=False,
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
if code != EXIT_OK:
|
|
97
|
+
raise typer.Exit(code)
|
|
98
|
+
|
|
99
|
+
if run_eval:
|
|
100
|
+
# Kwargs must track eval_run's signature; a new required param breaks here.
|
|
101
|
+
code = _step(
|
|
102
|
+
"cassis eval run",
|
|
103
|
+
lambda: eval_run(
|
|
104
|
+
path=path,
|
|
105
|
+
project_id=project_id,
|
|
106
|
+
api_key=api_key,
|
|
107
|
+
api_url=api_url,
|
|
108
|
+
base_path=base_path,
|
|
109
|
+
branch=None,
|
|
110
|
+
case=None,
|
|
111
|
+
label=None,
|
|
112
|
+
wait=True,
|
|
113
|
+
poll_interval=5.0,
|
|
114
|
+
timeout=1800.0,
|
|
115
|
+
json_output=False,
|
|
116
|
+
app_url=None,
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
if code != EXIT_OK:
|
|
120
|
+
raise typer.Exit(code)
|
|
121
|
+
|
|
122
|
+
typer.secho("✓ verify passed" + ("" if run_eval else " (eval skipped)"), fg=typer.colors.GREEN, bold=True)
|
|
123
|
+
raise typer.Exit(EXIT_OK)
|
|
@@ -1,129 +0,0 @@
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|