cassis-cli 1.7.0__tar.gz → 2.1.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cassis-cli
3
- Version: 1.7.0
3
+ Version: 2.1.0
4
4
  Summary: Validate, test and evaluate your Cassis ontology from your terminal, then publish it
5
5
  License: Apache-2.0
6
6
  License-File: LICENSE
@@ -35,11 +35,10 @@ Validate, test and evaluate your ontology from your terminal, then publish it. T
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. The file speaks only for the schemas it contains — a partial export (one schema of many) never removes the others; pass `--complete` when it is the project's complete source schema so schemas absent from it are treated as dropped. Waits for the detection run to finish and exits 0 only when it completed — the schema is stored and applied atomically with run completion, so exit 0 means the DDL parsed and the project now uses it.
38
+ - `cassis schema plan <ddl>` (or `--warehouse` on a project connected to a warehouse) previews what a schema update would change before anything is applied: the source schema diff, the ontology changes Cassis will make (every change on a table placed in the ontology, with everything a drop takes with it) and warnings, terraform-style. `cassis schema apply <ddl>` (or `--plan <id>`) writes the resulting ontology files into the local checkout, app untouched, for review with `git diff`. `cassis schema push <ddl> [--publish]` pushes the new schema and the local ontology to the app (`--yes` in CI). The file speaks only for the schemas it contains — pass `--complete` when it is the project's complete source schema so schemas absent from it are treated as dropped. With `--warehouse` the server introspects the connected warehouse instead of parsing a file; the plan is always whole-source.
39
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, how many Data source review items are pending (with the breaking count, when there are any), 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.
40
+ - `cassis status` shows the project's published version (number, label, git commit), whether unpublished changes await publication, the git-sync binding, a schema plan waiting to be applied, 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
41
  - `cassis issues` triages the issues Cassis raised on the project — what it found wrong while answering questions (an ontology gap, missing data) — without leaving the checkout: `issues list` (filterable by status, impact and cause), `issues show <id>` for the diagnosis, suggested action and the occurrences behind it, `issues evidence <id> <occurrence-id>` for what the agent actually saw, and `issues resolve` / `dismiss` / `reopen` once you've acted on it.
