cassis-cli 1.5.0__tar.gz → 1.6.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,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cassis-cli
3
- Version: 1.5.0
4
- Summary: Cassis CLI — run Cassis actions (ontology validation, upload and publish) from your CI pipelines
3
+ Version: 1.6.0
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
7
7
  License-File: NOTICE
8
- Keywords: cassis,ontology,ci,text-to-sql
8
+ Keywords: cassis,ontology,cli,ci,text-to-sql
9
9
  Author: Cassis
10
10
  Author-email: tech.admin@getcassis.com
11
11
  Requires-Python: >=3.10,<4.0
@@ -22,23 +22,24 @@ Description-Content-Type: text/markdown
22
22
 
23
23
  # Cassis CLI
24
24
 
25
- Run Cassis actions from your CI pipelines:
25
+ Validate, test and evaluate your ontology from your terminal, then publish it. The same commands gate your pull requests in CI:
26
26
 
27
27
  - `cassis ontology check` validates the ontology files in your repository with the exact same checks as the Cassis GitHub PR check (YAML parsing, round-trip, import validation) — so you can gate merges in any CI system, not just GitHub. It then prints advisory **ontology quality warnings** for a tree that parsed — tables not assigned to any domain, joins/metrics pointing at unknown tables or columns, missing table/column descriptions (the same findings `ontology test` reports, without the agent run). In a checkout bound to a project (`project.yml`, `--project`, or `CASSIS_PROJECT_ID`), it also cross-checks the tree against the project's source schema: references to tables or columns the warehouse doesn't have print as **warnings** too — advisory only (the object may simply not be built or synced yet). Warnings never fail the check.
28
28
  - `cassis schema pull` downloads the data source's full source schema (as Cassis last introspected it) into `<base-path>/.schema.json` — a **gitignored** local snapshot (the command maintains the ignore entry) with a `pulled_at` stamp. The warehouse stays authoritative; the snapshot is a cache for offline/bulk work — e.g. a coding agent grepping table and column names during a modeling pass instead of paging through the MCP `get_source_schema` tool. Re-run to refresh.
29
29
  - `cassis ontology fmt` rewrites the ontology files in canonical form (think `black`/`gofmt` for the ontology), so hand or agent edits pass the round-trip check.
30
30
  - `cassis ontology upload` uploads the ontology files to a Cassis project (full replace) and, by default, publishes them immediately as a new version — so a merge to your main branch can go live in one CI step.
31
- - `cassis ontology pull` downloads the project's unpublished ontology into your repository checkout (full sync — stale local ontology files are pruned), so you can start editing from the current state, or bootstrap a repo that isn't git-synced (e.g. Bitbucket).
31
+ - `cassis ontology pull` downloads the project's unpublished ontology into your repository checkout (full sync — stale local ontology files are pruned), so you can start editing from the current state, or bootstrap a repo that isn't git-synced (e.g. Bitbucket). Pruning only deletes files that are tracked and unmodified in git (i.e. restorable with `git checkout`); untracked or locally modified files are kept and listed, and every deleted path is printed.
32
32
  - `cassis ontology pull` and `cassis ontology fmt` also write `<base-path>/AGENTS.md`, the Cassis ontology modeling guide, into the checkout (default `cassis/AGENTS.md`) — a managed file (generated banner; the CLI overwrites local edits) so a repo-aware coding agent loads current Cassis modeling doctrine by convention. It sits inside the ontology directory but is not part of the ontology tree (which is the YAML files plus the domain Markdown files `domains/**/README.md`), so it is never uploaded, validated, or pruned. Commit it alongside your ontology changes. The guide text ships inside the CLI package, so its version tracks the **installed cassis-cli version** — upgrade the CLI (`pip install -U cassis-cli`) and re-run `fmt` to pick up doctrine updates; an unpinned `pip install cassis-cli` in CI gets them automatically. The banner stamps a doctrine version, and the CLI never *downgrades* the file: if the checkout's `AGENTS.md` was written by a newer doctrine (a newer CLI, or Cassis itself on a publish), `fmt`/`pull` leave it in place, print an upgrade notice, and `fmt --check` still passes.
33
33
  - The CLI identifies itself to the API (`User-Agent: cassis-cli/<version>`), and successful API responses advertise the newest published version — when you are behind, commands print a one-line upgrade notice on stderr (purely informational; output and exit codes are unchanged).
34
34
  - `cassis eval run` runs the project's eval suite against your local ontology files (scored in-memory — nothing is pushed to Cassis) and prints per-question results, so you can test the changes on your git branch before merging.
35
35
  - `cassis ontology test` runs individual questions through the text-to-SQL agent using your local ontology files, so you can check that a change actually works (e.g. a new column gets picked) — where `eval run` only checks for regressions on existing eval cases.
36
36
  - `cassis eval add-case` adds a gold question/SQL case to the project's eval suite — after fixing an ontology issue, add the question users were failing on so `eval run` guards it from regressing.
37
37
  - `cassis eval list-cases` and `cassis eval delete-case` maintain the suite: list the current cases with their ids, and prune one that is stale or wrong (e.g. its gold SQL encodes a definition the ontology has since changed).
