cassis-cli 2.0.0__tar.gz → 2.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cassis_cli-2.0.0 → cassis_cli-2.2.0}/PKG-INFO +19 -4
- {cassis_cli-2.0.0 → cassis_cli-2.2.0}/README.md +18 -3
- {cassis_cli-2.0.0 → cassis_cli-2.2.0}/cassis_cli/api.py +85 -1
- {cassis_cli-2.0.0 → cassis_cli-2.2.0}/cassis_cli/eval.py +109 -3
- {cassis_cli-2.0.0 → cassis_cli-2.2.0}/cassis_cli/guide.py +1 -1
- {cassis_cli-2.0.0 → cassis_cli-2.2.0}/cassis_cli/issues.py +14 -5
- {cassis_cli-2.0.0 → cassis_cli-2.2.0}/cassis_cli/ontology_design_guide.md +55 -11
- {cassis_cli-2.0.0 → cassis_cli-2.2.0}/cassis_cli/schema.py +97 -0
- {cassis_cli-2.0.0 → cassis_cli-2.2.0}/pyproject.toml +1 -1
- {cassis_cli-2.0.0 → cassis_cli-2.2.0}/LICENSE +0 -0
- {cassis_cli-2.0.0 → cassis_cli-2.2.0}/NOTICE +0 -0
- {cassis_cli-2.0.0 → cassis_cli-2.2.0}/cassis_cli/__init__.py +0 -0
- {cassis_cli-2.0.0 → cassis_cli-2.2.0}/cassis_cli/common.py +0 -0
- {cassis_cli-2.0.0 → cassis_cli-2.2.0}/cassis_cli/main.py +0 -0
- {cassis_cli-2.0.0 → cassis_cli-2.2.0}/cassis_cli/ontology.py +0 -0
- {cassis_cli-2.0.0 → cassis_cli-2.2.0}/cassis_cli/projects.py +0 -0
- {cassis_cli-2.0.0 → cassis_cli-2.2.0}/cassis_cli/schema_plan.py +0 -0
- {cassis_cli-2.0.0 → cassis_cli-2.2.0}/cassis_cli/status.py +0 -0
- {cassis_cli-2.0.0 → cassis_cli-2.2.0}/cassis_cli/verify.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cassis-cli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.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,10 +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 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.
|
|
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. `cassis schema plan <ddl> --dry-run` is the prepare-ahead variant: the plan is computed synchronously and nothing is kept in Cassis (no plan to apply or resume, the current plan untouched), so a dbt model or migration still in a PR can be planned against safely; `--write-checkout` writes the ontology files it would produce into the checkout, to commit alongside the schema change.
|
|
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
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
|
-
- `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
|
|
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, cause and ontology domain, and showing each issue's domain so you can work through one domain at a time), `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
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.
|
|
43
43
|
|
|
44
44
|
## Install
|
|
@@ -104,6 +104,17 @@ cassis eval run --project ... --case 019f0000-0000-7000-8000-0000000000ca
|
|
|
104
104
|
|
|
105
105
|
# Start the run and return immediately (poll in the webapp):
|
|
106
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
|
|
107
118
|
|
|
108
119
|
# Probe questions through the text-to-SQL agent using the local ontology files
|
|
109
120
|
# (one full agent run per question, expect ~30-90s each; repeat -q for several):
|
|
@@ -129,6 +140,7 @@ cassis schema plan schema.sql --complete
|
|
|
129
140
|
cassis schema apply schema.sql --complete # writes cassis/ locally
|
|
130
141
|
cassis schema push schema.sql --complete --yes # schema + ontology to the app
|
|
131
142
|
cassis schema plan --warehouse # warehouse-connected projects: introspect instead
|
|
143
|
+
cassis schema plan future.sql --dry-run --write-checkout # plan a not-yet-deployed DDL, keep nothing server-side
|
|
132
144
|
|
|
133
145
|
# List the projects the API key can reach (id, name, published version, dialect):
|
|
134
146
|
cassis projects list
|
|
@@ -142,6 +154,9 @@ cassis issues analyze
|
|
|
142
154
|
cassis issues list --status open
|
|
143
155
|
cassis issues show 019f0000-0000-7000-8000-0000000000e1
|
|
144
156
|
|
|
157
|
+
# Work one ontology domain at a time (nested domains included):
|
|
158
|
+
cassis issues list --domain sales
|
|
159
|
+
|
|
145
160
|
# Read what the agent saw for one occurrence (ids from `issues show`):
|
|
146
161
|
cassis issues evidence 019f0000-0000-7000-8000-0000000000e1 019f0000-0000-7000-8000-0000000000c1
|
|
147
162
|
|
|
@@ -197,7 +212,7 @@ cassis ontology fmt --check
|
|
|
197
212
|
| ---- | ------------------------------------------------------------------------------ |
|
|
198
213
|
| 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) |
|
|
199
214
|
| 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)) |
|
|
200
|
-
| 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits) |
|
|
215
|
+
| 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) |
|
|
201
216
|
| 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 |
|
|
202
217
|
|
|
203
218
|
Commands that send the local tree (`check`, `fmt`, `upload`, `eval run`, `test`) accept up to
|
|
@@ -13,10 +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 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.
|
|
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. `cassis schema plan <ddl> --dry-run` is the prepare-ahead variant: the plan is computed synchronously and nothing is kept in Cassis (no plan to apply or resume, the current plan untouched), so a dbt model or migration still in a PR can be planned against safely; `--write-checkout` writes the ontology files it would produce into the checkout, to commit alongside the schema change.
|
|
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
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
|
-
- `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
|
|
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, cause and ontology domain, and showing each issue's domain so you can work through one domain at a time), `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
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.
|
|
21
21
|
|
|
22
22
|
## Install
|
|
@@ -82,6 +82,17 @@ cassis eval run --project ... --case 019f0000-0000-7000-8000-0000000000ca
|
|
|
82
82
|
|
|
83
83
|
# Start the run and return immediately (poll in the webapp):
|
|
84
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
|
|
85
96
|
|
|
86
97
|
# Probe questions through the text-to-SQL agent using the local ontology files
|
|
87
98
|
# (one full agent run per question, expect ~30-90s each; repeat -q for several):
|
|
@@ -107,6 +118,7 @@ cassis schema plan schema.sql --complete
|
|
|
107
118
|
cassis schema apply schema.sql --complete # writes cassis/ locally
|
|
108
119
|
cassis schema push schema.sql --complete --yes # schema + ontology to the app
|
|
109
120
|
cassis schema plan --warehouse # warehouse-connected projects: introspect instead
|
|
121
|
+
cassis schema plan future.sql --dry-run --write-checkout # plan a not-yet-deployed DDL, keep nothing server-side
|
|
110
122
|
|
|
111
123
|
# List the projects the API key can reach (id, name, published version, dialect):
|
|
112
124
|
cassis projects list
|
|
@@ -120,6 +132,9 @@ cassis issues analyze
|
|
|
120
132
|
cassis issues list --status open
|
|
121
133
|
cassis issues show 019f0000-0000-7000-8000-0000000000e1
|
|
122
134
|
|
|
135
|
+
# Work one ontology domain at a time (nested domains included):
|
|
136
|
+
cassis issues list --domain sales
|
|
137
|
+
|
|
123
138
|
# Read what the agent saw for one occurrence (ids from `issues show`):
|
|
124
139
|
cassis issues evidence 019f0000-0000-7000-8000-0000000000e1 019f0000-0000-7000-8000-0000000000c1
|
|
125
140
|
|
|
@@ -175,7 +190,7 @@ cassis ontology fmt --check
|
|
|
175
190
|
| ---- | ------------------------------------------------------------------------------ |
|
|
176
191
|
| 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) |
|
|
177
192
|
| 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)) |
|
|
178
|
-
| 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits) |
|
|
193
|
+
| 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) |
|
|
179
194
|
| 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 |
|
|
180
195
|
|
|
181
196
|
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.
|
|
@@ -357,6 +372,10 @@ class SchemaPlanConflictError(ApiError):
|
|
|
357
372
|
"""409 from the schema-plan routes: a plan is already active, the project is connected, the plan is stale / expired / not ready."""
|
|
358
373
|
|
|
359
374
|
|
|
375
|
+
class SchemaPlanRejectedError(ApiError):
|
|
376
|
+
"""400 from the preview route: the DDL does not parse or reads as truncated (the user's file, not transport)."""
|
|
377
|
+
|
|
378
|
+
|
|
360
379
|
class SchemaPlanNotFoundError(ApiError):
|
|
361
380
|
"""404 with the server's exact detail `"Schema plan not found"` (a project-scope 404 is a different error)."""
|
|
362
381
|
|
|
@@ -447,6 +466,61 @@ def post_schema_plan_warehouse(
|
|
|
447
466
|
return _schema_plan_response(response, url)
|
|
448
467
|
|
|
449
468
|
|
|
469
|
+
def _schema_plan_preview_response(response: httpx.Response, url: str) -> dict[str, Any]:
|
|
470
|
+
if response.status_code == 400:
|
|
471
|
+
raise SchemaPlanRejectedError(str(_detail_or_text(response)))
|
|
472
|
+
_raise_for_schema_plan_status(response)
|
|
473
|
+
result = _parse_json_response(response, url)
|
|
474
|
+
if not isinstance(result, dict) or "document" not in result or not isinstance(result.get("files"), dict):
|
|
475
|
+
raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
|
|
476
|
+
return result
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def post_schema_plan_preview(
|
|
480
|
+
*,
|
|
481
|
+
api_url: str,
|
|
482
|
+
api_key: str,
|
|
483
|
+
project_id: str,
|
|
484
|
+
ddl: str,
|
|
485
|
+
complete_source: bool = False,
|
|
486
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
487
|
+
) -> dict[str, Any]:
|
|
488
|
+
"""POST /api/ci/projects/{project_id}/schema/plans/preview: plan + rendered tree, nothing persisted.
|
|
489
|
+
|
|
490
|
+
Synchronous: the server computes the plan in the request (no plan row, no
|
|
491
|
+
job), so the tree timeout applies. A DDL the server cannot parse, or that
|
|
492
|
+
reads as truncated, is a 400 → `SchemaPlanRejectedError`.
|
|
493
|
+
"""
|
|
494
|
+
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/schema/plans/preview"
|
|
495
|
+
try:
|
|
496
|
+
with _client(timeout=ONTOLOGY_TREE_TIMEOUT_SECONDS, transport=transport) as client:
|
|
497
|
+
response = client.post(
|
|
498
|
+
url,
|
|
499
|
+
json={"ddl": ddl, "complete_source": complete_source},
|
|
500
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
501
|
+
)
|
|
502
|
+
except httpx.HTTPError as exc:
|
|
503
|
+
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
504
|
+
return _schema_plan_preview_response(response, url)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def post_schema_plan_preview_warehouse(
|
|
508
|
+
*,
|
|
509
|
+
api_url: str,
|
|
510
|
+
api_key: str,
|
|
511
|
+
project_id: str,
|
|
512
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
513
|
+
) -> dict[str, Any]:
|
|
514
|
+
"""POST /api/ci/projects/{project_id}/schema/plans/preview/warehouse: the warehouse twin of the preview."""
|
|
515
|
+
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/schema/plans/preview/warehouse"
|
|
516
|
+
try:
|
|
517
|
+
with _client(timeout=ONTOLOGY_TREE_TIMEOUT_SECONDS, transport=transport) as client:
|
|
518
|
+
response = client.post(url, headers={"Authorization": f"Bearer {api_key}"})
|
|
519
|
+
except httpx.HTTPError as exc:
|
|
520
|
+
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
521
|
+
return _schema_plan_preview_response(response, url)
|
|
522
|
+
|
|
523
|
+
|
|
450
524
|
def get_schema_plan(
|
|
451
525
|
*,
|
|
452
526
|
api_url: str,
|
|
@@ -544,6 +618,11 @@ def post_eval_run_start(
|
|
|
544
618
|
"An eval run is already active for this project — wait for it to finish or cancel it "
|
|
545
619
|
"(in the webapp's Evals page, or with the run id printed when it was started)."
|
|
546
620
|
)
|
|
621
|
+
if response.status_code == 404 and _is_branch_not_found(_detail_or_text(response)):
|
|
622
|
+
# Distinct from a project-scope 404: the key and project are fine, the
|
|
623
|
+
# branch name is not, and the generic copy sends the user to check
|
|
624
|
+
# permissions instead.
|
|
625
|
+
raise EvalBranchNotFoundError(_detail_or_text(response))
|
|
547
626
|
if response.status_code in (403, 404):
|
|
548
627
|
raise _project_scope_error(response)
|
|
549
628
|
if response.status_code >= 400:
|
|
@@ -770,11 +849,16 @@ def get_issues(
|
|
|
770
849
|
status: Optional[str] = None,
|
|
771
850
|
impact: Optional[str] = None,
|
|
772
851
|
cause: Optional[str] = None,
|
|
852
|
+
domain: Optional[str] = None,
|
|
773
853
|
transport: Optional[httpx.BaseTransport] = None,
|
|
774
854
|
) -> list[dict[str, Any]]:
|
|
775
855
|
"""GET /api/ci/projects/{project_id}/issues and return the issue list."""
|
|
776
856
|
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/issues"
|
|
777
|
-
params = {
|
|
857
|
+
params = {
|
|
858
|
+
key: value
|
|
859
|
+
for key, value in (("status", status), ("impact", impact), ("cause", cause), ("domain", domain))
|
|
860
|
+
if value
|
|
861
|
+
}
|
|
778
862
|
result = _get_issue_json(url, api_key, transport, params=params)
|
|
779
863
|
if not isinstance(result, list) or not all(isinstance(issue, dict) and "id" in issue for issue in result):
|
|
780
864
|
raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
|
|
@@ -14,6 +14,7 @@ from cassis_cli.api import (
|
|
|
14
14
|
DEFAULT_API_URL,
|
|
15
15
|
ApiError,
|
|
16
16
|
AuthError,
|
|
17
|
+
EvalBranchNotFoundError,
|
|
17
18
|
EvalCaseExistsError,
|
|
18
19
|
EvalCaseGoldSqlError,
|
|
19
20
|
EvalCaseNotFoundError,
|
|
@@ -56,6 +57,15 @@ _STATUS_COLORS = {
|
|
|
56
57
|
"plan_unexecutable": typer.colors.YELLOW,
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
# Caps on the evidence printed under a failed case. Bounded so a suite of
|
|
61
|
+
# failures stays readable in a CI log, and always announced when they bite.
|
|
62
|
+
_MAX_ERROR_CHARS = 200
|
|
63
|
+
_MAX_REASONING_CHARS = 500
|
|
64
|
+
_MAX_SQL_LINES = 20
|
|
65
|
+
# A line cap alone bounds nothing: a gold SQL written as one long line in a YAML
|
|
66
|
+
# case is a single line of any size, printed twice per failing case.
|
|
67
|
+
_MAX_SQL_CHARS = 2000
|
|
68
|
+
|
|
59
69
|
|
|
60
70
|
def _run_page_url(app_url: str, project_id: str, run_id: str) -> str:
|
|
61
71
|
"""Webapp URL of the run's detail view (Evals page, runs tab)."""
|
|
@@ -118,6 +128,84 @@ def _print_validation_failure(detail: object, base_path: str) -> None:
|
|
|
118
128
|
typer.secho(str(detail), fg=typer.colors.RED, err=True)
|
|
119
129
|
|
|
120
130
|
|
|
131
|
+
def _shorten(text: str, limit: int) -> str:
|
|
132
|
+
"""Shorten to `limit` characters and say so. A silent cut can drop the cause."""
|
|
133
|
+
text = " ".join(text.split())
|
|
134
|
+
if len(text) <= limit:
|
|
135
|
+
return text
|
|
136
|
+
return f"{text[:limit]}... (truncated, {len(text)} chars)"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _print_sql(label: str, sql: str) -> None:
|
|
140
|
+
"""Print SQL under a failed case, bounded on both lines and characters."""
|
|
141
|
+
lines = sql.strip().splitlines()
|
|
142
|
+
typer.echo(f" {label}:")
|
|
143
|
+
budget = _MAX_SQL_CHARS
|
|
144
|
+
shown = 0
|
|
145
|
+
for line in lines[:_MAX_SQL_LINES]:
|
|
146
|
+
if budget <= 0:
|
|
147
|
+
break
|
|
148
|
+
if len(line) > budget:
|
|
149
|
+
typer.echo(f" {line[:budget]}... (truncated, {len(line)} chars)")
|
|
150
|
+
budget = 0
|
|
151
|
+
else:
|
|
152
|
+
typer.echo(f" {line}")
|
|
153
|
+
budget -= len(line)
|
|
154
|
+
shown += 1
|
|
155
|
+
if shown < len(lines):
|
|
156
|
+
typer.echo(f" ... ({len(lines) - shown} more lines)")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _print_failure_evidence(r: dict[str, Any]) -> None:
|
|
160
|
+
"""Print what a failed case needs to be diagnosed here, without row values.
|
|
161
|
+
|
|
162
|
+
Which field carries the reason depends on the status: a missing-concept
|
|
163
|
+
failure explains itself through `missing_concepts`, a judge rejection
|
|
164
|
+
through `judge_reasoning`, a data mismatch through `error` and the row
|
|
165
|
+
counts. Printing only `error` — as this did — showed nothing at all for the
|
|
166
|
+
first two.
|
|
167
|
+
|
|
168
|
+
Expected and actual row values are deliberately not printed. They ride in
|
|
169
|
+
`--json` and the webapp instead: `cassis verify` output is a CI job log, and
|
|
170
|
+
warehouse values in a job log travel further than whoever reads it intends.
|
|
171
|
+
|
|
172
|
+
Every field is read with `.get`, so an older server that returns none of
|
|
173
|
+
them degrades to the error line alone.
|
|
174
|
+
"""
|
|
175
|
+
error = r.get("error")
|
|
176
|
+
if error:
|
|
177
|
+
typer.echo(f" {_shorten(str(error), _MAX_ERROR_CHARS)}")
|
|
178
|
+
|
|
179
|
+
names = [str(c["name"]) for c in (r.get("missing_concepts") or []) if isinstance(c, dict) and c.get("name")]
|
|
180
|
+
if names:
|
|
181
|
+
typer.echo(f" Missing concepts: {', '.join(names)}")
|
|
182
|
+
|
|
183
|
+
if r.get("judge_verdict"):
|
|
184
|
+
typer.echo(f" Judge verdict: {r['judge_verdict']}")
|
|
185
|
+
if r.get("judge_reasoning"):
|
|
186
|
+
typer.echo(f" Judge reasoning: {_shorten(str(r['judge_reasoning']), _MAX_REASONING_CHARS)}")
|
|
187
|
+
|
|
188
|
+
expected, actual = r.get("expected_row_count"), r.get("actual_row_count")
|
|
189
|
+
if expected is not None or actual is not None:
|
|
190
|
+
shown_actual = "-" if actual is None else str(actual)
|
|
191
|
+
shown_expected = "-" if expected is None else str(expected)
|
|
192
|
+
typer.echo(f" Rows: {shown_actual} actual vs {shown_expected} expected")
|
|
193
|
+
|
|
194
|
+
# The agent's own words, for the statuses that carry no other explanation
|
|
195
|
+
# (a conversational reply instead of a plan, for one).
|
|
196
|
+
content = r.get("content")
|
|
197
|
+
if content and not error and not names and not r.get("judge_reasoning"):
|
|
198
|
+
typer.echo(f" {_shorten(str(content), _MAX_ERROR_CHARS)}")
|
|
199
|
+
|
|
200
|
+
if r.get("generated_sql"):
|
|
201
|
+
_print_sql("Generated SQL", str(r["generated_sql"]))
|
|
202
|
+
if r.get("gold_sql"):
|
|
203
|
+
label = "Gold SQL"
|
|
204
|
+
if r.get("gold_sql_from_live_case"):
|
|
205
|
+
label = "Gold SQL (the case as it stands now; this result predates snapshotting)"
|
|
206
|
+
_print_sql(label, str(r["gold_sql"]))
|
|
207
|
+
|
|
208
|
+
|
|
121
209
|
def _print_results_table(results: list[dict[str, Any]]) -> None:
|
|
122
210
|
for r in sorted(results, key=lambda r: (r.get("status") == _PASSED, r.get("question") or "")):
|
|
123
211
|
status = r.get("status", "?")
|
|
@@ -129,8 +217,9 @@ def _print_results_table(results: list[dict[str, Any]]) -> None:
|
|
|
129
217
|
question = question[:67] + "..."
|
|
130
218
|
line = f" {icon} {status:<17} {duration:>5} {question}"
|
|
131
219
|
typer.secho(line, fg=color)
|
|
132
|
-
if
|
|
133
|
-
|
|
220
|
+
if status == _PASSED:
|
|
221
|
+
continue
|
|
222
|
+
_print_failure_evidence(r)
|
|
134
223
|
|
|
135
224
|
|
|
136
225
|
def _print_summary(run: dict[str, Any]) -> None:
|
|
@@ -448,7 +537,13 @@ def run(
|
|
|
448
537
|
named case(s) run — e.g. proving one fresh `add-case` in seconds instead of
|
|
449
538
|
rerunning the whole suite. Exits 0 when the run completes with every case
|
|
450
539
|
passed, 1 on any failed case / failed run / invalid tree, 2 on usage
|
|
451
|
-
errors, 3 on transport
|
|
540
|
+
errors (including a --branch the project does not have), 3 on transport
|
|
541
|
+
errors or --timeout.
|
|
542
|
+
|
|
543
|
+
Failed cases print the generated SQL as the agent wrote it. Row values are
|
|
544
|
+
never printed (they are in --json), but an agent that read your data while
|
|
545
|
+
planning can carry a value it saw into a SQL literal — so treat this output
|
|
546
|
+
as sensitive as the queries themselves.
|
|
452
547
|
"""
|
|
453
548
|
api_key = require_api_key(api_key)
|
|
454
549
|
for case_id in case or []:
|
|
@@ -485,6 +580,17 @@ def run(
|
|
|
485
580
|
except EvalStartValidationError as exc:
|
|
486
581
|
_print_validation_failure(exc.detail, base_path)
|
|
487
582
|
raise typer.Exit(EXIT_VALIDATION_FAILED) from exc
|
|
583
|
+
except EvalBranchNotFoundError as exc:
|
|
584
|
+
# Bad input, not a transport or permissions problem — so exit 2, and say
|
|
585
|
+
# what --branch actually takes instead of pointing at the API key.
|
|
586
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
587
|
+
typer.echo(
|
|
588
|
+
"--branch runs against an ontology branch that already exists in Cassis "
|
|
589
|
+
"(create it in the webapp, or push it with `cassis ontology push`). "
|
|
590
|
+
"To label a run after your local checkout's branch, use --label instead.",
|
|
591
|
+
err=True,
|
|
592
|
+
)
|
|
593
|
+
raise typer.Exit(EXIT_USAGE) from exc
|
|
488
594
|
except ApiError as exc: # covers AuthError and EvalRunActiveError too
|
|
489
595
|
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
490
596
|
raise typer.Exit(EXIT_TRANSPORT) from exc
|
|
@@ -31,7 +31,7 @@ GUIDE_FILENAME = "AGENTS.md"
|
|
|
31
31
|
# Monotonic version of the doctrine text below. Bump it whenever
|
|
32
32
|
# ontology_design_guide.md changes (a backend test enforces the pairing) — it
|
|
33
33
|
# is what lets an older writer recognize a newer guide and leave it alone.
|
|
34
|
-
DOCTRINE_VERSION =
|
|
34
|
+
DOCTRINE_VERSION = 7
|
|
35
35
|
|
|
36
36
|
# Must stay byte-identical to backend/app/services/ontology_guide.py::_BANNER —
|
|
37
37
|
# the server-side git export writes the same file, and differing banners would
|
|
@@ -117,6 +117,9 @@ def list_issues(
|
|
|
117
117
|
status: Optional[str] = typer.Option(None, "--status", help=f"Filter by status ({', '.join(STATUSES)})."),
|
|
118
118
|
impact: Optional[str] = typer.Option(None, "--impact", help=f"Filter by impact ({', '.join(IMPACTS)})."),
|
|
119
119
|
cause: Optional[str] = typer.Option(None, "--cause", help=f"Filter by cause ({', '.join(CAUSES)})."),
|
|
120
|
+
domain: Optional[str] = typer.Option(
|
|
121
|
+
None, "--domain", help="Filter by ontology domain path; nested domains included."
|
|
122
|
+
),
|
|
120
123
|
path: Path = _PATH_OPTION,
|
|
121
124
|
project_id: Optional[str] = _PROJECT_OPTION,
|
|
122
125
|
api_key: Optional[str] = _API_KEY_OPTION,
|
|
@@ -126,9 +129,11 @@ def list_issues(
|
|
|
126
129
|
) -> None:
|
|
127
130
|
"""List the project's issues, prioritized by impact then recurrence.
|
|
128
131
|
|
|
129
|
-
Prints each issue's id, impact, occurrence count, status
|
|
130
|
-
|
|
131
|
-
|
|
132
|
+
Prints each issue's id, impact, occurrence count, status, primary ontology
|
|
133
|
+
domain (`-` when Cassis could not attach one) and title; the id is what
|
|
134
|
+
`cassis issues show`, `resolve`, `dismiss` and `reopen` take. Triage one
|
|
135
|
+
domain at a time with `--domain`, which covers its nested domains too.
|
|
136
|
+
Exits 0 on success, 2 on usage errors, 3 on transport/API errors.
|
|
132
137
|
"""
|
|
133
138
|
status = _validate_choice(status, STATUSES, "--status")
|
|
134
139
|
impact = _validate_choice(impact, IMPACTS, "--impact")
|
|
@@ -144,6 +149,7 @@ def list_issues(
|
|
|
144
149
|
status=status,
|
|
145
150
|
impact=impact,
|
|
146
151
|
cause=cause,
|
|
152
|
+
domain=domain,
|
|
147
153
|
)
|
|
148
154
|
except (AuthError, ApiError) as exc:
|
|
149
155
|
raise api_failure(exc) from exc
|
|
@@ -153,14 +159,16 @@ def list_issues(
|
|
|
153
159
|
raise typer.Exit(EXIT_OK)
|
|
154
160
|
|
|
155
161
|
if not issues:
|
|
156
|
-
typer.echo("No issues match." if (status or impact or cause) else "No issues on this project.")
|
|
162
|
+
typer.echo("No issues match." if (status or impact or cause or domain) else "No issues on this project.")
|
|
157
163
|
raise typer.Exit(EXIT_OK)
|
|
158
164
|
|
|
159
165
|
for issue in issues:
|
|
160
166
|
occurrences = issue.get("occurrence_count_cache") or 0
|
|
167
|
+
# Primary domain only; --json carries the whole list.
|
|
168
|
+
domains = issue.get("domains") or []
|
|
161
169
|
typer.echo(
|
|
162
170
|
f"{issue.get('id')} {issue.get('impact')} x{occurrences} "
|
|
163
|
-
f"{issue.get('status')} {one_line(issue.get('title'))}"
|
|
171
|
+
f"{issue.get('status')} {domains[0] if domains else '-'} {one_line(issue.get('title'))}"
|
|
164
172
|
)
|
|
165
173
|
raise typer.Exit(EXIT_OK)
|
|
166
174
|
|
|
@@ -201,6 +209,7 @@ def show(
|
|
|
201
209
|
f"{issue.get('status')} {issue.get('impact')} {issue.get('cause')} "
|
|
202
210
|
f"{issue.get('occurrence_count_cache', len(occurrences))} occurrence(s)"
|
|
203
211
|
)
|
|
212
|
+
_field("Domains", ", ".join(issue.get("domains") or []) or None)
|
|
204
213
|
_field("Description", issue.get("description"), blank_line=True)
|
|
205
214
|
_field("Suggested action", issue.get("suggested_action"), blank_line=True)
|
|
206
215
|
typer.echo("")
|
|
@@ -107,7 +107,7 @@ Put each fact at the lowest layer that fully owns it:
|
|
|
107
107
|
|
|
108
108
|
```
|
|
109
109
|
column synonym / column description single-column facts: meaning, encoding, unit, date format, nulls
|
|
110
|
-
→ table description single-table facts:
|
|
110
|
+
→ table description single-table facts: scope, mandatory filters, "use this not that"
|
|
111
111
|
→ domain context_md cross-table routing, disambiguation, vocabulary spanning tables
|
|
112
112
|
→ root context truly global rules only: corporate identity, entity hierarchy, global conventions
|
|
113
113
|
```
|
|
@@ -134,13 +134,18 @@ otherwise push it down.
|
|
|
134
134
|
Per-table facts go on that table's `description`; keep only genuinely
|
|
135
135
|
cross-table routing in `context_md`.
|
|
136
136
|
- Metrics are surfaced with their full definition → don't re-paste a formula in
|
|
137
|
-
prose
|
|
137
|
+
prose, and that includes the metric's **own** `description` and `notes` (§8).
|
|
138
|
+
Point to a metric by name **only** when it lives in a *different* domain
|
|
138
139
|
than the prose discussing it; a domain's own metrics are surfaced alongside its
|
|
139
140
|
`context_md` already.
|
|
140
141
|
- Joins are surfaced with their condition and cardinality → don't enumerate join
|
|
141
142
|
keys in prose. The only join content that belongs in prose is a join+filter
|
|
142
143
|
*recipe* no single structured join can express.
|
|
143
144
|
|
|
145
|
+
**Short or empty beats redundant.** When the structured fields already say
|
|
146
|
+
everything worth saying, leave the prose field short or empty. An empty
|
|
147
|
+
description costs nothing; a redundant one goes stale.
|
|
148
|
+
|
|
144
149
|
---
|
|
145
150
|
|
|
146
151
|
## 4. Domains and `context_md`
|
|
@@ -188,9 +193,9 @@ domain. Everything domain-specific moves down.
|
|
|
188
193
|
|
|
189
194
|
A table's `description` answers *"what is this, and what must the SQL agent know
|
|
190
195
|
to use it correctly?"* — business meaning **plus** the correctness facts (scope,
|
|
191
|
-
mandatory filters, gotchas).
|
|
192
|
-
|
|
193
|
-
|
|
196
|
+
mandatory filters, gotchas). The grain goes in the `grain` field — the identifying
|
|
197
|
+
column(s) — not in the description: the agent reads it structurally, and "one row
|
|
198
|
+
per order" in prose is a second copy. Don't enumerate the columns either — the
|
|
194
199
|
column list is surfaced automatically.
|
|
195
200
|
|
|
196
201
|
Use `synonyms` for alternate names a user might say ("purchases", "transactions"),
|
|
@@ -219,10 +224,10 @@ in the `sql` per §7.
|
|
|
219
224
|
## 6. Columns
|
|
220
225
|
|
|
221
226
|
Column enrichment is `description`, `unit`, and `synonyms` — you cannot add or
|
|
222
|
-
rename columns (they come from introspection). The
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
227
|
+
rename columns (they come from introspection). The agent sees a column's name,
|
|
228
|
+
type, `unit` and `synonyms` structurally; the `description` carries what those
|
|
229
|
+
don't: meaning, possible values, encoding, date format, null behavior, any
|
|
230
|
+
mandatory filter.
|
|
226
231
|
|
|
227
232
|
- **Prioritize the trap columns.** Disambiguate similarly-named columns
|
|
228
233
|
(`created_at` vs `updated_at`, `amount` vs `net_amount`), numeric-encoded
|
|
@@ -232,6 +237,14 @@ behavior, any mandatory filter.
|
|
|
232
237
|
- **Set `unit`** whenever a number is a currency, percentage, or duration —
|
|
233
238
|
aggregation correctness depends on it.
|
|
234
239
|
- **Use `synonyms`** for 1–3 short, plain alternate terms a user might use.
|
|
240
|
+
- **Facts, not SQL.** Say what the column holds and how it is encoded ("TRUE =
|
|
241
|
+
the account can sign in"), never a fragment to paste ("filter with
|
|
242
|
+
`is_active = TRUE`", "count people with `COUNT(DISTINCT id)`"). Writing the SQL
|
|
243
|
+
is the agent's job.
|
|
244
|
+
- **Relationships live in joins.** A foreign-key column says what it identifies
|
|
245
|
+
("the owning organization"), not which table it references or on what
|
|
246
|
+
condition; the join carries that, and a description repeating it drifts when
|
|
247
|
+
the join changes.
|
|
235
248
|
- **Keep the join key queryable.** A foreign-key column stays a real, described
|
|
236
249
|
column even when a join covers the relationship — hiding it leaves the agent
|
|
237
250
|
unable to filter or group on it without traversing the join. Test: imagine a
|
|
@@ -312,12 +325,33 @@ unit: euro cents
|
|
|
312
325
|
synonyms:
|
|
313
326
|
- net sales
|
|
314
327
|
- revenue net of refunds
|
|
315
|
-
description:
|
|
328
|
+
description: Revenue kept after refunds; the top-line sales figure.
|
|
316
329
|
```
|
|
317
330
|
|
|
318
331
|
A metric definition beats a prose "revenue = net revenue excluding tax" note —
|
|
319
332
|
the metric is surfaced structurally and can be reused; the prose drifts.
|
|
320
333
|
|
|
334
|
+
**Definition in the fields, meaning in the prose.** `expression`, `filters`,
|
|
335
|
+
`table_schema`/`table_name` and `unit` *are* the definition. Every prose field —
|
|
336
|
+
including the metric's own `description` and `notes` — says what the number means
|
|
337
|
+
and when to use it, never how it is computed: no columns, thresholds, time windows,
|
|
338
|
+
or numerator/denominator. A rule copied into prose is a second definition, and the
|
|
339
|
+
two drift apart (one metric shipped with a 90-day window in `filters` and "30 days"
|
|
340
|
+
in its own description). Business intent is fine ("draft bookings are not
|
|
341
|
+
commitments"); its encoding (`status <> 'DRAFT'`) is not. This holds even when the
|
|
342
|
+
metric's name *is* the term being defined ("active partners"): the reader sees
|
|
343
|
+
`filters` right next to the description. Disambiguate from sibling metrics by the
|
|
344
|
+
question each answers, never by restating their windows or filters. If the fields
|
|
345
|
+
say it all, keep the description to a phrase or leave it empty.
|
|
346
|
+
|
|
347
|
+
```yaml
|
|
348
|
+
# Bad — restates the definition
|
|
349
|
+
description: Active partners = COUNT(DISTINCT partner_id) with a non-cancelled
|
|
350
|
+
booking in the last 90 days.
|
|
351
|
+
# Good — what the number is for
|
|
352
|
+
description: Partners still trading; the activity count used for outreach targets.
|
|
353
|
+
```
|
|
354
|
+
|
|
321
355
|
---
|
|
322
356
|
|
|
323
357
|
## 9. Identifiers and case — where SQL silently breaks
|
|
@@ -351,7 +385,9 @@ description, so a wrong example is worse than none.
|
|
|
351
385
|
|
|
352
386
|
- **Use real stored enum values verbatim.** Never hand-wave a list with "etc." —
|
|
353
387
|
the agent reads `etc.` as license to invent values. If you don't have the exact
|
|
354
|
-
values, omit the list rather than approximate it.
|
|
388
|
+
values, omit the list rather than approximate it. A `DISTINCT` over the column
|
|
389
|
+
*is* the list of stored values: write it plainly ("Stored values: 'EDITOR'"),
|
|
390
|
+
with no "observed so far" hedge and no counts. When two columns supposedly
|
|
355
391
|
share an enum, verify they're identical before encoding both.
|
|
356
392
|
- **Be wary of analogies to external products** — they mislead the moment the
|
|
357
393
|
real mechanic diverges.
|
|
@@ -390,6 +426,14 @@ description, so a wrong example is worse than none.
|
|
|
390
426
|
- **Ground writes in the real schema.** Before referencing a physical table or
|
|
391
427
|
column, confirm it exists. Don't invent tables or columns; the physical schema
|
|
392
428
|
is fixed.
|
|
429
|
+
- **Never persist a measurement.** Prose holds stable encodings and business
|
|
430
|
+
definitions, not what the data shows today. Row counts, distinct counts, shares,
|
|
431
|
+
distributions, funnel rates and "as of" / "currently" / "observed" statements go
|
|
432
|
+
stale silently, and the answering agent repeats them as fact. Query the data to
|
|
433
|
+
*verify* a stable fact (stored enum values, a date format, null behaviour) and
|
|
434
|
+
write only the fact: "Stored values: 'EDITOR', 'ADMIN'", not "only 'EDITOR'
|
|
435
|
+
observed (6 of 6, 100%)". Complete value lists and constants the business fixes
|
|
436
|
+
(a 14-day trial) are facts, not measurements.
|
|
393
437
|
- **Ask for the business meaning; don't guess.** A term like "active user" or
|
|
394
438
|
"last month" has a project-specific definition (time window, criteria, filters).
|
|
395
439
|
Encode the business's actual definition, not a textbook default.
|
|
@@ -21,6 +21,7 @@ from cassis_cli.api import (
|
|
|
21
21
|
ApiError,
|
|
22
22
|
AuthError,
|
|
23
23
|
SchemaPlanConflictError,
|
|
24
|
+
SchemaPlanRejectedError,
|
|
24
25
|
UploadValidationError,
|
|
25
26
|
get_ontology_export,
|
|
26
27
|
get_schema_export,
|
|
@@ -29,6 +30,8 @@ from cassis_cli.api import (
|
|
|
29
30
|
post_ontology_import,
|
|
30
31
|
post_schema_plan,
|
|
31
32
|
post_schema_plan_apply,
|
|
33
|
+
post_schema_plan_preview,
|
|
34
|
+
post_schema_plan_preview_warehouse,
|
|
32
35
|
post_schema_plan_warehouse,
|
|
33
36
|
)
|
|
34
37
|
from cassis_cli.common import (
|
|
@@ -229,6 +232,17 @@ def plan(
|
|
|
229
232
|
timeout: float = _TIMEOUT_OPTION,
|
|
230
233
|
json_output: bool = _JSON_OPTION,
|
|
231
234
|
out: Optional[Path] = _OUT_OPTION,
|
|
235
|
+
dry_run: bool = typer.Option(
|
|
236
|
+
False,
|
|
237
|
+
"--dry-run",
|
|
238
|
+
help="Compute the plan in the request and keep nothing server-side: no plan to apply or resume, "
|
|
239
|
+
"the project's current plan untouched. For a DDL not deployed to the warehouse yet.",
|
|
240
|
+
),
|
|
241
|
+
write_checkout: bool = typer.Option(
|
|
242
|
+
False,
|
|
243
|
+
"--write-checkout",
|
|
244
|
+
help="With --dry-run: also write the ontology files the plan would produce under <path>/<base-path>.",
|
|
245
|
+
),
|
|
232
246
|
) -> None:
|
|
233
247
|
"""Preview what a schema update would change. Nothing is applied.
|
|
234
248
|
|
|
@@ -241,11 +255,46 @@ def plan(
|
|
|
241
255
|
whole-source. Exits 0 when the plan is ready (even when it is empty), 1
|
|
242
256
|
when the plan failed (unparseable or truncated DDL, unreachable
|
|
243
257
|
warehouse), 2 on usage errors, 3 on transport errors or a timeout.
|
|
258
|
+
|
|
259
|
+
--dry-run is the prepare-ahead gesture: the plan is computed synchronously
|
|
260
|
+
and nothing is kept server-side, so it works for a schema change that is
|
|
261
|
+
still a PR (a dbt model, a migration) and leaves the project's current plan
|
|
262
|
+
alone. --write-checkout then writes the resulting ontology files into the
|
|
263
|
+
checkout, to commit next to the schema change; nothing is pushed.
|
|
244
264
|
"""
|
|
245
265
|
api_key = require_api_key(api_key)
|
|
246
266
|
_require_one_source(ddl_file, warehouse)
|
|
267
|
+
if write_checkout and not dry_run:
|
|
268
|
+
typer.secho(
|
|
269
|
+
"--write-checkout needs --dry-run (use `cassis schema apply` otherwise).", fg=typer.colors.RED, err=True
|
|
270
|
+
)
|
|
271
|
+
raise typer.Exit(EXIT_USAGE)
|
|
247
272
|
resolved_project = resolve_project_id(project_id, path / base_path, quiet=json_output)
|
|
248
273
|
assert resolved_project is not None
|
|
274
|
+
if dry_run:
|
|
275
|
+
preview = _preview(
|
|
276
|
+
ddl_file,
|
|
277
|
+
warehouse=warehouse,
|
|
278
|
+
api_url=api_url,
|
|
279
|
+
api_key=api_key,
|
|
280
|
+
project_id=resolved_project,
|
|
281
|
+
complete_source=complete_source,
|
|
282
|
+
json_output=json_output,
|
|
283
|
+
out=out,
|
|
284
|
+
)
|
|
285
|
+
if write_checkout:
|
|
286
|
+
ontology_dir = path / base_path.strip().strip("/")
|
|
287
|
+
written, deleted, _kept = _write_checkout(ontology_dir, preview["files"], json_output=json_output)
|
|
288
|
+
typer.secho(
|
|
289
|
+
f"✓ Wrote {len(written)} ontology file(s) into {ontology_dir}"
|
|
290
|
+
+ (f", deleted {len(deleted)} stale file(s)" if deleted else "")
|
|
291
|
+
+ ". The app is unchanged.",
|
|
292
|
+
fg=typer.colors.GREEN,
|
|
293
|
+
err=json_output,
|
|
294
|
+
)
|
|
295
|
+
if json_output:
|
|
296
|
+
typer.echo(json.dumps(preview, indent=2))
|
|
297
|
+
raise typer.Exit(EXIT_OK)
|
|
249
298
|
record = _plan(
|
|
250
299
|
ddl_file,
|
|
251
300
|
warehouse=warehouse,
|
|
@@ -724,6 +773,54 @@ def _plan(
|
|
|
724
773
|
return record
|
|
725
774
|
|
|
726
775
|
|
|
776
|
+
def _preview(
|
|
777
|
+
ddl_file: Optional[Path],
|
|
778
|
+
*,
|
|
779
|
+
warehouse: bool,
|
|
780
|
+
api_url: str,
|
|
781
|
+
api_key: str,
|
|
782
|
+
project_id: str,
|
|
783
|
+
complete_source: bool,
|
|
784
|
+
json_output: bool,
|
|
785
|
+
out: Optional[Path],
|
|
786
|
+
) -> "dict[str, Any]":
|
|
787
|
+
"""Compute a dry-run plan for `ddl_file` (or the warehouse), render it. Return the preview record."""
|
|
788
|
+
try:
|
|
789
|
+
if warehouse:
|
|
790
|
+
preview = post_schema_plan_preview_warehouse(api_url=api_url, api_key=api_key, project_id=project_id)
|
|
791
|
+
else:
|
|
792
|
+
assert ddl_file is not None
|
|
793
|
+
preview = post_schema_plan_preview(
|
|
794
|
+
api_url=api_url,
|
|
795
|
+
api_key=api_key,
|
|
796
|
+
project_id=project_id,
|
|
797
|
+
ddl=_read_ddl(ddl_file),
|
|
798
|
+
complete_source=complete_source,
|
|
799
|
+
)
|
|
800
|
+
except (SchemaPlanRejectedError, SchemaPlanConflictError) as exc:
|
|
801
|
+
typer.secho(f"Plan failed: {exc}", fg=typer.colors.RED, err=True)
|
|
802
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED) from exc
|
|
803
|
+
except (AuthError, ApiError) as exc:
|
|
804
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
805
|
+
raise typer.Exit(EXIT_TRANSPORT) from exc
|
|
806
|
+
if out is not None:
|
|
807
|
+
try:
|
|
808
|
+
out.write_text(json.dumps(preview, indent=2) + "\n", encoding="utf-8")
|
|
809
|
+
except OSError as exc:
|
|
810
|
+
typer.secho(f"Could not write {out}: {exc}", fg=typer.colors.RED, err=True)
|
|
811
|
+
raise typer.Exit(EXIT_USAGE) from exc
|
|
812
|
+
# The renderer reads a plan record; a preview is one without an id.
|
|
813
|
+
record = {"status": "ready", "document": preview.get("document"), "summary": preview.get("summary")}
|
|
814
|
+
render_plan(record, err=json_output)
|
|
815
|
+
for warning in preview.get("warnings") or []:
|
|
816
|
+
typer.secho(f" warning: {warning}", fg=typer.colors.YELLOW, err=True)
|
|
817
|
+
if plan_is_empty(record):
|
|
818
|
+
typer.secho("✓ Schema is up to date (dry run, nothing kept).", fg=typer.colors.GREEN, err=json_output)
|
|
819
|
+
else:
|
|
820
|
+
typer.secho("✓ Plan computed (dry run, nothing kept).", fg=typer.colors.GREEN, err=json_output)
|
|
821
|
+
return preview
|
|
822
|
+
|
|
823
|
+
|
|
727
824
|
def _explain_not_ready(record: "dict[str, Any]") -> None:
|
|
728
825
|
status = record.get("status")
|
|
729
826
|
error = record.get("error") or ""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|