42
- - `cassis source-changes` reads the project's Data source review queue — the schema drift Cassis detected between the source and what the ontology tracks: `source-changes list` (paginated, pending by default, breaking severity flagged) and `source-changes show <id>` for one change's impact references and suggested edit. Read-only: approving or dismissing stays in the webapp; fix headlessly by editing the ontology files and opening a pull request.
43
42
  - `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.
44
43
 
45
44
  ## Install
@@ -105,6 +104,17 @@ cassis eval run --project ... --case 019f0000-0000-7000-8000-0000000000ca
105
104
 
106
105
  # Start the run and return immediately (poll in the webapp):
107
106
  cassis eval run --project ... --no-wait
107
+ ```
108
+
109
+ Under each failed case `eval run` prints what is needed to diagnose it: the SQL the agent
110
+ generated, the gold SQL it was compared against, how many rows each side returned, any concepts
111
+ the agent found missing, and the judge's reasoning when a judge graded the case. The expected and
112
+ actual row *values* are deliberately not printed — they are in `--json` and in the Cassis app.
113
+ Note that the **generated SQL is printed as the agent wrote it**, and an agent that read your data
114
+ while planning can carry a value it saw into a literal in that SQL. Treat `eval run` output as
115
+ carrying the same sensitivity as the queries themselves when you decide who can read your CI logs.
116
+
117
+ ```bash
108
118
 
109
119
  # Probe questions through the text-to-SQL agent using the local ontology files
110
120
  # (one full agent run per question, expect ~30-90s each; repeat -q for several):
@@ -125,9 +135,11 @@ cassis eval delete-case 019f0000-0000-7000-8000-0000000000ca --project ...
125
135
  # Pull the source schema into <base-path>/.schema.json (gitignored local snapshot):
126
136
  cassis schema pull
127
137
 
128
- # Push a DDL file to detect source-schema changes (DDL-only projects);
129
- # exits 0 only once the run completed and the schema is applied:
130
- cassis schema push schema.sql
138
+ # Preview, apply locally and push a schema update from a DDL file (DDL-only projects):
139
+ cassis schema plan schema.sql --complete
140
+ cassis schema apply schema.sql --complete # writes cassis/ locally
141
+ cassis schema push schema.sql --complete --yes # schema + ontology to the app
142
+ cassis schema plan --warehouse # warehouse-connected projects: introspect instead
131
143
 
132
144
  # List the projects the API key can reach (id, name, published version, dialect):
133
145
  cassis projects list
@@ -195,8 +207,8 @@ cassis ontology fmt --check
195
207
  | Code | Meaning |
196
208
  | ---- | ------------------------------------------------------------------------------ |
197
209
  | 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) |
198
- | 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; issues: no such issue or occurrence in the project; issues analyze: failed or cancelled analysis run; schema push: failed or cancelled detection run, or the project won't accept the push (a run is already in flight, or it is warehouse-connected rather than DDL-only); source-changes show: no such change in the project) |
199
- | 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits) |
210
+ | 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; issues: no such issue or occurrence in the project; issues analyze: failed or cancelled analysis run; schema plan/apply: the plan failed (unparseable or truncated DDL), is stale or expired, the apply failed, or the project won't accept it (a plan is being applied, a DDL was given for a warehouse-connected project, or --warehouse for a DDL-only one)) |
211
+ | 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits, `eval run --branch` naming an ontology branch the project does not have) |
200
212
  | 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another eval run or issue analysis already active, out of credits, or `--timeout` reached |
201
213
 
202
214
  Commands that send the local tree (`check`, `fmt`, `upload`, `eval run`, `test`) accept up to
@@ -13,11 +13,10 @@ Validate, test and evaluate your ontology from your terminal, then publish it. T
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. The file speaks only for the schemas it contains — a partial export (one schema of many) never removes the others; pass `--complete` when it is the project's complete source schema so schemas absent from it are treated as dropped. Waits for the detection run to finish and exits 0 only when it completed — the schema is stored and applied atomically with run completion, so exit 0 means the DDL parsed and the project now uses it.
16
+ - `cassis schema plan <ddl>` (or `--warehouse` on a project connected to a warehouse) previews what a schema update would change before anything is applied: the source schema diff, the ontology changes Cassis will make (every change on a table placed in the ontology, with everything a drop takes with it) and warnings, terraform-style. `cassis schema apply <ddl>` (or `--plan <id>`) writes the resulting ontology files into the local checkout, app untouched, for review with `git diff`. `cassis schema push <ddl> [--publish]` pushes the new schema and the local ontology to the app (`--yes` in CI). The file speaks only for the schemas it contains — pass `--complete` when it is the project's complete source schema so schemas absent from it are treated as dropped. With `--warehouse` the server introspects the connected warehouse instead of parsing a file; the plan is always whole-source.
17
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, how many Data source review items are pending (with the breaking count, when there are any), 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.
18
+ - `cassis status` shows the project's published version (number, label, git commit), whether unpublished changes await publication, the git-sync binding, a schema plan waiting to be applied, 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
19
  - `cassis issues` triages the issues Cassis raised on the project — what it found wrong while answering questions (an ontology gap, missing data) — without leaving the checkout: `issues list` (filterable by status, impact and cause), `issues show <id>` for the diagnosis, suggested action and the occurrences behind it, `issues evidence <id> <occurrence-id>` for what the agent actually saw, and `issues resolve` / `dismiss` / `reopen` once you've acted on it.
20
- - `cassis source-changes` reads the project's Data source review queue — the schema drift Cassis detected between the source and what the ontology tracks: `source-changes list` (paginated, pending by default, breaking severity flagged) and `source-changes show <id>` for one change's impact references and suggested edit. Read-only: approving or dismissing stays in the webapp; fix headlessly by editing the ontology files and opening a pull request.
21
20
  - `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.
22
21
 
23
22
  ## Install
@@ -83,6 +82,17 @@ cassis eval run --project ... --case 019f0000-0000-7000-8000-0000000000ca
83
82
 
84
83
  # Start the run and return immediately (poll in the webapp):
85
84
  cassis eval run --project ... --no-wait
