cassis-cli 0.4.0__tar.gz → 1.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cassis_cli-0.4.0 → cassis_cli-1.1.0}/PKG-INFO +31 -9
- {cassis_cli-0.4.0 → cassis_cli-1.1.0}/README.md +30 -8
- {cassis_cli-0.4.0 → cassis_cli-1.1.0}/cassis_cli/__init__.py +1 -1
- {cassis_cli-0.4.0 → cassis_cli-1.1.0}/cassis_cli/api.py +180 -17
- cassis_cli-1.1.0/cassis_cli/common.py +177 -0
- {cassis_cli-0.4.0 → cassis_cli-1.1.0}/cassis_cli/eval.py +88 -10
- cassis_cli-1.1.0/cassis_cli/guide.py +97 -0
- {cassis_cli-0.4.0 → cassis_cli-1.1.0}/cassis_cli/ontology.py +225 -30
- cassis_cli-1.1.0/cassis_cli/ontology_design_guide.md +412 -0
- {cassis_cli-0.4.0 → cassis_cli-1.1.0}/pyproject.toml +4 -1
- cassis_cli-0.4.0/cassis_cli/common.py +0 -95
- {cassis_cli-0.4.0 → cassis_cli-1.1.0}/cassis_cli/main.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cassis-cli
|
|
3
|
-
Version:
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: Cassis CLI — run Cassis actions (ontology validation, upload and publish) from your CI pipelines
|
|
5
5
|
License: Proprietary
|
|
6
6
|
Keywords: cassis,ontology,ci,text-to-sql
|
|
@@ -25,8 +25,12 @@ Run Cassis actions from your CI pipelines:
|
|
|
25
25
|
- `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.
|
|
26
26
|
- `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.
|
|
27
27
|
- `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.
|
|
28
|
-
- `cassis ontology pull` downloads the project's unpublished ontology into your repository checkout (full sync — stale local
|
|
28
|
+
- `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).
|
|
29
|
+
- `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.
|
|
30
|
+
- 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).
|
|
29
31
|
- `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.
|
|
32
|
+
- `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.
|
|
33
|
+
- `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.
|
|
30
34
|
|
|
31
35
|
## Install
|
|
32
36
|
|
|
@@ -34,11 +38,21 @@ Run Cassis actions from your CI pipelines:
|
|
|
34
38
|
pip install cassis-cli
|
|
35
39
|
```
|
|
36
40
|
|
|
41
|
+
## Ontology file format
|
|
42
|
+
|
|
43
|
+
The ontology tree under `<base-path>` (default `cassis/`) is:
|
|
44
|
+
|
|
45
|
+
- **Project identity** — `project.yml`: the Cassis project id and format version. Written by `pull` and by server-side publish (the contexts that know the id); a local `fmt` won't create it.
|
|
46
|
+
- **Domains** — Markdown files: every domain is the `README.md` of its folder — `domains/README.md` for the root, `domains/<path>/README.md` for each sub-domain. Each has a small YAML frontmatter block (`type`, `title`, `description`) and a Markdown body carrying the domain's `context_md`; a generated section at the bottom links the domain's tables and metrics (kept current by `fmt`/`pull` — edit your prose above it, and the PR check fails if the links are stale, so re-run `fmt`). The layout is a Cassis profile inspired by [OKF](https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf): the files render on GitHub and read in any Markdown editor, but Cassis validates them strictly (unknown keys are flagged, not preserved).
|
|
47
|
+
- **Tables, joins, metrics** — YAML, unchanged: `tables/<schema>/<table>.yml`, `joins.yml`, `metrics/<name>.yml`.
|
|
48
|
+
|
|
49
|
+
**Migrating an existing repo** (domains were YAML `_project.yml` / `_domain.yml` before cassis-cli 1.1.0): upgrade and run `cassis ontology fmt` (or `cassis ontology pull` if you have no local edits) — it rewrites the domain files to Markdown and removes the old ones. Review the diff and commit. Cassis reads the old YAML domain files too, so an un-migrated repo keeps working until you convert it. **Uploading requires cassis-cli ≥ 1.1.0** — the server rejects an older CLI (which would drop the Markdown domain files) with a clear upgrade error.
|
|
50
|
+
|
|
37
51
|
## Setup
|
|
38
52
|
|
|
39
53
|
1. Create an API key in Cassis under **Organization settings → API keys** (keys start with `sk-k6-`).
|
|
40
54
|
2. Store it as a CI secret and expose it as `CASSIS_API_KEY`.
|
|
41
|
-
3. For `pull`, `upload` and `eval
|
|
55
|
+
3. For `pull`, `upload`, `eval run`, `ontology test`, and `eval add-case`: the project ID (UUID) is taken from `<base-path>/project.yml` in the checkout (written by `pull` and by publishing) — so once a repo is pulled you don't need to pass it. To override, or before the first pull, set `CASSIS_PROJECT_ID` or pass `--project` (find the UUID in the project's URL).
|
|
42
56
|
|
|
43
57
|
## Usage
|
|
44
58
|
|
|
@@ -77,6 +91,14 @@ cassis eval run --project ... --branch feature-x
|
|
|
77
91
|
|
|
78
92
|
# Start the run and return immediately (poll in the webapp):
|
|
79
93
|
cassis eval run --project ... --no-wait
|
|
94
|
+
|
|
95
|
+
# Probe questions through the text-to-SQL agent using the local ontology files
|
|
96
|
+
# (one full agent run per question, expect ~30-90s each; repeat -q for several):
|
|
97
|
+
cassis ontology test --project ... -q "How much was refunded last month?" -q "Net revenue in Q1?"
|
|
98
|
+
|
|
99
|
+
# Add a gold case to the eval suite (rejected if the exact question already exists):
|
|
100
|
+
cassis eval add-case --project ... -q "How much was refunded last month?" \
|
|
101
|
+
--gold-sql "SELECT SUM(refunded_cents) / 100.0 FROM public.orders WHERE ..."
|
|
80
102
|
```
|
|
81
103
|
|
|
82
104
|
Configuration (flags take precedence over env vars):
|
|
@@ -86,7 +108,7 @@ Configuration (flags take precedence over env vars):
|
|
|
86
108
|
| `--api-key` | `CASSIS_API_KEY` | — (required) |
|
|
87
109
|
| `--api-url` | `CASSIS_API_URL` | `https://app.getcassis.com` |
|
|
88
110
|
| `--base-path` | `CASSIS_BASE_PATH` | `cassis` — must match the project's git-sync "Path" setting |
|
|
89
|
-
| `--project` (pull, upload, eval run) | `CASSIS_PROJECT_ID` | — (required) |
|
|
111
|
+
| `--project` (pull, upload, eval run, eval add-case, test) | `CASSIS_PROJECT_ID` | — (required) |
|
|
90
112
|
|
|
91
113
|
`cassis eval run` also accepts `--label` (run label in the Evals page; defaults
|
|
92
114
|
to the branch name from the CI environment or the local git checkout; rejected
|
|
@@ -114,13 +136,13 @@ cassis ontology fmt --check
|
|
|
114
136
|
|
|
115
137
|
| Code | Meaning |
|
|
116
138
|
| ---- | ------------------------------------------------------------------------------ |
|
|
117
|
-
| 0 | Ontology is valid (check) / pulled (pull) / uploaded (upload) / eval run completed all-passed (eval run) |
|
|
118
|
-
| 1 | Validation failed (check: findings printed; upload: nothing imported; eval run: invalid tree, failed cases, or failed/cancelled run) |
|
|
139
|
+
| 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) |
|
|
140
|
+
| 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) |
|
|
119
141
|
| 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits) |
|
|
120
|
-
| 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another run already active, or `--timeout` reached |
|
|
142
|
+
| 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another run already active, out of credits, or `--timeout` reached |
|
|
121
143
|
|
|
122
|
-
Commands that send the local tree (`check`, `upload`, `eval run`) accept up to
|
|
123
|
-
2000
|
|
144
|
+
Commands that send the local tree (`check`, `fmt`, `upload`, `eval run`, `test`) accept up to
|
|
145
|
+
2000 ontology files / 5 MB total — far above real ontologies (a few hundred small
|
|
124
146
|
files). Beyond that the CLI fails fast with exit 2 before uploading anything;
|
|
125
147
|
double-check `--base-path` if you hit it.
|
|
126
148
|
|
|
@@ -5,8 +5,12 @@ Run Cassis actions from your CI pipelines:
|
|
|
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.
|
|
6
6
|
- `cassis ontology fmt` rewrites the ontology files in canonical form (think `black`/`gofmt` for the ontology), so hand or agent edits pass the round-trip check.
|
|
7
7
|
- `cassis ontology upload` uploads the ontology files to a Cassis project (full replace) and, by default, publishes them immediately as a new version — so a merge to your main branch can go live in one CI step.
|
|
8
|
-
- `cassis ontology pull` downloads the project's unpublished ontology into your repository checkout (full sync — stale local
|
|
8
|
+
- `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` 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.
|
|
10
|
+
- 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).
|
|
9
11
|
- `cassis eval run` runs the project's eval suite against your local ontology files (scored in-memory — nothing is pushed to Cassis) and prints per-question results, so you can test the changes on your git branch before merging.
|
|
12
|
+
- `cassis ontology test` runs individual questions through the text-to-SQL agent using your local ontology files, so you can check that a change actually works (e.g. a new column gets picked) — where `eval run` only checks for regressions on existing eval cases.
|
|
13
|
+
- `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.
|
|
10
14
|
|
|
11
15
|
## Install
|
|
12
16
|
|
|
@@ -14,11 +18,21 @@ Run Cassis actions from your CI pipelines:
|
|
|
14
18
|
pip install cassis-cli
|
|
15
19
|
```
|
|
16
20
|
|
|
21
|
+
## Ontology file format
|
|
22
|
+
|
|
23
|
+
The ontology tree under `<base-path>` (default `cassis/`) is:
|
|
24
|
+
|
|
25
|
+
- **Project identity** — `project.yml`: the Cassis project id and format version. Written by `pull` and by server-side publish (the contexts that know the id); a local `fmt` won't create it.
|
|
26
|
+
- **Domains** — Markdown files: every domain is the `README.md` of its folder — `domains/README.md` for the root, `domains/<path>/README.md` for each sub-domain. Each has a small YAML frontmatter block (`type`, `title`, `description`) and a Markdown body carrying the domain's `context_md`; a generated section at the bottom links the domain's tables and metrics (kept current by `fmt`/`pull` — edit your prose above it, and the PR check fails if the links are stale, so re-run `fmt`). The layout is a Cassis profile inspired by [OKF](https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf): the files render on GitHub and read in any Markdown editor, but Cassis validates them strictly (unknown keys are flagged, not preserved).
|
|
27
|
+
- **Tables, joins, metrics** — YAML, unchanged: `tables/<schema>/<table>.yml`, `joins.yml`, `metrics/<name>.yml`.
|
|
28
|
+
|
|
29
|
+
**Migrating an existing repo** (domains were YAML `_project.yml` / `_domain.yml` before cassis-cli 1.1.0): upgrade and run `cassis ontology fmt` (or `cassis ontology pull` if you have no local edits) — it rewrites the domain files to Markdown and removes the old ones. Review the diff and commit. Cassis reads the old YAML domain files too, so an un-migrated repo keeps working until you convert it. **Uploading requires cassis-cli ≥ 1.1.0** — the server rejects an older CLI (which would drop the Markdown domain files) with a clear upgrade error.
|
|
30
|
+
|
|
17
31
|
## Setup
|
|
18
32
|
|
|
19
33
|
1. Create an API key in Cassis under **Organization settings → API keys** (keys start with `sk-k6-`).
|
|
20
34
|
2. Store it as a CI secret and expose it as `CASSIS_API_KEY`.
|
|
21
|
-
3. For `pull`, `upload` and `eval
|
|
35
|
+
3. For `pull`, `upload`, `eval run`, `ontology test`, and `eval add-case`: the project ID (UUID) is taken from `<base-path>/project.yml` in the checkout (written by `pull` and by publishing) — so once a repo is pulled you don't need to pass it. To override, or before the first pull, set `CASSIS_PROJECT_ID` or pass `--project` (find the UUID in the project's URL).
|
|
22
36
|
|
|
23
37
|
## Usage
|
|
24
38
|
|
|
@@ -57,6 +71,14 @@ cassis eval run --project ... --branch feature-x
|
|
|
57
71
|
|
|
58
72
|
# Start the run and return immediately (poll in the webapp):
|
|
59
73
|
cassis eval run --project ... --no-wait
|
|
74
|
+
|
|
75
|
+
# Probe questions through the text-to-SQL agent using the local ontology files
|
|
76
|
+
# (one full agent run per question, expect ~30-90s each; repeat -q for several):
|
|
77
|
+
cassis ontology test --project ... -q "How much was refunded last month?" -q "Net revenue in Q1?"
|
|
78
|
+
|
|
79
|
+
# Add a gold case to the eval suite (rejected if the exact question already exists):
|
|
80
|
+
cassis eval add-case --project ... -q "How much was refunded last month?" \
|
|
81
|
+
--gold-sql "SELECT SUM(refunded_cents) / 100.0 FROM public.orders WHERE ..."
|
|
60
82
|
```
|
|
61
83
|
|
|
62
84
|
Configuration (flags take precedence over env vars):
|
|
@@ -66,7 +88,7 @@ Configuration (flags take precedence over env vars):
|
|
|
66
88
|
| `--api-key` | `CASSIS_API_KEY` | — (required) |
|
|
67
89
|
| `--api-url` | `CASSIS_API_URL` | `https://app.getcassis.com` |
|
|
68
90
|
| `--base-path` | `CASSIS_BASE_PATH` | `cassis` — must match the project's git-sync "Path" setting |
|
|
69
|
-
| `--project` (pull, upload, eval run) | `CASSIS_PROJECT_ID` | — (required) |
|
|
91
|
+
| `--project` (pull, upload, eval run, eval add-case, test) | `CASSIS_PROJECT_ID` | — (required) |
|
|
70
92
|
|
|
71
93
|
`cassis eval run` also accepts `--label` (run label in the Evals page; defaults
|
|
72
94
|
to the branch name from the CI environment or the local git checkout; rejected
|
|
@@ -94,13 +116,13 @@ cassis ontology fmt --check
|
|
|
94
116
|
|
|
95
117
|
| Code | Meaning |
|
|
96
118
|
| ---- | ------------------------------------------------------------------------------ |
|
|
97
|
-
| 0 | Ontology is valid (check) / pulled (pull) / uploaded (upload) / eval run completed all-passed (eval run) |
|
|
98
|
-
| 1 | Validation failed (check: findings printed; upload: nothing imported; eval run: invalid tree, failed cases, or failed/cancelled run) |
|
|
119
|
+
| 0 | Ontology is valid (check) / pulled (pull) / uploaded (upload) / eval run completed all-passed (eval run) / every probe completed (test — whatever its outcome; probes are informational, don't gate CI on them) |
|
|
120
|
+
| 1 | Validation failed (check: findings printed; upload: nothing imported; eval run: invalid tree, failed cases, or failed/cancelled run; test: invalid tree or a probe failed) |
|
|
99
121
|
| 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits) |
|
|
100
|
-
| 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another run already active, or `--timeout` reached |
|
|
122
|
+
| 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another run already active, out of credits, or `--timeout` reached |
|
|
101
123
|
|
|
102
|
-
Commands that send the local tree (`check`, `upload`, `eval run`) accept up to
|
|
103
|
-
2000
|
|
124
|
+
Commands that send the local tree (`check`, `fmt`, `upload`, `eval run`, `test`) accept up to
|
|
125
|
+
2000 ontology files / 5 MB total — far above real ontologies (a few hundred small
|
|
104
126
|
files). Beyond that the CLI fails fast with exit 2 before uploading anything;
|
|
105
127
|
double-check `--base-path` if you hit it.
|
|
106
128
|
|
|
@@ -2,13 +2,73 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import sys
|
|
5
6
|
from typing import Any, Optional
|
|
6
7
|
|
|
7
8
|
import httpx
|
|
9
|
+
from cassis_cli import __version__
|
|
8
10
|
|
|
9
11
|
DEFAULT_API_URL = "https://app.getcassis.com"
|
|
10
12
|
TIMEOUT_SECONDS = 60.0
|
|
11
13
|
|
|
14
|
+
USER_AGENT = f"cassis-cli/{__version__}"
|
|
15
|
+
|
|
16
|
+
# Response header the CI endpoints set to the newest cassis-cli on PyPI.
|
|
17
|
+
LATEST_VERSION_HEADER = "x-cassis-cli-latest"
|
|
18
|
+
|
|
19
|
+
_upgrade_notice_shown = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _version_tuple(version: str) -> Optional[tuple[int, ...]]:
|
|
23
|
+
try:
|
|
24
|
+
return tuple(int(part) for part in version.strip().split("."))
|
|
25
|
+
except ValueError:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _maybe_print_upgrade_notice(response: httpx.Response) -> None:
|
|
30
|
+
"""Print a one-time stderr notice when the server advertises a newer CLI.
|
|
31
|
+
|
|
32
|
+
Purely informational — never changes behavior or exit codes. Staying
|
|
33
|
+
current matters beyond bugfixes: the ontology modeling guide written to
|
|
34
|
+
``cassis/AGENTS.md`` ships inside this package, so an old CLI keeps old
|
|
35
|
+
doctrine in the repo.
|
|
36
|
+
"""
|
|
37
|
+
global _upgrade_notice_shown
|
|
38
|
+
if _upgrade_notice_shown:
|
|
39
|
+
return
|
|
40
|
+
latest = response.headers.get(LATEST_VERSION_HEADER)
|
|
41
|
+
if not latest:
|
|
42
|
+
return
|
|
43
|
+
mine, theirs = _version_tuple(__version__), _version_tuple(latest)
|
|
44
|
+
if mine is None or theirs is None:
|
|
45
|
+
return
|
|
46
|
+
# Zero-pad to equal length so "0.6" == "0.6.0" (same rule as the webapp's
|
|
47
|
+
# Agent setup page — the comparison logic exists on both surfaces).
|
|
48
|
+
width = max(len(mine), len(theirs))
|
|
49
|
+
if theirs + (0,) * (width - len(theirs)) <= mine + (0,) * (width - len(mine)):
|
|
50
|
+
return
|
|
51
|
+
_upgrade_notice_shown = True
|
|
52
|
+
print(
|
|
53
|
+
f"notice: cassis-cli {latest} is available (you have {__version__}) — "
|
|
54
|
+
"run `pip install -U cassis-cli`, then `cassis ontology fmt` to refresh cassis/AGENTS.md.",
|
|
55
|
+
file=sys.stderr,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _client(*, timeout: float = TIMEOUT_SECONDS, transport: Optional[httpx.BaseTransport] = None) -> httpx.Client:
|
|
60
|
+
"""Build the HTTP client every API call goes through.
|
|
61
|
+
|
|
62
|
+
Identifies the CLI to the server (User-Agent) and watches responses for
|
|
63
|
+
the newer-version advertisement.
|
|
64
|
+
"""
|
|
65
|
+
return httpx.Client(
|
|
66
|
+
timeout=timeout,
|
|
67
|
+
transport=transport,
|
|
68
|
+
headers={"User-Agent": USER_AGENT},
|
|
69
|
+
event_hooks={"response": [_maybe_print_upgrade_notice]},
|
|
70
|
+
)
|
|
71
|
+
|
|
12
72
|
|
|
13
73
|
class ApiError(Exception):
|
|
14
74
|
"""Transport or HTTP-level failure talking to the Cassis API."""
|
|
@@ -52,6 +112,14 @@ def _project_scope_error(response: httpx.Response) -> ApiError:
|
|
|
52
112
|
)
|
|
53
113
|
|
|
54
114
|
|
|
115
|
+
def _detail_or_text(response: httpx.Response) -> Any:
|
|
116
|
+
"""Return the error response's ``detail`` (str or structured), falling back to its body."""
|
|
117
|
+
try:
|
|
118
|
+
return response.json().get("detail") or response.text[:500]
|
|
119
|
+
except ValueError:
|
|
120
|
+
return response.text[:500]
|
|
121
|
+
|
|
122
|
+
|
|
55
123
|
def _parse_json_response(response: httpx.Response, url: str) -> Any:
|
|
56
124
|
try:
|
|
57
125
|
return response.json()
|
|
@@ -71,7 +139,7 @@ def post_ontology_check(
|
|
|
71
139
|
"""POST the ontology tree to /api/ci/ontology-check and return the response body."""
|
|
72
140
|
url = api_url.rstrip("/") + "/api/ci/ontology-check"
|
|
73
141
|
try:
|
|
74
|
-
with
|
|
142
|
+
with _client(transport=transport) as client:
|
|
75
143
|
response = client.post(
|
|
76
144
|
url,
|
|
77
145
|
json={"files": files},
|
|
@@ -110,7 +178,7 @@ def post_ontology_import(
|
|
|
110
178
|
if label is not None:
|
|
111
179
|
body["label"] = label
|
|
112
180
|
try:
|
|
113
|
-
with
|
|
181
|
+
with _client(transport=transport) as client:
|
|
114
182
|
response = client.post(
|
|
115
183
|
url,
|
|
116
184
|
json=body,
|
|
@@ -122,11 +190,9 @@ def post_ontology_import(
|
|
|
122
190
|
if response.status_code == 401:
|
|
123
191
|
raise AuthError("The Cassis API rejected the API key (invalid or expired).")
|
|
124
192
|
if response.status_code == 400:
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
detail = response.text[:500]
|
|
129
|
-
raise UploadValidationError(str(detail))
|
|
193
|
+
raise UploadValidationError(str(_detail_or_text(response)))
|
|
194
|
+
if response.status_code == 426: # this CLI is too old for the server's ontology format
|
|
195
|
+
raise UploadValidationError(str(_detail_or_text(response)))
|
|
130
196
|
if response.status_code in (403, 404):
|
|
131
197
|
raise _project_scope_error(response)
|
|
132
198
|
if response.status_code >= 400:
|
|
@@ -149,7 +215,7 @@ def get_ontology_export(
|
|
|
149
215
|
"""GET /api/ci/projects/{project_id}/ontology/export and return the files tree."""
|
|
150
216
|
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/ontology/export"
|
|
151
217
|
try:
|
|
152
|
-
with
|
|
218
|
+
with _client(transport=transport) as client:
|
|
153
219
|
response = client.get(url, headers={"Authorization": f"Bearer {api_key}"})
|
|
154
220
|
except httpx.HTTPError as exc:
|
|
155
221
|
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
@@ -186,7 +252,7 @@ def post_eval_run_start(
|
|
|
186
252
|
if label is not None:
|
|
187
253
|
body["label"] = label
|
|
188
254
|
try:
|
|
189
|
-
with
|
|
255
|
+
with _client(transport=transport) as client:
|
|
190
256
|
response = client.post(url, json=body, headers={"Authorization": f"Bearer {api_key}"})
|
|
191
257
|
except httpx.HTTPError as exc:
|
|
192
258
|
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
@@ -194,11 +260,7 @@ def post_eval_run_start(
|
|
|
194
260
|
if response.status_code == 401:
|
|
195
261
|
raise AuthError("The Cassis API rejected the API key (invalid or expired).")
|
|
196
262
|
if response.status_code == 400:
|
|
197
|
-
|
|
198
|
-
detail = response.json().get("detail") or response.text[:500]
|
|
199
|
-
except ValueError:
|
|
200
|
-
detail = response.text[:500]
|
|
201
|
-
raise EvalStartValidationError(detail)
|
|
263
|
+
raise EvalStartValidationError(_detail_or_text(response))
|
|
202
264
|
if response.status_code == 409:
|
|
203
265
|
raise EvalRunActiveError(
|
|
204
266
|
"An eval run is already active for this project — wait for it to finish or cancel it "
|
|
@@ -214,10 +276,55 @@ def post_eval_run_start(
|
|
|
214
276
|
return result
|
|
215
277
|
|
|
216
278
|
|
|
279
|
+
class EvalCaseExistsError(ApiError):
|
|
280
|
+
"""The project already has an eval case with this exact question."""
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class EvalCaseGoldSqlError(ApiError):
|
|
284
|
+
"""The API rejected the gold SQL (400): it does not run against the project's data source."""
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def post_eval_case_create(
|
|
288
|
+
*,
|
|
289
|
+
api_url: str,
|
|
290
|
+
api_key: str,
|
|
291
|
+
project_id: str,
|
|
292
|
+
question: str,
|
|
293
|
+
gold_sql: str,
|
|
294
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
295
|
+
) -> dict[str, Any]:
|
|
296
|
+
"""POST to /api/ci/projects/{project_id}/eval/cases and return the created case."""
|
|
297
|
+
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/eval/cases"
|
|
298
|
+
try:
|
|
299
|
+
with _client(transport=transport) as client:
|
|
300
|
+
response = client.post(
|
|
301
|
+
url,
|
|
302
|
+
json={"question": question, "gold_sql": gold_sql},
|
|
303
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
304
|
+
)
|
|
305
|
+
except httpx.HTTPError as exc:
|
|
306
|
+
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
307
|
+
|
|
308
|
+
if response.status_code == 401:
|
|
309
|
+
raise AuthError("The Cassis API rejected the API key (invalid or expired).")
|
|
310
|
+
if response.status_code == 409:
|
|
311
|
+
raise EvalCaseExistsError(str(_detail_or_text(response)))
|
|
312
|
+
if response.status_code == 400:
|
|
313
|
+
raise EvalCaseGoldSqlError(str(_detail_or_text(response)))
|
|
314
|
+
if response.status_code in (403, 404):
|
|
315
|
+
raise _project_scope_error(response)
|
|
316
|
+
if response.status_code >= 400:
|
|
317
|
+
raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
|
|
318
|
+
result = _parse_json_response(response, url)
|
|
319
|
+
if not isinstance(result, dict) or not all(key in result for key in ("id", "question", "gold_sql")):
|
|
320
|
+
raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
|
|
321
|
+
return result
|
|
322
|
+
|
|
323
|
+
|
|
217
324
|
def _get_eval_json(url: str, api_key: str, transport: Optional[httpx.BaseTransport]) -> Any:
|
|
218
325
|
"""GET an eval-run URL with the shared error mapping."""
|
|
219
326
|
try:
|
|
220
|
-
with
|
|
327
|
+
with _client(transport=transport) as client:
|
|
221
328
|
response = client.get(url, headers={"Authorization": f"Bearer {api_key}"})
|
|
222
329
|
except httpx.HTTPError as exc:
|
|
223
330
|
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
@@ -274,7 +381,7 @@ def post_eval_run_cancel(
|
|
|
274
381
|
"""POST /api/ci/projects/{project_id}/eval/runs/{run_id}/cancel."""
|
|
275
382
|
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/eval/runs/{run_id}/cancel"
|
|
276
383
|
try:
|
|
277
|
-
with
|
|
384
|
+
with _client(transport=transport) as client:
|
|
278
385
|
response = client.post(url, headers={"Authorization": f"Bearer {api_key}"})
|
|
279
386
|
except httpx.HTTPError as exc:
|
|
280
387
|
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
@@ -296,7 +403,7 @@ def post_ontology_fmt(
|
|
|
296
403
|
"""POST the ontology tree to /api/ci/ontology-fmt and return the response body."""
|
|
297
404
|
url = api_url.rstrip("/") + "/api/ci/ontology-fmt"
|
|
298
405
|
try:
|
|
299
|
-
with
|
|
406
|
+
with _client(transport=transport) as client:
|
|
300
407
|
response = client.post(
|
|
301
408
|
url,
|
|
302
409
|
json={"files": files},
|
|
@@ -320,3 +427,59 @@ def post_ontology_fmt(
|
|
|
320
427
|
):
|
|
321
428
|
raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
|
|
322
429
|
return result
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
# The server-side probe budget is 300s; leave headroom for transport.
|
|
433
|
+
ONTOLOGY_TEST_TIMEOUT_SECONDS = 330.0
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
class OntologyTestValidationError(ApiError):
|
|
437
|
+
"""The API rejected the ontology tree as invalid (400).
|
|
438
|
+
|
|
439
|
+
``detail`` keeps the structured payload (``{"message", "findings"}``) for
|
|
440
|
+
display.
|
|
441
|
+
"""
|
|
442
|
+
|
|
443
|
+
def __init__(self, detail: object) -> None:
|
|
444
|
+
super().__init__(str(detail))
|
|
445
|
+
self.detail = detail
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def post_ontology_test(
|
|
449
|
+
*,
|
|
450
|
+
api_url: str,
|
|
451
|
+
api_key: str,
|
|
452
|
+
project_id: str,
|
|
453
|
+
files: dict[str, str],
|
|
454
|
+
question: str,
|
|
455
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
456
|
+
) -> dict[str, Any]:
|
|
457
|
+
"""POST to /api/ci/projects/{project_id}/ontology/test and return the probe outcome.
|
|
458
|
+
|
|
459
|
+
Blocks for the duration of the agent run (up to ~5 minutes server-side).
|
|
460
|
+
"""
|
|
461
|
+
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/ontology/test"
|
|
462
|
+
try:
|
|
463
|
+
with _client(timeout=ONTOLOGY_TEST_TIMEOUT_SECONDS, transport=transport) as client:
|
|
464
|
+
response = client.post(
|
|
465
|
+
url,
|
|
466
|
+
json={"files": files, "question": question},
|
|
467
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
468
|
+
)
|
|
469
|
+
except httpx.HTTPError as exc:
|
|
470
|
+
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
471
|
+
|
|
472
|
+
if response.status_code == 401:
|
|
473
|
+
raise AuthError("The Cassis API rejected the API key (invalid or expired).")
|
|
474
|
+
if response.status_code == 400:
|
|
475
|
+
raise OntologyTestValidationError(_detail_or_text(response))
|
|
476
|
+
if response.status_code == 402:
|
|
477
|
+
raise ApiError("Your organization has run out of credits. Contact your administrator to top up.")
|
|
478
|
+
if response.status_code in (403, 404):
|
|
479
|
+
raise _project_scope_error(response)
|
|
480
|
+
if response.status_code >= 400:
|
|
481
|
+
raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
|
|
482
|
+
result = _parse_json_response(response, url)
|
|
483
|
+
if not isinstance(result, dict) or "status" not in result:
|
|
484
|
+
raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
|
|
485
|
+
return result
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Helpers shared by the `cassis` subcommands (tree collection, auth, exit codes)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
from uuid import UUID
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
# Repository directory the ontology tree is exported under. Must match the
|
|
13
|
+
# project's git-sync "Path" setting in Cassis (default "cassis").
|
|
14
|
+
DEFAULT_BASE_PATH = "cassis"
|
|
15
|
+
|
|
16
|
+
# Exit codes (documented in the README; stable contract for CI scripts).
|
|
17
|
+
EXIT_OK = 0
|
|
18
|
+
EXIT_VALIDATION_FAILED = 1
|
|
19
|
+
EXIT_USAGE = 2
|
|
20
|
+
EXIT_TRANSPORT = 3
|
|
21
|
+
|
|
22
|
+
# 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:
|
|
24
|
+
# backend/app/schemas/ci.py (the server's 422 remains the backstop).
|
|
25
|
+
MAX_FILES = 2000
|
|
26
|
+
MAX_TOTAL_BYTES = 5 * 1024 * 1024
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_ontology_file(rel_path: str) -> bool:
|
|
30
|
+
"""Whether a base-relative path is an ontology file the server reads.
|
|
31
|
+
|
|
32
|
+
YAML (``project.yml``, tables, joins, metrics, legacy domains) plus domain
|
|
33
|
+
Markdown — every domain is the ``README.md`` of its folder, root included
|
|
34
|
+
(``domains/README.md``), so ``domains/**/README.md`` covers them all. Mirrors
|
|
35
|
+
the server's ``ontology_fs.is_ontology_tree_file`` (kept in sync by hand —
|
|
36
|
+
the CLI can't import the backend). Excludes the managed ``AGENTS.md`` and any
|
|
37
|
+
stray Markdown note, so neither is uploaded nor deleted by ``pull --prune``.
|
|
38
|
+
"""
|
|
39
|
+
if rel_path.endswith((".yml", ".yaml")):
|
|
40
|
+
return True
|
|
41
|
+
return rel_path.startswith("domains/") and rel_path.endswith("/README.md")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_legacy_domain_file(rel_path: str) -> bool:
|
|
45
|
+
"""Whether a base-relative path is a legacy (pre-Markdown) domain file.
|
|
46
|
+
|
|
47
|
+
Used to report the one-time migration to the Markdown domain format, when
|
|
48
|
+
``pull``/``fmt`` remove a ``_project.yml``/``_domain.yml`` and write the
|
|
49
|
+
``domains/README.md`` (and sub-domain ``README.md``) that replaces it.
|
|
50
|
+
"""
|
|
51
|
+
return rel_path == "_project.yml" or rel_path.endswith("/_domain.yml")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def collect_files(ontology_dir: Path) -> dict[str, str]:
|
|
55
|
+
"""Read every ontology file under the ontology dir, keyed by posix relpath.
|
|
56
|
+
|
|
57
|
+
Ontology files are YAML and domain Markdown (see ``is_ontology_file``); the
|
|
58
|
+
managed ``AGENTS.md`` and stray notes are skipped. Exits 2 (usage) on an
|
|
59
|
+
unreadable or non-UTF-8 file — a local checkout problem, reported before
|
|
60
|
+
anything is sent to the API.
|
|
61
|
+
"""
|
|
62
|
+
files: dict[str, str] = {}
|
|
63
|
+
for pattern in ("**/*.yml", "**/*.yaml", "**/*.md"):
|
|
64
|
+
for file in sorted(ontology_dir.glob(pattern)):
|
|
65
|
+
if not file.is_file():
|
|
66
|
+
continue
|
|
67
|
+
rel = file.relative_to(ontology_dir).as_posix()
|
|
68
|
+
if not is_ontology_file(rel):
|
|
69
|
+
continue
|
|
70
|
+
try:
|
|
71
|
+
files[rel] = file.read_text(encoding="utf-8")
|
|
72
|
+
except (UnicodeDecodeError, OSError) as exc:
|
|
73
|
+
typer.secho(f"Cannot read {file}: {exc}", fg=typer.colors.RED, err=True)
|
|
74
|
+
raise typer.Exit(EXIT_USAGE) from exc
|
|
75
|
+
return files
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# project.yml is a two-line machine-written file (`cassis_format_version`, `project_id`);
|
|
79
|
+
# match the id line directly rather than pull in a YAML parser just for this.
|
|
80
|
+
_PROJECT_ID_LINE = re.compile(r"^project_id:\s*['\"]?([^'\"\s]+)['\"]?\s*$")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def read_project_id_from_dir(ontology_dir: Path) -> Optional[str]:
|
|
84
|
+
"""Return the ``project_id`` recorded in ``<ontology_dir>/project.yml``, or None."""
|
|
85
|
+
try:
|
|
86
|
+
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)
|
|
88
|
+
return None
|
|
89
|
+
for line in text.splitlines():
|
|
90
|
+
match = _PROJECT_ID_LINE.match(line.strip())
|
|
91
|
+
if match:
|
|
92
|
+
return match.group(1)
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def resolve_project_id(project_id: Optional[str], ontology_dir: Path) -> str:
|
|
97
|
+
"""Resolve the target project id, defaulting to the checkout's ``project.yml``.
|
|
98
|
+
|
|
99
|
+
Precedence: an explicit ``--project`` / ``CASSIS_PROJECT_ID`` wins; otherwise
|
|
100
|
+
the ``project_id`` recorded in ``<base-path>/project.yml`` (written by
|
|
101
|
+
``pull`` / publish) is used, and where it came from is noted on stderr so a
|
|
102
|
+
stale value in a copied repo is visible. Exits 2 (usage) when neither is
|
|
103
|
+
available or the value isn't a UUID.
|
|
104
|
+
"""
|
|
105
|
+
from_file = False
|
|
106
|
+
if not project_id:
|
|
107
|
+
project_id = read_project_id_from_dir(ontology_dir)
|
|
108
|
+
from_file = project_id is not None
|
|
109
|
+
if not project_id:
|
|
110
|
+
typer.secho(
|
|
111
|
+
f"No project. Pass --project (or set CASSIS_PROJECT_ID), or run in a checkout whose "
|
|
112
|
+
f"{ontology_dir.name}/project.yml records it (written by `cassis ontology pull` or a publish).",
|
|
113
|
+
fg=typer.colors.RED,
|
|
114
|
+
err=True,
|
|
115
|
+
)
|
|
116
|
+
raise typer.Exit(EXIT_USAGE)
|
|
117
|
+
try:
|
|
118
|
+
UUID(project_id)
|
|
119
|
+
except ValueError:
|
|
120
|
+
typer.secho(f"--project must be a project ID (UUID), got {project_id!r}.", fg=typer.colors.RED, err=True)
|
|
121
|
+
raise typer.Exit(EXIT_USAGE)
|
|
122
|
+
if from_file:
|
|
123
|
+
typer.secho(f"Using project {project_id} from {ontology_dir.name}/project.yml.", fg=typer.colors.CYAN, err=True)
|
|
124
|
+
return project_id
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def require_api_key(api_key: Optional[str]) -> str:
|
|
128
|
+
"""Exit 2 (usage) when no API key was provided."""
|
|
129
|
+
if not api_key:
|
|
130
|
+
typer.secho(
|
|
131
|
+
"No API key. Set CASSIS_API_KEY or pass --api-key "
|
|
132
|
+
"(create one in Cassis under Organization settings -> API keys).",
|
|
133
|
+
fg=typer.colors.RED,
|
|
134
|
+
err=True,
|
|
135
|
+
)
|
|
136
|
+
raise typer.Exit(EXIT_USAGE)
|
|
137
|
+
return api_key
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def collect_tree(path: Path, base_path: str) -> "tuple[dict[str, str], str]":
|
|
141
|
+
"""Resolve the ontology dir under the checkout and read its file tree.
|
|
142
|
+
|
|
143
|
+
Returns ``(files, normalized_base_path)``. Exits 2 (usage) on an empty or
|
|
144
|
+
missing dir, or a tree beyond the API request ceilings — all local checkout
|
|
145
|
+
problems, reported before anything is sent to the API.
|
|
146
|
+
"""
|
|
147
|
+
base_path = base_path.strip().strip("/")
|
|
148
|
+
if not base_path:
|
|
149
|
+
typer.secho("--base-path must not be empty.", fg=typer.colors.RED, err=True)
|
|
150
|
+
raise typer.Exit(EXIT_USAGE)
|
|
151
|
+
|
|
152
|
+
ontology_dir = path / Path(base_path)
|
|
153
|
+
if not ontology_dir.is_dir():
|
|
154
|
+
typer.secho(
|
|
155
|
+
f"No {base_path}/ directory found under {path}. "
|
|
156
|
+
"If the project exports to a custom path, pass it with --base-path (or CASSIS_BASE_PATH).",
|
|
157
|
+
fg=typer.colors.RED,
|
|
158
|
+
err=True,
|
|
159
|
+
)
|
|
160
|
+
raise typer.Exit(EXIT_USAGE)
|
|
161
|
+
|
|
162
|
+
files = collect_files(ontology_dir)
|
|
163
|
+
if not files:
|
|
164
|
+
typer.secho(f"No ontology files found under {ontology_dir}.", fg=typer.colors.RED, err=True)
|
|
165
|
+
raise typer.Exit(EXIT_USAGE)
|
|
166
|
+
|
|
167
|
+
total_bytes = sum(len(content.encode()) for content in files.values())
|
|
168
|
+
if len(files) > MAX_FILES or total_bytes > MAX_TOTAL_BYTES:
|
|
169
|
+
typer.secho(
|
|
170
|
+
f"Ontology tree too large: {len(files)} files / {total_bytes / (1024 * 1024):.1f} MB "
|
|
171
|
+
f"(limits: {MAX_FILES} files / {MAX_TOTAL_BYTES // (1024 * 1024)} MB). "
|
|
172
|
+
"Check that --base-path points at the ontology directory, not a larger tree.",
|
|
173
|
+
fg=typer.colors.RED,
|
|
174
|
+
err=True,
|
|
175
|
+
)
|
|
176
|
+
raise typer.Exit(EXIT_USAGE)
|
|
177
|
+
return files, base_path
|