38
- - `cassis schema push` uploads a DDL file to detect source-schema changes on a DDL-only project (same as the webapp's "Update from DDL" button): Cassis diffs the DDL against the ontology and surfaces added, dropped, and changed objects in Ontology > Review > Data source for approval. Waits for 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 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.
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, 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, 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.
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.
42
43
  - `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
44
 
44
45
  ## Install
@@ -73,7 +74,8 @@ cassis ontology check
73
74
  cassis ontology check /path/to/checkout
74
75
 
75
76
  # Download the project's unpublished ontology into the checkout (full sync;
76
- # review with git diff — pass --no-prune to keep local files it would delete):
77
+ # review with git diff — untracked/modified files are never deleted, and
78
+ # --no-prune keeps even the tracked stale files it would otherwise delete):
77
79
  cassis ontology pull --project 019f0000-0000-7000-8000-000000000000
78
80
 
79
81
  # Upload the ontology to a project and publish it immediately:
@@ -188,14 +190,14 @@ cassis ontology fmt --check
188
190
  | Code | Meaning |
189
191
  | ---- | ------------------------------------------------------------------------------ |
190
192
  | 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) |
191
- | 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) |
193
+ | 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; 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) |
192
194
  | 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits) |
193
- | 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another run already active, out of credits, or `--timeout` reached |
195
+ | 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another eval run already active, out of credits, or `--timeout` reached |
194
196
 
195
197
  Commands that send the local tree (`check`, `fmt`, `upload`, `eval run`, `test`) accept up to
196
- 2000 ontology files / 5 MB total — far above real ontologies (a few hundred small
197
- files). Beyond that the CLI fails fast with exit 2 before uploading anything;
198
- double-check `--base-path` if you hit it.
198
+ 20,000 ontology files / 100 MB total (path + content bytes) — sized for ontologies of
199
+ roughly 10,000 modeled tables. Beyond that the CLI fails fast with exit 2 before
200
+ uploading anything; double-check `--base-path` if you hit it.
199
201
 
200
202
  `upload` replaces the project's entire ontology with the uploaded tree. A
201
203
  never-published project always goes live immediately on first upload (even
@@ -1,22 +1,23 @@
1
1
  # Cassis CLI
2
2
 
3
- Run Cassis actions from your CI pipelines:
3
+ Validate, test and evaluate your ontology from your terminal, then publish it. The same commands gate your pull requests in CI:
4
4
 
5
5
  - `cassis ontology check` validates the ontology files in your repository with the exact same checks as the Cassis GitHub PR check (YAML parsing, round-trip, import validation) — so you can gate merges in any CI system, not just GitHub. It then prints advisory **ontology quality warnings** for a tree that parsed — tables not assigned to any domain, joins/metrics pointing at unknown tables or columns, missing table/column descriptions (the same findings `ontology test` reports, without the agent run). In a checkout bound to a project (`project.yml`, `--project`, or `CASSIS_PROJECT_ID`), it also cross-checks the tree against the project's source schema: references to tables or columns the warehouse doesn't have print as **warnings** too — advisory only (the object may simply not be built or synced yet). Warnings never fail the check.
6
6
  - `cassis schema pull` downloads the data source's full source schema (as Cassis last introspected it) into `<base-path>/.schema.json` — a **gitignored** local snapshot (the command maintains the ignore entry) with a `pulled_at` stamp. The warehouse stays authoritative; the snapshot is a cache for offline/bulk work — e.g. a coding agent grepping table and column names during a modeling pass instead of paging through the MCP `get_source_schema` tool. Re-run to refresh.
7
7
  - `cassis ontology fmt` rewrites the ontology files in canonical form (think `black`/`gofmt` for the ontology), so hand or agent edits pass the round-trip check.
8
8
  - `cassis ontology upload` uploads the ontology files to a Cassis project (full replace) and, by default, publishes them immediately as a new version — so a merge to your main branch can go live in one CI step.
9
- - `cassis ontology pull` downloads the project's unpublished ontology into your repository checkout (full sync — stale local ontology files are pruned), so you can start editing from the current state, or bootstrap a repo that isn't git-synced (e.g. Bitbucket).
9
+ - `cassis ontology pull` downloads the project's unpublished ontology into your repository checkout (full sync — stale local ontology files are pruned), so you can start editing from the current state, or bootstrap a repo that isn't git-synced (e.g. Bitbucket). Pruning only deletes files that are tracked and unmodified in git (i.e. restorable with `git checkout`); untracked or locally modified files are kept and listed, and every deleted path is printed.
10
10
  - `cassis ontology pull` and `cassis ontology fmt` also write `<base-path>/AGENTS.md`, the Cassis ontology modeling guide, into the checkout (default `cassis/AGENTS.md`) — a managed file (generated banner; the CLI overwrites local edits) so a repo-aware coding agent loads current Cassis modeling doctrine by convention. It sits inside the ontology directory but is not part of the ontology tree (which is the YAML files plus the domain Markdown files `domains/**/README.md`), so it is never uploaded, validated, or pruned. Commit it alongside your ontology changes. The guide text ships inside the CLI package, so its version tracks the **installed cassis-cli version** — upgrade the CLI (`pip install -U cassis-cli`) and re-run `fmt` to pick up doctrine updates; an unpinned `pip install cassis-cli` in CI gets them automatically. The banner stamps a doctrine version, and the CLI never *downgrades* the file: if the checkout's `AGENTS.md` was written by a newer doctrine (a newer CLI, or Cassis itself on a publish), `fmt`/`pull` leave it in place, print an upgrade notice, and `fmt --check` still passes.
11
11
  - The CLI identifies itself to the API (`User-Agent: cassis-cli/<version>`), and successful API responses advertise the newest published version — when you are behind, commands print a one-line upgrade notice on stderr (purely informational; output and exit codes are unchanged).
12
12
  - `cassis eval run` runs the project's eval suite against your local ontology files (scored in-memory — nothing is pushed to Cassis) and prints per-question results, so you can test the changes on your git branch before merging.
13
13
  - `cassis ontology test` runs individual questions through the text-to-SQL agent using your local ontology files, so you can check that a change actually works (e.g. a new column gets picked) — where `eval run` only checks for regressions on existing eval cases.
14
14
  - `cassis eval add-case` adds a gold question/SQL case to the project's eval suite — after fixing an ontology issue, add the question users were failing on so `eval run` guards it from regressing.
15
15
  - `cassis eval list-cases` and `cassis eval delete-case` maintain the suite: list the current cases with their ids, and prune one that is stale or wrong (e.g. its gold SQL encodes a definition the ontology has since changed).
16
- - `cassis schema push` uploads a DDL file to detect source-schema changes on a DDL-only project (same as the webapp's "Update from DDL" button): Cassis diffs the DDL against the ontology and surfaces added, dropped, and changed objects in Ontology > Review > Data source for approval. Waits for 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 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.
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, 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, 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.
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.
20
21
  - `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
22
 
22
23
  ## Install
@@ -51,7 +52,8 @@ cassis ontology check
51
52
  cassis ontology check /path/to/checkout
52
53
 
53
54
  # Download the project's unpublished ontology into the checkout (full sync;
54
- # review with git diff — pass --no-prune to keep local files it would delete):
55
+ # review with git diff — untracked/modified files are never deleted, and
56
+ # --no-prune keeps even the tracked stale files it would otherwise delete):
55
57
  cassis ontology pull --project 019f0000-0000-7000-8000-000000000000
56
58
 
57
59
  # Upload the ontology to a project and publish it immediately:
@@ -166,14 +168,14 @@ cassis ontology fmt --check
166
168
  | Code | Meaning |
167
169
  | ---- | ------------------------------------------------------------------------------ |
168
170
  | 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) |
169
- | 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) |
171
+ | 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; 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) |
170
172
  | 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits) |
171
- | 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another run already active, out of credits, or `--timeout` reached |
173
+ | 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another eval run already active, out of credits, or `--timeout` reached |
172
174
 