85
+ ```
86
+
87
+ Under each failed case `eval run` prints what is needed to diagnose it: the SQL the agent
88
+ generated, the gold SQL it was compared against, how many rows each side returned, any concepts
89
+ the agent found missing, and the judge's reasoning when a judge graded the case. The expected and
90
+ actual row *values* are deliberately not printed — they are in `--json` and in the Cassis app.
91
+ Note that the **generated SQL is printed as the agent wrote it**, and an agent that read your data
92
+ while planning can carry a value it saw into a literal in that SQL. Treat `eval run` output as
93
+ carrying the same sensitivity as the queries themselves when you decide who can read your CI logs.
94
+
95
+ ```bash
86
96
 
87
97
  # Probe questions through the text-to-SQL agent using the local ontology files
88
98
  # (one full agent run per question, expect ~30-90s each; repeat -q for several):
@@ -103,9 +113,11 @@ cassis eval delete-case 019f0000-0000-7000-8000-0000000000ca --project ...
103
113
  # Pull the source schema into <base-path>/.schema.json (gitignored local snapshot):
104
114
  cassis schema pull
105
115
 
106
- # Push a DDL file to detect source-schema changes (DDL-only projects);
107
- # exits 0 only once the run completed and the schema is applied:
108
- cassis schema push schema.sql
116
+ # Preview, apply locally and push a schema update from a DDL file (DDL-only projects):
117
+ cassis schema plan schema.sql --complete
118
+ cassis schema apply schema.sql --complete # writes cassis/ locally
119
+ cassis schema push schema.sql --complete --yes # schema + ontology to the app
120
+ cassis schema plan --warehouse # warehouse-connected projects: introspect instead
109
121
 
110
122
  # List the projects the API key can reach (id, name, published version, dialect):
111
123
  cassis projects list
@@ -173,8 +185,8 @@ cassis ontology fmt --check
173
185
  | Code | Meaning |
174
186
  | ---- | ------------------------------------------------------------------------------ |
175
187
  | 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) |
176
- | 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; issues: no such issue or occurrence in the project; issues analyze: failed or cancelled analysis run; schema push: failed or cancelled detection run, or the project won't accept the push (a run is already in flight, or it is warehouse-connected rather than DDL-only); source-changes show: no such change in the project) |
177
- | 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits) |
188
+ | 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; issues: no such issue or occurrence in the project; issues analyze: failed or cancelled analysis run; schema plan/apply: the plan failed (unparseable or truncated DDL), is stale or expired, the apply failed, or the project won't accept it (a plan is being applied, a DDL was given for a warehouse-connected project, or --warehouse for a DDL-only one)) |
189
+ | 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits, `eval run --branch` naming an ontology branch the project does not have) |
178
190
  | 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another eval run or issue analysis already active, out of credits, or `--timeout` reached |
179
191
 
180
192
  Commands that send the local tree (`check`, `fmt`, `upload`, `eval run`, `test`) accept up to
@@ -106,6 +106,21 @@ class EvalRunActiveError(ApiError):
106
106
  """Another eval run is already active for the project (409)."""
107
107
 
108
108
 
109
+ class EvalBranchNotFoundError(ApiError):
110
+ """`--branch` named an ontology branch the project does not have (404)."""
111
+
112
+
113
+ def _is_branch_not_found(detail: object) -> bool:
114
+ """Whether a 404 detail is the start endpoint's missing-branch message.
115
+
116
+ Wire contract with `start_eval_run` in backend `endpoints/ci/eval.py`,
117
+ matched the way `delete_eval_case` matches its own 404 text. A server that
118
+ words it differently falls through to the generic project-scope error, so an
119
+ older server degrades rather than misreporting.
120
+ """
121
+ return isinstance(detail, str) and detail.startswith("Branch ") and detail.endswith(" not found")
122
+
123
+
109
124
  def _project_scope_error(response: httpx.Response) -> ApiError:
110
125
  # Surface the server's own message when it names the missing resource
111
126
  # (e.g. "Branch 'x' not found") — the generic hint covers the rest.
@@ -353,11 +368,57 @@ def get_schema_export(
353
368
  return result
354
369
 
355
370
 
356
- class SourceChangeConflictError(ApiError):
357
- """A concurrent detection run is already active, or the project has a connected data source."""
371
+ class SchemaPlanConflictError(ApiError):
372
+ """409 from the schema-plan routes: a plan is already active, the project is connected, the plan is stale / expired / not ready."""
373
+
374
+
375
+ class SchemaPlanNotFoundError(ApiError):
376
+ """404 with the server's exact detail `"Schema plan not found"` (a project-scope 404 is a different error)."""
377
+
358
378
 
379
+ class ServerTooOldError(ApiError):
380
+ """The server has no schema-plan routes (bare FastAPI 404): upgrade the server first."""
359
381
 
360
- def post_detect_from_ddl(
382
+
383
+ _SCHEMA_PLAN_NOT_FOUND = "Schema plan not found"
384
+
385
+
386
+ def _raise_for_schema_plan_status(response: httpx.Response) -> None:
387
+ """Map the schema-plan routes' error statuses to the CLI's exceptions; return on 2xx.
388
+
389
+ One ladder for every plan route (start, read, apply, checkout): the 409
390
+ conflict and the exact-detail 404 are wire contracts cassis-cli matches.
391
+ A bare FastAPI 404 means the server predates schema plans altogether.
392
+ """
393
+ if response.status_code == 401:
394
+ raise AuthError("The Cassis API rejected the API key (invalid or expired).")
395
+ if response.status_code == 409:
396
+ raise SchemaPlanConflictError(str(_detail_or_text(response)))
397
+ if response.status_code == 404:
398
+ detail = _detail_or_text(response)
399
+ if detail == _SCHEMA_PLAN_NOT_FOUND:
400
+ raise SchemaPlanNotFoundError(_SCHEMA_PLAN_NOT_FOUND)
401
+ if detail == "Not Found":
402
+ raise ServerTooOldError(
403
+ "This Cassis server has no schema plans yet: upgrade the server first, "
404
+ "or pin cassis-cli<2.0 to keep the previous `schema push`."
405
+ )
406
+ raise _project_scope_error(response)
407
+ if response.status_code == 403:
408
+ raise _project_scope_error(response)
409
+ if response.status_code >= 400:
410
+ raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
411
+
412
+
413
+ def _schema_plan_response(response: httpx.Response, url: str) -> dict[str, Any]:
414
+ _raise_for_schema_plan_status(response)
415
+ result = _parse_json_response(response, url)
416
+ if not isinstance(result, dict) or "id" not in result or "status" not in result:
417
+ raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
418
+ return result
419
+
420
+
421
+ def post_schema_plan(
361
422
  *,
362
423
  api_url: str,
363
424
  api_key: str,
@@ -366,10 +427,10 @@ def post_detect_from_ddl(
366
427
  complete_source: bool = False,
367
428
  transport: Optional[httpx.BaseTransport] = None,
368
429
  ) -> dict[str, Any]:
369
- """POST /api/ci/projects/{project_id}/source-changes/detect-from-ddl and return the run record."""
370
- url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/source-changes/detect-from-ddl"
430
+ """POST /api/ci/projects/{project_id}/schema/plans and return the PLANNING plan record."""
431
+ url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/schema/plans"
371
432
  try:
372
- with _client(transport=transport) as client:
433
+ with _client(timeout=ONTOLOGY_TREE_TIMEOUT_SECONDS, transport=transport) as client:
373
434
  response = client.post(
374
435
  url,
375
436
  json={"ddl": ddl, "complete_source": complete_source},
@@ -377,49 +438,88 @@ def post_detect_from_ddl(
377
438
  )
378
439
  except httpx.HTTPError as exc:
379
440
  raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
441
+ return _schema_plan_response(response, url)
380
442
 
381
- if response.status_code == 401:
382
- raise AuthError("The Cassis API rejected the API key (invalid or expired).")
383
- if response.status_code == 409:
384
- raise SourceChangeConflictError(str(_detail_or_text(response)))
385
- if response.status_code in (403, 404):
386
- raise _project_scope_error(response)
387
- if response.status_code >= 400:
388
- raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
389
- result = _parse_json_response(response, url)
390
- if not isinstance(result, dict) or "run_id" not in result:
391
- raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
392
- return result
443
+
444
+ def post_schema_plan_warehouse(
445
+ *,
446
+ api_url: str,
447
+ api_key: str,
448
+ project_id: str,
449
+ transport: Optional[httpx.BaseTransport] = None,
450
+ ) -> dict[str, Any]:
451
+ """POST /api/ci/projects/{project_id}/schema/plans/warehouse and return the PLANNING plan record.
452
+
453
+ The server introspects the project's connected warehouse instead of parsing
454
+ a DDL; the plan is always whole-source.
455
+ """
456
+ url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/schema/plans/warehouse"
457
+ try:
458
+ with _client(transport=transport) as client:
459
+ response = client.post(url, headers={"Authorization": f"Bearer {api_key}"})
460
+ except httpx.HTTPError as exc:
461
+ raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
462
+ return _schema_plan_response(response, url)
393
463
 
394
464
 
395
- def get_source_change_run(
465
+ def get_schema_plan(
396
466
  *,
397
467
  api_url: str,
398
468
  api_key: str,
399
469
  project_id: str,
400
- run_id: str,
470
+ plan_id: str,
401
471
  transport: Optional[httpx.BaseTransport] = None,
402
472
  ) -> dict[str, Any]:
403
- """GET /api/ci/projects/{project_id}/source-changes/runs/{run_id} and return the run record."""
404
- url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/source-changes/runs/{run_id}"
473
+ """GET /api/ci/projects/{project_id}/schema/plans/{plan_id} and return the plan record."""
474
+ url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/schema/plans/{plan_id}"
405
475
  try:
406
476
  with _client(transport=transport) as client:
407
477
  response = client.get(url, headers={"Authorization": f"Bearer {api_key}"})
408
478
  except httpx.HTTPError as exc:
409
479
  raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
480
+ return _schema_plan_response(response, url)
410
481
 
411
- if response.status_code == 401:
412
- raise AuthError("The Cassis API rejected the API key (invalid or expired).")
413
- if response.status_code in (403, 404):
414
- raise _project_scope_error(response)
415
- if response.status_code >= 400:
416
- raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
482
+
483
+ def get_schema_plan_checkout(
484
+ *,
485
+ api_url: str,
486
+ api_key: str,
487
+ project_id: str,
488
+ plan_id: str,
489
+ transport: Optional[httpx.BaseTransport] = None,
490
+ ) -> dict[str, Any]:
491
+ """GET /api/ci/projects/{project_id}/schema/plans/{plan_id}/checkout: the post-apply ontology tree."""
492
+ url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/schema/plans/{plan_id}/checkout"
493
+ try:
494
+ with _client(timeout=ONTOLOGY_TREE_TIMEOUT_SECONDS, transport=transport) as client:
495
+ response = client.get(url, headers={"Authorization": f"Bearer {api_key}"})
496
+ except httpx.HTTPError as exc:
497
+ raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
498
+ _raise_for_schema_plan_status(response)
417
499
  result = _parse_json_response(response, url)
418
- if not isinstance(result, dict) or "run_id" not in result:
500
+ if not isinstance(result, dict) or not isinstance(result.get("files"), dict):
419
501
  raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
420
502
  return result
421
503
 
422
504
 
505
+ def post_schema_plan_apply(
506
+ *,
507
+ api_url: str,
508
+ api_key: str,
509
+ project_id: str,
510
+ plan_id: str,
511
+ transport: Optional[httpx.BaseTransport] = None,
512
+ ) -> dict[str, Any]:
513
+ """POST /api/ci/projects/{project_id}/schema/plans/{plan_id}/apply and return the APPLYING plan record."""
514
+ url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/schema/plans/{plan_id}/apply"
515
+ try:
516
+ with _client(transport=transport) as client:
517
+ response = client.post(url, headers={"Authorization": f"Bearer {api_key}"})
518
+ except httpx.HTTPError as exc:
519
+ raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
520
+ return _schema_plan_response(response, url)
521
+
522
+
423
523
  def post_eval_run_start(
424
524
  *,
425
525
  api_url: str,
@@ -459,6 +559,11 @@ def post_eval_run_start(
459
559
  "An eval run is already active for this project — wait for it to finish or cancel it "
460
560
  "(in the webapp's Evals page, or with the run id printed when it was started)."
461
561
  )
562
+ if response.status_code == 404 and _is_branch_not_found(_detail_or_text(response)):
563
+ # Distinct from a project-scope 404: the key and project are fine, the
564
+ # branch name is not, and the generic copy sends the user to check
565
+ # permissions instead.
566
+ raise EvalBranchNotFoundError(_detail_or_text(response))
462
567
  if response.status_code in (403, 404):
463
568
  raise _project_scope_error(response)
464
569
  if response.status_code >= 400:
@@ -771,80 +876,6 @@ def post_issue_status(
771
876
  return result
772
877
 
773
878
 
774
- class SourceChangeNotFoundError(ApiError):
775
- """The project has no source change with this id."""
776
-
777
-
778
- def get_source_changes(
779
- *,
780
- api_url: str,
781
- api_key: str,
782
- project_id: str,
783
- status: Optional[str] = None,
784
- limit: Optional[int] = None,
785
- offset: Optional[int] = None,
786
- transport: Optional[httpx.BaseTransport] = None,
787
- ) -> dict[str, Any]:
788
- """GET /api/ci/projects/{project_id}/source-changes and return the {items, total} page."""
789
- url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/source-changes"
790
- params: dict[str, str] = {}
791
- if status:
792
- params["status"] = status
793
- if limit is not None:
794
- params["limit"] = str(limit)
795
- if offset is not None:
796
- params["offset"] = str(offset)
797
- try:
798
- with _client(transport=transport) as client:
799
- response = client.get(url, params=params or None, headers={"Authorization": f"Bearer {api_key}"})
800
- except httpx.HTTPError as exc:
801
- raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
802
-
803
- if response.status_code == 401:
804
- raise AuthError("The Cassis API rejected the API key (invalid or expired).")
805
- if response.status_code in (403, 404):
806
- raise _project_scope_error(response)
807
- if response.status_code >= 400:
808
- raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
809
- result = _parse_json_response(response, url)
810
- if not isinstance(result, dict) or not isinstance(result.get("items"), list) or "total" not in result:
811
- raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
812
- return result
813
-
814
-
815
- def get_source_change(
816
- *,
817
- api_url: str,
818
- api_key: str,
819
- project_id: str,
820
- change_id: str,
821
- transport: Optional[httpx.BaseTransport] = None,
822
- ) -> dict[str, Any]:
823
- """GET /api/ci/projects/{project_id}/source-changes/{change_id} and return the full change."""
824
- url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/source-changes/{change_id}"
825
- try:
826
- with _client(transport=transport) as client:
827
- response = client.get(url, headers={"Authorization": f"Bearer {api_key}"})
828
- except httpx.HTTPError as exc:
829
- raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
830
-
831
- if response.status_code == 401:
832
- raise AuthError("The Cassis API rejected the API key (invalid or expired).")
833
- # Exact-match wire contract with the source-change endpoints' 404 detail
834
- # (see backend/app/endpoints/ci.py): it distinguishes a missing change
835
- # (exit 1) from a project-scope 404 (exit 3).
836
- if response.status_code == 404 and _detail_or_text(response) == "Source change not found":
837
- raise SourceChangeNotFoundError(f"No source change {change_id} in project {project_id}.")
838
- if response.status_code in (403, 404):
839
- raise _project_scope_error(response)
840
- if response.status_code >= 400:
841
- raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
842
- result = _parse_json_response(response, url)
843
- if not isinstance(result, dict) or "id" not in result:
844
- raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
845
- return result
846
-
847
-
848
879
  def post_ontology_fmt(
849
880
  *,
850
881
  api_url: str,
@@ -5,11 +5,13 @@ from __future__ import annotations
5
5
  import os
6
6
  import re
7
7
  import subprocess
8
+ import time
8
9
  from pathlib import Path
9
- from typing import Optional
10
+ from typing import Callable, Optional, TypeVar
10
11
  from uuid import UUID
11
12
 
12
13
  import typer
14
+ from cassis_cli.api import ApiError, AuthError
13
15
 
14
16
  # Repository directory the ontology tree is exported under. Must match the
15
17
  # project's git-sync "Path" setting in Cassis (default "cassis").
@@ -31,6 +33,15 @@ EXIT_INTERRUPTED = 130
31
33
  # backend/app/schemas/ci.py (the server's 422 remains the backstop).
32
34
  MAX_FILES = 20_000
33
35
  MAX_TOTAL_BYTES = 100 * 1024 * 1024
36
+ # Mirrors the server's DDL upload ceiling (backend/app/endpoints/uploads.py).
37
+ MAX_DDL_BYTES = 10 * 1024 * 1024
38
+
39
+ # Consecutive poll failures tolerated before a wait gives up: covers transient
40
+ # blips (LB hiccup, brief network loss) without letting a permanently broken
41
+ # poll (deleted run/project) spin until --timeout.
42
+ MAX_CONSECUTIVE_POLL_FAILURES = 5
43
+
44
+ T = TypeVar("T")
34
45
 
35
46
 
36
47
  def api_failure(exc: Exception) -> "typer.Exit":
@@ -95,6 +106,151 @@ def git_file_states(directory: Path) -> Optional[tuple[set[str], set[str]]]:
95
106
  return tracked, dirty
96
107
 
97
108
 
109
+ def sync_ontology_tree(
110
+ ontology_dir: Path,
111
+ files: "dict[str, str]",
112
+ *,
113
+ prune: bool = True,
114
+ skip_unchanged: bool = False,
115
+ before_delete: "Optional[Callable[[list[str]], None]]" = None,
116
+ ) -> "tuple[list[str], list[str], list[dict[str, str]]]":
117
+ """Write a server-rendered ontology tree under ``ontology_dir``; prune what git can restore.
118
+
119
+ The write/prune contract shared by ``ontology pull`` and ``schema apply``.
120
+ Returns ``(written, deleted, kept)``: ``written`` lists every file written
121
+ (with ``skip_unchanged``, only those whose content actually changed),
122
+ ``deleted`` the stale ontology files removed, ``kept`` the stale files left
123
+ in place as ``{"path", "reason"}`` entries. ``before_delete`` is called with
124
+ the paths about to be deleted, when there are any, so a command can announce
125
+ them first.
126
+
127
+ The server controls the paths: anything escaping ``ontology_dir`` exits 3
128
+ rather than being trusted. A file that cannot be written or deleted exits 2
129
+ (a local checkout problem: permissions, dir/file collision). Pruning only
130
+ ever deletes a stale file that is tracked and unmodified in git: an
131
+ untracked or locally modified file is user work Cassis has never seen, and
132
+ outside a git work tree nothing is deleted at all (#27).
133
+ """
134
+ ontology_dir = ontology_dir.resolve()
135
+ written: list[str] = []
136
+ for rel, content in sorted(files.items()):
137
+ dest = (ontology_dir / rel).resolve()
138
+ if not dest.is_relative_to(ontology_dir):
139
+ typer.secho(f"Refusing to write outside {ontology_dir}: {rel!r}", fg=typer.colors.RED, err=True)
140
+ raise typer.Exit(EXIT_TRANSPORT)
141
+ try:
142
+ dest.parent.mkdir(parents=True, exist_ok=True)
143
+ if skip_unchanged and dest.exists() and dest.read_text(encoding="utf-8") == content:
144
+ continue
145
+ dest.write_text(content, encoding="utf-8")
146
+ except OSError as exc:
147
+ typer.secho(f"Cannot write {dest}: {exc}", fg=typer.colors.RED, err=True)
148
+ raise typer.Exit(EXIT_USAGE) from exc
149
+ written.append(rel)
150
+
151
+ deleted: list[str] = []
152
+ kept: list[dict[str, str]] = []
153
+ if prune and ontology_dir.is_dir():
154
+ stale = sorted(set(collect_files(ontology_dir)) - set(files))
155
+ if stale:
156
+ states = git_file_states(ontology_dir)
157
+ to_delete: list[str] = []
158
+ if states is None:
159
+ kept = [{"path": rel, "reason": "not in a git repository"} for rel in stale]
160
+ else:
161
+ tracked, dirty = states
162
+ for rel in stale:
163
+ if rel not in tracked:
164
+ kept.append({"path": rel, "reason": "untracked in git"})
165
+ elif rel in dirty:
166
+ kept.append({"path": rel, "reason": "locally modified"})
167
+ else:
168
+ to_delete.append(rel)
169
+ if to_delete and before_delete is not None:
170
+ before_delete(to_delete)
171
+ for rel in to_delete:
172
+ try:
173
+ (ontology_dir / rel).unlink()
174
+ except OSError as exc:
175
+ typer.secho(f"Cannot delete {ontology_dir / rel}: {exc}", fg=typer.colors.RED, err=True)
176
+ raise typer.Exit(EXIT_USAGE) from exc
177
+ deleted.append(rel)
178
+ return written, deleted, kept
179
+
180
+
181
+ def _echo_error(exc: Exception) -> None:
182
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
183
+
184
+
185
+ def echo_poll_retry(exc: ApiError) -> None:
186
+ """The standard "(poll failed, retrying: ...)" notice, for ``poll_until(on_retry=...)``."""
187
+ typer.secho(f"(poll failed, retrying: {exc})", fg=typer.colors.YELLOW, err=True)
188
+
189
+
190
+ def poll_until(
191
+ fetch: "Callable[[], T]",
192
+ is_terminal: "Callable[[T], bool]",
193
+ *,
194
+ poll_interval: float,
195
+ timeout: float,
196
+ on_timeout: "Callable[[T], None]",
197
+ max_consecutive_failures: int = MAX_CONSECUTIVE_POLL_FAILURES,
198
+ on_progress: "Optional[Callable[[T], None]]" = None,
199
+ on_auth_error: "Optional[Callable[[AuthError], None]]" = None,
200
+ on_retry: "Optional[Callable[[ApiError], None]]" = None,
201
+ on_give_up: "Optional[Callable[[ApiError, int], None]]" = None,
202
+ ) -> T:
203
+ """Call ``fetch`` every ``poll_interval`` seconds until ``is_terminal`` accepts what it returned.
204
+
205
+ The one wait loop behind every ``--wait``: the record is fetched at least
206
+ once (so ``--timeout 0`` means "poll once"), ``on_progress`` sees every
207
+ record fetched, and the terminal record is returned. Exits 3 (transport),
208
+ after the matching callback has printed its message, when:
209
+
210
+ - the deadline passes while the record is still not terminal
211
+ (``on_timeout`` gets the last record);
212
+ - ``fetch`` raises ``AuthError`` (fail fast: retrying a revoked key can't
213
+ succeed; ``on_auth_error``, default: the error text);
214
+ - ``fetch`` raises ``ApiError`` ``max_consecutive_failures`` times in a row
215
+ (``on_give_up``, default: the error text). Each tolerated failure is
216
+ reported through ``on_retry`` (default: silent) and the wait continues,
217
+ so a transient blip never abandons a multi-minute run.
218
+
219
+ Anything else ``fetch`` raises, ``typer.Exit`` and ``KeyboardInterrupt``
220
+ included, propagates untouched: the caller decides whether to cancel the
221
+ server-side run on Ctrl-C.
222
+ """
223
+ deadline = time.monotonic() + timeout
224
+ failures = 0
225
+ while True:
226
+ try:
227
+ record = fetch()
228
+ except AuthError as exc:
229
+ (on_auth_error or _echo_error)(exc)
230
+ raise typer.Exit(EXIT_TRANSPORT) from exc
231
+ except ApiError as exc:
232
+ failures += 1
233
+ if failures >= max_consecutive_failures:
234
+ if on_give_up is not None:
235
+ on_give_up(exc, failures)
236
+ else:
237
+ _echo_error(exc)
238
+ raise typer.Exit(EXIT_TRANSPORT) from exc
239
+ if on_retry is not None:
240
+ on_retry(exc)
241
+ time.sleep(poll_interval)
242
+ continue
243
+ failures = 0
244
+ if on_progress is not None:
245
+ on_progress(record)
246
+ if is_terminal(record):
247
+ return record
248
+ if time.monotonic() >= deadline:
249
+ on_timeout(record)
250
+ raise typer.Exit(EXIT_TRANSPORT)
251
+ time.sleep(poll_interval)
252
+
253
+
98
254
  def is_legacy_domain_file(rel_path: str) -> bool:
99
255
  """Whether a base-relative path is a legacy (pre-Markdown) domain file.
100
256