173
175
  Commands that send the local tree (`check`, `fmt`, `upload`, `eval run`, `test`) accept up to
174
- 2000 ontology files / 5 MB total — far above real ontologies (a few hundred small
175
- files). Beyond that the CLI fails fast with exit 2 before uploading anything;
176
- double-check `--base-path` if you hit it.
176
+ 20,000 ontology files / 100 MB total (path + content bytes) — sized for ontologies of
177
+ roughly 10,000 modeled tables. Beyond that the CLI fails fast with exit 2 before
178
+ uploading anything; double-check `--base-path` if you hit it.
177
179
 
178
180
  `upload` replaces the project's entire ontology with the uploaded tree. A
179
181
  never-published project always goes live immediately on first upload (even
@@ -1,4 +1,7 @@
1
- """Cassis CLI — run Cassis actions from your CI pipelines."""
1
+ """Validate, test and evaluate your ontology from your terminal, then publish it.
2
+
3
+ The same commands gate your pull requests in CI.
4
+ """
2
5
 
3
6
  from importlib.metadata import PackageNotFoundError, version
4
7
 
@@ -218,6 +218,12 @@ def post_ontology_import(
218
218
  if response.status_code >= 400:
219
219
  raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
220
220
  result = _parse_json_response(response, url)
221
+ # The import runs as a background job on the server; once the response
222
+ # stream has started the status code is fixed at 200, so an in-job failure
223
+ # arrives as a body carrying only an ``error`` key (deliberately not the
224
+ # success shape, so older CLIs fail loudly instead of reporting success).
225
+ if isinstance(result, dict) and "error" in result and "domain_count" not in result:
226
+ raise UploadValidationError(str(result["error"]))
221
227
  if not isinstance(result, dict) or not all(
222
228
  key in result for key in ("domain_count", "table_count", "join_count", "metric_count", "published_version")
223
229
  ):
@@ -235,7 +241,10 @@ def get_ontology_export(
235
241
  """GET /api/ci/projects/{project_id}/ontology/export and return the files tree."""
236
242
  url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/ontology/export"
237
243
  try:
238
- with _client(transport=transport) as client:
244
+ # Whole-tree download: serializing thousands of tables takes the
245
+ # server the better part of a minute, so the default budget is the
246
+ # thing that breaks first — use the tree ceiling.
247
+ with _client(timeout=ONTOLOGY_TREE_TIMEOUT_SECONDS, transport=transport) as client:
239
248
  response = client.get(url, headers={"Authorization": f"Bearer {api_key}"})
240
249
  except httpx.HTTPError as exc:
241
250
  raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
@@ -354,13 +363,18 @@ def post_detect_from_ddl(
354
363
  api_key: str,
355
364
  project_id: str,
356
365
  ddl: str,
366
+ complete_source: bool = False,
357
367
  transport: Optional[httpx.BaseTransport] = None,
358
368
  ) -> dict[str, Any]:
359
369
  """POST /api/ci/projects/{project_id}/source-changes/detect-from-ddl and return the run record."""
360
370
  url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/source-changes/detect-from-ddl"
361
371
  try:
362
372
  with _client(transport=transport) as client:
363
- response = client.post(url, json={"ddl": ddl}, headers={"Authorization": f"Bearer {api_key}"})
373
+ response = client.post(
374
+ url,
375
+ json={"ddl": ddl, "complete_source": complete_source},
376
+ headers={"Authorization": f"Bearer {api_key}"},
377
+ )
364
378
  except httpx.HTTPError as exc:
365
379
  raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
366
380
 
@@ -429,7 +443,9 @@ def post_eval_run_start(
429
443
  if case_ids is not None:
430
444
  body["test_case_ids"] = case_ids
431
445
  try:
432
- with _client(transport=transport) as client:
446
+ # May carry the whole ontology tree in `files` — same budget as the
447
+ # other whole-tree endpoints.
448
+ with _client(timeout=ONTOLOGY_TREE_TIMEOUT_SECONDS, transport=transport) as client:
433
449
  response = client.post(url, json=body, headers={"Authorization": f"Bearer {api_key}"})
434
450
  except httpx.HTTPError as exc:
435
451
  raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
@@ -745,6 +761,80 @@ def post_issue_status(
745
761
  return result
746
762
 
747
763
 
764
+ class SourceChangeNotFoundError(ApiError):
765
+ """The project has no source change with this id."""
766
+
767
+
768
+ def get_source_changes(
769
+ *,
770
+ api_url: str,
771
+ api_key: str,
772
+ project_id: str,
773
+ status: Optional[str] = None,
774
+ limit: Optional[int] = None,
775
+ offset: Optional[int] = None,
776
+ transport: Optional[httpx.BaseTransport] = None,
777
+ ) -> dict[str, Any]:
778
+ """GET /api/ci/projects/{project_id}/source-changes and return the {items, total} page."""
779
+ url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/source-changes"
780
+ params: dict[str, str] = {}
781
+ if status:
782
+ params["status"] = status
783
+ if limit is not None:
784
+ params["limit"] = str(limit)
785
+ if offset is not None:
786
+ params["offset"] = str(offset)
787
+ try:
788
+ with _client(transport=transport) as client:
789
+ response = client.get(url, params=params or None, headers={"Authorization": f"Bearer {api_key}"})
790
+ except httpx.HTTPError as exc:
791
+ raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
792
+
793
+ if response.status_code == 401:
794
+ raise AuthError("The Cassis API rejected the API key (invalid or expired).")
795
+ if response.status_code in (403, 404):
796
+ raise _project_scope_error(response)
797
+ if response.status_code >= 400:
798
+ raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
799
+ result = _parse_json_response(response, url)
800
+ if not isinstance(result, dict) or not isinstance(result.get("items"), list) or "total" not in result:
801
+ raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
802
+ return result
803
+
804
+
805
+ def get_source_change(
806
+ *,
807
+ api_url: str,
808
+ api_key: str,
809
+ project_id: str,
810
+ change_id: str,
811
+ transport: Optional[httpx.BaseTransport] = None,
812
+ ) -> dict[str, Any]:
813
+ """GET /api/ci/projects/{project_id}/source-changes/{change_id} and return the full change."""
814
+ url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/source-changes/{change_id}"
815
+ try:
816
+ with _client(transport=transport) as client:
817
+ response = client.get(url, headers={"Authorization": f"Bearer {api_key}"})
818
+ except httpx.HTTPError as exc:
819
+ raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
820
+
821
+ if response.status_code == 401:
822
+ raise AuthError("The Cassis API rejected the API key (invalid or expired).")
823
+ # Exact-match wire contract with the source-change endpoints' 404 detail
824
+ # (see backend/app/endpoints/ci.py): it distinguishes a missing change
825
+ # (exit 1) from a project-scope 404 (exit 3).
826
+ if response.status_code == 404 and _detail_or_text(response) == "Source change not found":
827
+ raise SourceChangeNotFoundError(f"No source change {change_id} in project {project_id}.")
828
+ if response.status_code in (403, 404):
829
+ raise _project_scope_error(response)
830
+ if response.status_code >= 400:
831
+ raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
832
+ result = _parse_json_response(response, url)
833
+ if not isinstance(result, dict) or "id" not in result:
834
+ raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
835
+ return result
836
+
837
+
748
838
  def post_ontology_fmt(
749
839
  *,
750
840
  api_url: str,
@@ -2,7 +2,9 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
5
6
  import re
7
+ import subprocess
6
8
  from pathlib import Path
7
9
  from typing import Optional
8
10
  from uuid import UUID
@@ -20,10 +22,23 @@ EXIT_USAGE = 2
20
22
  EXIT_TRANSPORT = 3
21
23
 
22
24
  # Request ceilings of the /api/ci file-tree endpoints, mirrored so oversized
23
- # trees fail fast with a clear message before any upload. Source of truth:
25
+ # trees fail fast with a clear message before any upload. Sized for ~10,000
26
+ # modeled tables (a 6,000-table tree is ~8,600 files / ~60 MB). Source of
27
+ # truth: backend/app/services/ontology_check.py, enforced by
24
28
  # backend/app/schemas/ci.py (the server's 422 remains the backstop).
25
- MAX_FILES = 2000
26
- MAX_TOTAL_BYTES = 5 * 1024 * 1024
29
+ MAX_FILES = 20_000
30
+ MAX_TOTAL_BYTES = 100 * 1024 * 1024
31
+
32
+
33
+ def api_failure(exc: Exception) -> "typer.Exit":
34
+ """Print a transport/API error and return the transport exit code."""
35
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
36
+ return typer.Exit(EXIT_TRANSPORT)
37
+
38
+
39
+ def one_line(value: object) -> str:
40
+ """Collapse a possibly-multiline value into one trimmed line for list rows."""
41
+ return str(value or "").replace("\n", " ").strip()
27
42
 
28
43
 
29
44
  def is_ontology_file(rel_path: str) -> bool:
@@ -41,6 +56,42 @@ def is_ontology_file(rel_path: str) -> bool:
41
56
  return rel_path.startswith("domains/") and rel_path.endswith("/README.md")
42
57
 
43
58
 
59
+ def git_file_states(directory: Path) -> Optional[tuple[set[str], set[str]]]:
60
+ """(tracked, dirty) path sets for files under ``directory``, relative to it.
61
+
62
+ ``tracked`` is every git-tracked file below the directory; ``dirty`` the
63
+ subset whose working-tree content differs from the index (modified or
64
+ missing). Returns ``None`` when the directory is not inside a git work tree
65
+ or git is unavailable — callers must then treat every file as
66
+ unrecoverable and refuse to delete it.
67
+ """
68
+ # GIT_OPTIONAL_LOCKS=0: read-only queries must not take the index lock
69
+ # (and fail) when another git process is running.
70
+ env = {**os.environ, "GIT_OPTIONAL_LOCKS": "0"}
71
+ try:
72
+ tracked_proc = subprocess.run(
73
+ ["git", "ls-files", "-z"],
74
+ cwd=directory,
75
+ env=env,
76
+ capture_output=True,
77
+ check=True,
78
+ text=True,
79
+ )
80
+ dirty_proc = subprocess.run(
81
+ ["git", "ls-files", "-z", "--modified"],
82
+ cwd=directory,
83
+ env=env,
84
+ capture_output=True,
85
+ check=True,
86
+ text=True,
87
+ )
88
+ except (OSError, subprocess.CalledProcessError, UnicodeDecodeError):
89
+ return None
90
+ tracked = {p for p in tracked_proc.stdout.split("\0") if p}
91
+ dirty = {p for p in dirty_proc.stdout.split("\0") if p}
92
+ return tracked, dirty
93
+
94
+
44
95
  def is_legacy_domain_file(rel_path: str) -> bool:
45
96
  """Whether a base-relative path is a legacy (pre-Markdown) domain file.
46
97
 
@@ -84,7 +135,7 @@ def read_project_id_from_dir(ontology_dir: Path) -> Optional[str]:
84
135
  """Return the ``project_id`` recorded in ``<ontology_dir>/project.yml``, or None."""
85
136
  try:
86
137
  text = (ontology_dir / "project.yml").read_text(encoding="utf-8")
87
- except (OSError, UnicodeDecodeError) as _exc: # `as` keeps black from stripping the parens (3.14-only syntax)
138
+ except (OSError, UnicodeDecodeError):
88
139
  return None
89
140
  for line in text.splitlines():
90
141
  match = _PROJECT_ID_LINE.match(line.strip())
@@ -171,7 +222,9 @@ def collect_tree(path: Path, base_path: str) -> "tuple[dict[str, str], str]":
171
222
  typer.secho(f"No ontology files found under {ontology_dir}.", fg=typer.colors.RED, err=True)
172
223
  raise typer.Exit(EXIT_USAGE)
173
224
 
174
- total_bytes = sum(len(content.encode()) for content in files.values())
225
+ # Count path bytes too, exactly like the server's _validate_tree_files —
226
+ # a tree accepted here must never come back as a server-side 422.
227
+ total_bytes = sum(len(rel.encode()) + len(content.encode()) for rel, content in files.items())
175
228
  if len(files) > MAX_FILES or total_bytes > MAX_TOTAL_BYTES:
176
229
  typer.secho(
177
230
  f"Ontology tree too large: {len(files)} files / {total_bytes / (1024 * 1024):.1f} MB "
@@ -26,9 +26,10 @@ from cassis_cli.api import (
26
26
  from cassis_cli.common import (
27
27
  DEFAULT_BASE_PATH,
28
28
  EXIT_OK,
29
- EXIT_TRANSPORT,
30
29
  EXIT_USAGE,
31
30
  EXIT_VALIDATION_FAILED,
31
+ api_failure,
32
+ one_line,
32
33
  require_api_key,
33
34
  resolve_project_id,
34
35
  )
@@ -81,20 +82,11 @@ def _validate_choice(value: Optional[str], allowed: "tuple[str, ...]", flag: str
81
82
  raise typer.Exit(EXIT_USAGE)
82
83
 
83
84
 
84
- def _api_failure(exc: ApiError) -> "typer.Exit":
85
- typer.secho(str(exc), fg=typer.colors.RED, err=True)
86
- return typer.Exit(EXIT_TRANSPORT)
87
-
88
-
89
85
  def _not_found_failure(exc: IssueNotFoundError) -> "typer.Exit":
90
86
  typer.secho(str(exc), fg=typer.colors.YELLOW, err=True)
91
87
  return typer.Exit(EXIT_VALIDATION_FAILED)
92
88
 
93
89
 
94
- def _one_line(value: Any) -> str:
95
- return str(value or "").replace("\n", " ").strip()
96
-
97
-
98
90
  def _field(label: str, value: Any, *, blank_line: bool = False) -> None:
99
91
  """Print a labelled block, indenting a multi-line value under its label."""
100
92
  if value in (None, "", [], {}):
@@ -145,7 +137,7 @@ def list_issues(
145
137
  cause=cause,
146
138
  )
147
139
  except (AuthError, ApiError) as exc:
148
- raise _api_failure(exc) from exc
140
+ raise api_failure(exc) from exc
149
141
 
150
142
  if json_output:
151
143
  typer.echo(json.dumps(issues, indent=2))
@@ -159,7 +151,7 @@ def list_issues(
159
151
  occurrences = issue.get("occurrence_count_cache") or 0
160
152
  typer.echo(
161
153
  f"{issue.get('id')} {issue.get('impact')} x{occurrences} "
162
- f"{issue.get('status')} {_one_line(issue.get('title'))}"
154
+ f"{issue.get('status')} {one_line(issue.get('title'))}"
163
155
  )
164
156
  raise typer.Exit(EXIT_OK)
165
157
 
@@ -188,14 +180,14 @@ def show(
188
180
  except IssueNotFoundError as exc:
189
181
  raise _not_found_failure(exc) from exc
190
182
  except (AuthError, ApiError) as exc:
191
- raise _api_failure(exc) from exc
183
+ raise api_failure(exc) from exc
192
184
 
193
185
  if json_output:
194
186
  typer.echo(json.dumps(issue, indent=2))
195
187
  raise typer.Exit(EXIT_OK)
196
188
 
197
189
  occurrences = issue.get("occurrences") or []
198
- typer.echo(f"{issue.get('id')} {_one_line(issue.get('title'))}")
190
+ typer.echo(f"{issue.get('id')} {one_line(issue.get('title'))}")
199
191
  typer.echo(
200
192
  f"{issue.get('status')} {issue.get('impact')} {issue.get('cause')} "
201
193
  f"{issue.get('occurrence_count_cache', len(occurrences))} occurrence(s)"
@@ -208,7 +200,7 @@ def show(
208
200
  typer.echo("")
209
201
  typer.echo("Occurrences:")
210
202
  for occurrence in occurrences:
211
- typer.echo(f" {occurrence.get('id')} {_one_line(occurrence.get('symptom'))}")
203
+ typer.echo(f" {occurrence.get('id')} {one_line(occurrence.get('symptom'))}")
212
204
  raise typer.Exit(EXIT_OK)
213
205
 
214
206
 
@@ -245,7 +237,7 @@ def evidence(
245
237
  except IssueNotFoundError as exc:
246
238
  raise _not_found_failure(exc) from exc
247
239
  except (AuthError, ApiError) as exc:
248
- raise _api_failure(exc) from exc
240
+ raise api_failure(exc) from exc
249
241
 
250
242
  if json_output:
251
243
  typer.echo(json.dumps(record, indent=2))
@@ -281,7 +273,7 @@ def _set_status(
281
273
  except IssueNotFoundError as exc:
282
274
  raise _not_found_failure(exc) from exc
283
275
  except (AuthError, ApiError) as exc:
284
- raise _api_failure(exc) from exc
276
+ raise api_failure(exc) from exc
285
277
 
286
278
  typer.secho(f"✓ Issue {issue_id} is now {issue.get('status', status)}.", fg=typer.colors.GREEN)
287
279
  raise typer.Exit(EXIT_OK)
@@ -9,18 +9,23 @@ from cassis_cli.issues import app as issues_app
9
9
  from cassis_cli.ontology import app as ontology_app
10
10
  from cassis_cli.projects import app as projects_app
11
11
  from cassis_cli.schema import app as schema_app
12
+ from cassis_cli.source_changes import app as source_changes_app
12
13
  from cassis_cli.status import status
13
14
  from cassis_cli.verify import verify
14
15
 
15
16
  app = typer.Typer(
16
17
  no_args_is_help=True,
17
- help="Cassis CLI — run Cassis actions from your CI pipelines.",
18
+ help=(
19
+ "Cassis CLI: validate, test and evaluate your ontology from your terminal, "
20
+ "then publish it. The same commands gate your pull requests in CI."
21
+ ),
18
22
  )
19
23
  app.add_typer(ontology_app, name="ontology")
20
24
  app.add_typer(eval_app, name="eval")
21
25
  app.add_typer(schema_app, name="schema")
22
26
  app.add_typer(projects_app, name="projects")
23
27
  app.add_typer(issues_app, name="issues")
28
+ app.add_typer(source_changes_app, name="source-changes")
24
29
  app.command()(status)
25
30
  app.command()(verify)
26
31
 
@@ -28,6 +28,7 @@ from cassis_cli.common import (
28
28
  )
29
29
  from cassis_cli.common import collect_files as _collect_files
30
30
  from cassis_cli.common import collect_tree as _collect_tree
31
+ from cassis_cli.common import git_file_states as _git_file_states
31
32
  from cassis_cli.common import is_legacy_domain_file as _is_legacy_domain_file
32
33
  from cassis_cli.common import require_api_key as _require_api_key
33
34
  from cassis_cli.common import resolve_project_id as _resolve_project_id
@@ -197,7 +198,11 @@ def pull(
197
198
  prune: bool = typer.Option(
198
199
  True,
199
200
  "--prune/--no-prune",
200
- help="Delete local ontology files that no longer exist in the project's ontology (default: prune).",
201
+ help=(
202
+ "Delete local ontology files that no longer exist in the project's ontology "
203
+ "(default: prune). Only files that are tracked and unmodified in git are "
204
+ "deleted; untracked or locally modified files are always kept and reported."
205
+ ),
201
206
  ),
202
207
  json_output: bool = typer.Option(False, "--json", help="Print a JSON summary of written/deleted files."),
203
208
  ) -> None:
@@ -205,7 +210,9 @@ def pull(
205
210
 
206
211
  Writes the ontology tree under the export path (full sync: files are
207
212
  overwritten and, unless --no-prune, stale local ontology files are deleted,
208
- so the checkout ends up matching the project exactly). Review the changes with
213
+ so the checkout ends up matching the project exactly). Pruning never touches
214
+ files git could not restore: untracked or locally modified files are kept
215
+ and listed, and every deleted path is named. Review the changes with
209
216
  git diff before committing. Exits 0 on success, 2 on usage errors, 3 on
210
217
  transport/API errors.
211
218
  """
@@ -243,15 +250,38 @@ def pull(
243
250
  written.append(rel)
244
251
 
245
252
  deleted: list[str] = []
253
+ kept: list[dict[str, str]] = []
246
254
  if prune and ontology_dir.is_dir():
247
255
  local = _collect_files(ontology_dir)
248
- for rel in sorted(set(local) - set(files)):
249
- try:
250
- (ontology_dir / rel).unlink()
251
- except OSError as exc:
252
- typer.secho(f"Cannot delete {ontology_dir / rel}: {exc}", fg=typer.colors.RED, err=True)
253
- raise typer.Exit(EXIT_USAGE) from exc
254
- deleted.append(rel)
256
+ stale = sorted(set(local) - set(files))
257
+ if stale:
258
+ # Only delete what git can restore. An untracked or locally
259
+ # modified file is user work Cassis has never seen — pruning it
260
+ # would be unrecoverable data loss (#27).
261
+ states = _git_file_states(ontology_dir)
262
+ to_delete: list[str] = []
263
+ if states is None:
264
+ kept = [{"path": rel, "reason": "not in a git repository"} for rel in stale]
265
+ else:
266
+ tracked, dirty = states
267
+ for rel in stale:
268
+ if rel not in tracked:
269
+ kept.append({"path": rel, "reason": "untracked in git"})
270
+ elif rel in dirty:
271
+ kept.append({"path": rel, "reason": "locally modified"})
272
+ else:
273
+ to_delete.append(rel)
274
+ if to_delete and not json_output:
275
+ typer.echo(f"Deleting {len(to_delete)} stale ontology file(s):")
276
+ for rel in to_delete:
277
+ typer.echo(f" {base_path}/{rel}")
278
+ for rel in to_delete:
279
+ try:
280
+ (ontology_dir / rel).unlink()
281
+ except OSError as exc:
282
+ typer.secho(f"Cannot delete {ontology_dir / rel}: {exc}", fg=typer.colors.RED, err=True)
283
+ raise typer.Exit(EXIT_USAGE) from exc
284
+ deleted.append(rel)
255
285
 
256
286
  # Managed modeling guide: refresh AGENTS.md so a repo-aware agent loads
257
287
  # current Cassis doctrine. Not part of the ontology tree (YAML-only), so it
@@ -266,14 +296,31 @@ def pull(
266
296
  _warn_newer_guide(base_path)
267
297
 
268
298
  if json_output:
269
- typer.echo(json.dumps({"written": written, "deleted": deleted, "guide_written": guide_written}, indent=2))
299
+ typer.echo(
300
+ json.dumps(
301
+ {"written": written, "deleted": deleted, "kept": kept, "guide_written": guide_written},
302
+ indent=2,
303
+ )
304
+ )
270
305
  else:
271
306
  summary = f"✓ Pulled {len(written)} files into {ontology_dir}"
272
307
  if deleted:
273
- summary += f" ({len(deleted)} stale files deleted)"
308
+ summary += f" ({len(deleted)} stale files deleted, listed above)"
274
309
  if guide_written:
275
310
  summary += f"; wrote {base_path}/{GUIDE_FILENAME}"
276
311
  typer.secho(f"{summary}.", fg=typer.colors.GREEN)
312
+ if kept:
313
+ typer.secho(
314
+ f"Kept {len(kept)} local file(s) not in the project ontology "
315
+ "(only files tracked and unmodified in git are pruned):",
316
+ fg=typer.colors.YELLOW,
317
+ )
318
+ for entry in kept:
319
+ typer.secho(f" {base_path}/{entry['path']} ({entry['reason']})", fg=typer.colors.YELLOW)
320
+ typer.secho(
321
+ " Commit them if they are intentional, or delete them manually.",
322
+ fg=typer.colors.YELLOW,
323
+ )
277
324
  migrated = sum(1 for rel in deleted if _is_legacy_domain_file(rel))
278
325
  if migrated:
279
326
  typer.secho(
@@ -134,7 +134,7 @@ def ensure_gitignored(ontology_dir: Path) -> None:
134
134
  gitignore = ontology_dir / ".gitignore"
135
135
  try:
136
136
  existing = gitignore.read_text(encoding="utf-8")
137
- except (OSError, UnicodeDecodeError) as _exc: # `as` keeps black from stripping the parens (3.14-only syntax)
137
+ except (OSError, UnicodeDecodeError):
138
138
  existing = ""
139
139
  if SNAPSHOT_FILENAME in existing.splitlines():
140
140
  return
@@ -175,17 +175,26 @@ def push(
175
175
  envvar="CASSIS_BASE_PATH",
176
176
  help="Repository directory the ontology is exported under (the project's git-sync Path setting).",
177
177
  ),
178
+ complete_source: bool = typer.Option(
179
+ False,
180
+ "--complete",
181
+ help="The file is the project's complete source schema: schemas absent from it are treated as dropped. "
182
+ "Without it, the upload only speaks for the schemas it contains.",
183
+ ),
178
184
  poll_interval: float = typer.Option(5.0, "--poll-interval", help="Seconds between polls."),
179
185
  timeout: float = typer.Option(600.0, "--timeout", help="Give up waiting after this many seconds."),
180
186
  json_output: bool = typer.Option(False, "--json", help="Print the run record as raw JSON."),
181
187
  ) -> None:
182
188
  """Upload a DDL file to detect source-schema changes (same as the webapp's "Update from DDL").
183
189
 
184
- The DDL must contain at least one CREATE TABLE statement and represents the
185
- project's complete source schema. Cassis diffs it against the ontology:
186
- added, dropped, and changed objects appear in Ontology > Review > Data
187
- source for approval. Re-uploading a corrected DDL supersedes the previous
188
- one. Only works on DDL-only projects (no warehouse connection).
190
+ The DDL must contain at least one CREATE TABLE statement. Cassis diffs it
191
+ against the ontology: added, dropped, and changed objects appear in
192
+ Ontology > Review > Data source for approval. The file speaks only for the
193
+ schemas it contains — a partial export (one schema of many) never removes
194
+ the others; pass --complete when the file is the project's complete source
195
+ schema so schemas absent from it are treated as dropped. Re-uploading a
196
+ corrected DDL supersedes the previous one. Only works on DDL-only projects
197
+ (no warehouse connection).
189
198
 
190
199
  Always waits for the detection run to finish: the server parses the DDL
191
200
  inside the run (a large file takes a while, and an unparseable one fails
@@ -211,7 +220,9 @@ def push(
211
220
  raise typer.Exit(EXIT_USAGE)
212
221
 
213
222
  try:
214
- run = post_detect_from_ddl(api_url=api_url, api_key=api_key, project_id=project_id, ddl=ddl_text)
223
+ run = post_detect_from_ddl(
224
+ api_url=api_url, api_key=api_key, project_id=project_id, ddl=ddl_text, complete_source=complete_source
225
+ )
215
226
  except SourceChangeConflictError as exc:
216
227
  typer.secho(str(exc), fg=typer.colors.RED, err=True)
217
228
  raise typer.Exit(EXIT_VALIDATION_FAILED) from exc
@@ -237,9 +248,19 @@ def push(
237
248
  run_status = run.get("status")
238
249
  if run_status == "completed":
239
250
  summary = run.get("summary") or {}
240
- total = summary.get("total_changes", 0)
251
+ total = summary.get("reviewable_total")
252
+ if total is None:
253
+ total = sum(summary.get(key, 0) for key in ("changes_created", "changes_updated", "changes_reopened"))
254
+ if summary.get("partial_upload_suspected"):
255
+ typer.secho(
256
+ "Note: the file drops most of the tracked tables in the schemas it covers, which"
257
+ " often means a partial export. Removals of modeled tables wait for review;"
258
+ " re-push a complete export to undo unintended drops.",
259
+ fg=typer.colors.YELLOW,
260
+ err=True,
261
+ )
241
262
  typer.secho(
242
- f"✓ Detection completed: {total} change(s) detected." if total else "✓ Detection completed: no changes.",
263
+ f"✓ Detection completed: {total} change(s) to review." if total else "✓ Detection completed: no changes.",
243
264
  fg=typer.colors.GREEN,
244
265
  )
245
266
  raise typer.Exit(EXIT_OK)
@@ -0,0 +1,191 @@
1
+ """`cassis source-changes` — read the Data source review queue from the terminal.
2
+
3
+ Source changes are the schema drift Cassis detected between the data source
4
+ and what the ontology tracks (tables/columns added, removed, renamed,
5
+ retyped). Reading them from a checkout lets an agent see that a breaking
6
+ `column_removed` card is pending against a table it is editing — the fix then
7
+ happens in the ontology files via a pull request. Read-only by design:
8
+ reviewing (approve applies ontology edits, dismiss mutes the table) stays in
9
+ the webapp.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from pathlib import Path
16
+ from typing import Any, Optional
17
+
18
+ import typer
19
+ from cassis_cli.api import (
20
+ DEFAULT_API_URL,
21
+ ApiError,
22
+ AuthError,
23
+ SourceChangeNotFoundError,
24
+ get_source_change,
25
+ get_source_changes,
26
+ )
27
+ from cassis_cli.common import (
28
+ DEFAULT_BASE_PATH,
29
+ EXIT_OK,
30
+ EXIT_USAGE,
31
+ EXIT_VALIDATION_FAILED,
32
+ api_failure,
33
+ one_line,
34
+ require_api_key,
35
+ resolve_project_id,
36
+ )
37
+
38
+ app = typer.Typer(no_args_is_help=True, help="Read the project's Data source review queue.")
39
+
40
+ STATUSES = ("pending", "approved", "rejected", "superseded")
41
+
42
+ _PATH_OPTION = typer.Option(
43
+ Path("."),
44
+ "--path",
45
+ help="Repository checkout root (holds <base-path>/project.yml for the --project default).",
46
+ )
47
+ _PROJECT_OPTION = typer.Option(
48
+ None,
49
+ "--project",
50
+ envvar="CASSIS_PROJECT_ID",
51
+ help="Target Cassis project ID (UUID). Defaults to the id in <base-path>/project.yml.",
52
+ )
53
+ _API_KEY_OPTION = typer.Option(
54
+ None,
55
+ "--api-key",
56
+ envvar="CASSIS_API_KEY",
57
+ help="Cassis API key (sk-k6-...). Create one in Organization settings -> API keys.",
58
+ )
59
+ _API_URL_OPTION = typer.Option(
60
+ DEFAULT_API_URL,
61
+ "--api-url",
62
+ envvar="CASSIS_API_URL",
63
+ help="Cassis API base URL.",
64
+ )
65
+ _BASE_PATH_OPTION = typer.Option(
66
+ DEFAULT_BASE_PATH,
67
+ "--base-path",
68
+ envvar="CASSIS_BASE_PATH",
69
+ help="Repository directory the ontology is exported under (holds project.yml for the --project default).",
70
+ )
71
+
72
+
73
+ def _target(change: dict[str, Any]) -> str:
74
+ parts = [change.get("target_schema"), change.get("target_table"), change.get("target_column")]
75
+ return ".".join(str(p) for p in parts if p)
76
+
77
+
78
+ @app.command(name="list")
79
+ def list_source_changes(
80
+ status: Optional[str] = typer.Option(
81
+ None, "--status", help=f"Filter by status ({', '.join(STATUSES)}). Defaults to pending."
82
+ ),
83
+ limit: int = typer.Option(100, "--limit", min=1, max=500, help="Page size."),
84
+ offset: int = typer.Option(0, "--offset", min=0, help="Page start, newest first."),
85
+ path: Path = _PATH_OPTION,
86
+ project_id: Optional[str] = _PROJECT_OPTION,
87
+ api_key: Optional[str] = _API_KEY_OPTION,
88
+ api_url: str = _API_URL_OPTION,
89
+ base_path: str = _BASE_PATH_OPTION,
90
+ json_output: bool = typer.Option(False, "--json", help="Print the page as raw JSON ({items, total})."),
91
+ ) -> None:
92
+ """List the pending Data source review items, newest first.
93
+
94
+ Prints each change's id, type, severity, status and target; the id is what
95
+ `cassis source-changes show` takes. A `breaking` severity means a curated
96
+ ontology object references the changed source object. Exits 0 on success,
97
+ 2 on usage errors, 3 on transport/API errors.
98
+ """
99
+ if status is not None and status not in STATUSES:
100
+ typer.secho(f"--status must be one of {', '.join(STATUSES)}, got {status!r}.", fg=typer.colors.RED, err=True)
101
+ raise typer.Exit(EXIT_USAGE)
102
+ api_key = require_api_key(api_key)
103
+ project_id = resolve_project_id(project_id, path / Path(base_path), quiet=json_output)
104
+
105
+ try:
106
+ page = get_source_changes(
107
+ api_url=api_url,
108
+ api_key=api_key,
109
+ project_id=project_id,
110
+ status=status,
111
+ limit=limit,
112
+ offset=offset,
113
+ )
114
+ except (AuthError, ApiError) as exc:
115
+ raise api_failure(exc) from exc
116
+
117
+ if json_output:
118
+ typer.echo(json.dumps(page, indent=2))
119
+ raise typer.Exit(EXIT_OK)
120
+
121
+ items = page.get("items") or []
122
+ total = page.get("total", len(items))
123
+ if not items:
124
+ typer.echo("No source changes match." if status else "No pending source changes.")
125
+ raise typer.Exit(EXIT_OK)
126
+
127
+ for change in items:
128
+ typer.echo(
129
+ f"{change.get('id')} {change.get('change_type')} {change.get('severity')} "
130
+ f"{change.get('status')} {_target(change)}"
131
+ )
132
+ shown = len(items)
133
+ if offset + shown < total:
134
+ typer.echo(f"Showing {shown} of {total} (use --offset {offset + shown} for the next page).")
135
+ raise typer.Exit(EXIT_OK)
136
+
137
+
138
+ @app.command()
139
+ def show(
140
+ change_id: str = typer.Argument(..., help="Id of the change to show (from `cassis source-changes list`)."),
141
+ path: Path = _PATH_OPTION,
142
+ project_id: Optional[str] = _PROJECT_OPTION,
143
+ api_key: Optional[str] = _API_KEY_OPTION,
144
+ api_url: str = _API_URL_OPTION,
145
+ base_path: str = _BASE_PATH_OPTION,
146
+ json_output: bool = typer.Option(False, "--json", help="Print the change as raw JSON."),
147
+ ) -> None:
148
+ """Show one Data source review item: its impact and the suggested edit.
149
+
150
+ `impact` lists the curated ontology objects referencing the changed source
151
+ object; the suggested edit describes what approving in the webapp would do
152
+ — make the equivalent edit in the ontology files to fix headlessly. Exits
153
+ 0 on success, 1 when the change does not exist in the project, 2 on usage
154
+ errors, 3 on transport/API errors.
155
+ """
156
+ api_key = require_api_key(api_key)
157
+ project_id = resolve_project_id(project_id, path / Path(base_path), quiet=json_output)
158
+
159
+ try:
160
+ change = get_source_change(api_url=api_url, api_key=api_key, project_id=project_id, change_id=change_id)
161
+ except SourceChangeNotFoundError as exc:
162
+ typer.secho(str(exc), fg=typer.colors.YELLOW, err=True)
163
+ raise typer.Exit(EXIT_VALIDATION_FAILED) from exc
164
+ except (AuthError, ApiError) as exc:
165
+ raise api_failure(exc) from exc
166
+
167
+ if json_output:
168
+ typer.echo(json.dumps(change, indent=2))
169
+ raise typer.Exit(EXIT_OK)
170
+
171
+ typer.echo(f"{change.get('id')} {change.get('change_type')} {change.get('severity')} {change.get('status')}")
172
+ typer.echo(f"Target: {_target(change)}")
173
+ typer.echo(f"Raised {change.get('times_raised', 1)}x, last {change.get('last_detected_at')}")
174
+ impact = change.get("impact") or []
175
+ if impact:
176
+ typer.echo("")
177
+ typer.echo("Impact:")
178
+ for ref in impact:
179
+ line = f" {ref.get('kind')} {ref.get('confidence')} {one_line(ref.get('object_label'))}"
180
+ detail = ref.get("detail")
181
+ if detail:
182
+ line += f" — {one_line(detail)}"
183
+ typer.echo(line)
184
+ edit = change.get("suggested_edit") or {}
185
+ summary = edit.get("human_summary") if isinstance(edit, dict) else None
186
+ if summary:
187
+ typer.echo("")
188
+ typer.echo(f"Suggested edit: {one_line(summary)}")
189
+ for note in edit.get("manual_review") or []:
190
+ typer.echo(f" Manual review: {one_line(note)}")
191
+ raise typer.Exit(EXIT_OK)
@@ -87,6 +87,11 @@ def _render(status_record: "dict[str, Any]", comparison_text: str) -> None:
87
87
  typer.echo(f"Git sync: {git_sync['provider']} {git_sync['repo']} (path {git_sync['base_path']})")
88
88
  else:
89
89
  typer.echo("Git sync: not configured")
90
+ pending = status_record.get("pending_source_changes")
91
+ if pending and pending.get("total"):
92
+ breaking = pending.get("breaking") or 0
93
+ breaking_text = f", {breaking} breaking" if breaking else ""
94
+ typer.echo(f"Source changes pending review: {pending['total']}{breaking_text} (cassis source-changes list)")
90
95
  typer.echo(f"Local checkout: {comparison_text}")
91
96
 
92
97
 
@@ -1,11 +1,11 @@
1
1
  [project]
2
2
  name = "cassis-cli"
3
- version = "1.5.0"
4
- description = "Cassis CLI — run Cassis actions (ontology validation, upload and publish) from your CI pipelines"
3
+ version = "1.6.0"
4
+ description = "Validate, test and evaluate your Cassis ontology from your terminal, then publish it"
5
5
  readme = "README.md"
6
6
  license = { text = "Apache-2.0" }
7
7
  authors = [{ name = "Cassis", email = "tech.admin@getcassis.com" }]
8
- keywords = ["cassis", "ontology", "ci", "text-to-sql"]
8
+ keywords = ["cassis", "ontology", "cli", "ci", "text-to-sql"]
9
9
  classifiers = [
10
10
  "License :: OSI Approved :: Apache Software License",
11
11
  "Environment :: Console",
@@ -43,3 +43,10 @@ build-backend = "poetry.core.masonry.api"
43
43
 
44
44
  [tool.pytest.ini_options]
45
45
  testpaths = ["tests"]
46
+
47
+ [tool.black]
48
+ # The package ships for Python >=3.10 (see requires-python above): formatting
49
+ # must target the floor, or black under the monorepo's 3.14 rewrites syntax
50
+ # into 3.14-only forms (e.g. stripping parens from multi-exception `except`).
51
+ line-length = 120
52
+ target-version = ["py310"]
File without changes
File without changes