cassis-cli 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cassis_cli-0.2.0 → cassis_cli-0.3.0}/PKG-INFO +61 -9
- {cassis_cli-0.2.0 → cassis_cli-0.3.0}/README.md +60 -8
- {cassis_cli-0.2.0 → cassis_cli-0.3.0}/cassis_cli/__init__.py +1 -1
- cassis_cli-0.3.0/cassis_cli/api.py +286 -0
- cassis_cli-0.3.0/cassis_cli/common.py +95 -0
- cassis_cli-0.3.0/cassis_cli/eval.py +356 -0
- {cassis_cli-0.2.0 → cassis_cli-0.3.0}/cassis_cli/main.py +2 -0
- {cassis_cli-0.2.0 → cassis_cli-0.3.0}/cassis_cli/ontology.py +114 -87
- {cassis_cli-0.2.0 → cassis_cli-0.3.0}/pyproject.toml +1 -1
- cassis_cli-0.2.0/cassis_cli/api.py +0 -113
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cassis-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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
|
|
@@ -24,6 +24,8 @@ Run Cassis actions from your CI pipelines:
|
|
|
24
24
|
|
|
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 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.
|
|
27
|
+
- `cassis ontology pull` downloads the project's unpublished ontology into your repository checkout (full sync — stale local YAML files are pruned), so you can start editing from the current state, or bootstrap a repo that isn't git-synced (e.g. Bitbucket).
|
|
28
|
+
- `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.
|
|
27
29
|
|
|
28
30
|
## Install
|
|
29
31
|
|
|
@@ -35,7 +37,7 @@ pip install cassis-cli
|
|
|
35
37
|
|
|
36
38
|
1. Create an API key in Cassis under **Organization settings → API keys** (keys start with `sk-k6-`).
|
|
37
39
|
2. Store it as a CI secret and expose it as `CASSIS_API_KEY`.
|
|
38
|
-
3. For `upload`: find the project ID (UUID) in the project's URL and expose it as `CASSIS_PROJECT_ID` (or pass `--project`).
|
|
40
|
+
3. For `pull`, `upload` and `eval run`: find the project ID (UUID) in the project's URL and expose it as `CASSIS_PROJECT_ID` (or pass `--project`).
|
|
39
41
|
|
|
40
42
|
## Usage
|
|
41
43
|
|
|
@@ -46,6 +48,10 @@ cassis ontology check
|
|
|
46
48
|
# Or point at the checkout explicitly:
|
|
47
49
|
cassis ontology check /path/to/checkout
|
|
48
50
|
|
|
51
|
+
# Download the project's unpublished ontology into the checkout (full sync;
|
|
52
|
+
# review with git diff — pass --no-prune to keep local files it would delete):
|
|
53
|
+
cassis ontology pull --project 019f0000-0000-7000-8000-000000000000
|
|
54
|
+
|
|
49
55
|
# Upload the ontology to a project and publish it immediately:
|
|
50
56
|
cassis ontology upload --project 019f0000-0000-7000-8000-000000000000
|
|
51
57
|
|
|
@@ -57,7 +63,19 @@ cassis ontology upload --project ... --label "release 1.2"
|
|
|
57
63
|
|
|
58
64
|
# Machine-readable output:
|
|
59
65
|
cassis ontology check --json
|
|
66
|
+
cassis ontology pull --project ... --json
|
|
60
67
|
cassis ontology upload --project ... --json
|
|
68
|
+
cassis eval run --project ... --json
|
|
69
|
+
|
|
70
|
+
# Run the eval suite against the local ontology files and wait for results
|
|
71
|
+
# (the run is labelled with your git branch name in the Evals page):
|
|
72
|
+
cassis eval run --project ...
|
|
73
|
+
|
|
74
|
+
# Run against an existing Cassis ontology branch, or the unpublished ontology:
|
|
75
|
+
cassis eval run --project ... --branch feature-x
|
|
76
|
+
|
|
77
|
+
# Start the run and return immediately (poll in the webapp):
|
|
78
|
+
cassis eval run --project ... --no-wait
|
|
61
79
|
```
|
|
62
80
|
|
|
63
81
|
Configuration (flags take precedence over env vars):
|
|
@@ -67,20 +85,29 @@ Configuration (flags take precedence over env vars):
|
|
|
67
85
|
| `--api-key` | `CASSIS_API_KEY` | — (required) |
|
|
68
86
|
| `--api-url` | `CASSIS_API_URL` | `https://app.getcassis.com` |
|
|
69
87
|
| `--base-path` | `CASSIS_BASE_PATH` | `cassis` — must match the project's git-sync "Path" setting |
|
|
70
|
-
| `--project` (upload
|
|
88
|
+
| `--project` (pull, upload, eval run) | `CASSIS_PROJECT_ID` | — (required) |
|
|
89
|
+
|
|
90
|
+
`cassis eval run` also accepts `--label` (run label in the Evals page; defaults
|
|
91
|
+
to the branch name from the CI environment or the local git checkout; rejected
|
|
92
|
+
with `--branch`, whose runs are labelled with the branch name), `--wait/--no-wait`, `--poll-interval` (5 s),
|
|
93
|
+
`--timeout` (30 min — the run keeps going server-side if the CLI stops waiting),
|
|
94
|
+
and Ctrl-C cancels the run (exit 130). It prints a deep link to the run's page
|
|
95
|
+
in the Evals UI; `--app-url` / `CASSIS_APP_URL` overrides the link's base URL
|
|
96
|
+
when the webapp is not served from the API host (defaults to `--api-url`).
|
|
71
97
|
|
|
72
98
|
### Exit codes
|
|
73
99
|
|
|
74
100
|
| Code | Meaning |
|
|
75
101
|
| ---- | ------------------------------------------------------------------------------ |
|
|
76
|
-
| 0 | Ontology is valid (check) / uploaded (upload)
|
|
77
|
-
| 1 | Validation failed (check: findings printed; upload: nothing imported)
|
|
102
|
+
| 0 | Ontology is valid (check) / pulled (pull) / uploaded (upload) / eval run completed all-passed (eval run) |
|
|
103
|
+
| 1 | Validation failed (check: findings printed; upload: nothing imported; eval run: invalid tree, failed cases, or failed/cancelled run) |
|
|
78
104
|
| 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits) |
|
|
79
|
-
| 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response) |
|
|
105
|
+
| 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another run already active, or `--timeout` reached |
|
|
80
106
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
exit 2 before uploading anything;
|
|
107
|
+
Commands that send the local tree (`check`, `upload`, `eval run`) accept up to
|
|
108
|
+
2000 YAML files / 5 MB total — far above real ontologies (a few hundred small
|
|
109
|
+
files). Beyond that the CLI fails fast with exit 2 before uploading anything;
|
|
110
|
+
double-check `--base-path` if you hit it.
|
|
84
111
|
|
|
85
112
|
`upload` replaces the project's entire ontology with the uploaded tree. A
|
|
86
113
|
never-published project always goes live immediately on first upload (even
|
|
@@ -105,6 +132,20 @@ jobs:
|
|
|
105
132
|
env:
|
|
106
133
|
CASSIS_API_KEY: ${{ secrets.CASSIS_API_KEY }}
|
|
107
134
|
|
|
135
|
+
ontology-eval:
|
|
136
|
+
runs-on: ubuntu-latest
|
|
137
|
+
if: github.event_name == 'pull_request'
|
|
138
|
+
steps:
|
|
139
|
+
- uses: actions/checkout@v4
|
|
140
|
+
- uses: actions/setup-python@v5
|
|
141
|
+
with:
|
|
142
|
+
python-version: "3.12"
|
|
143
|
+
- run: pip install cassis-cli
|
|
144
|
+
- run: cassis eval run
|
|
145
|
+
env:
|
|
146
|
+
CASSIS_API_KEY: ${{ secrets.CASSIS_API_KEY }}
|
|
147
|
+
CASSIS_PROJECT_ID: ${{ vars.CASSIS_PROJECT_ID }}
|
|
148
|
+
|
|
108
149
|
ontology-publish:
|
|
109
150
|
runs-on: ubuntu-latest
|
|
110
151
|
if: github.ref == 'refs/heads/main'
|
|
@@ -131,6 +172,17 @@ ontology-check:
|
|
|
131
172
|
variables:
|
|
132
173
|
CASSIS_API_KEY: $CASSIS_API_KEY
|
|
133
174
|
|
|
175
|
+
ontology-eval:
|
|
176
|
+
image: python:3.12-slim
|
|
177
|
+
rules:
|
|
178
|
+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
179
|
+
script:
|
|
180
|
+
- pip install cassis-cli
|
|
181
|
+
- cassis eval run
|
|
182
|
+
variables:
|
|
183
|
+
CASSIS_API_KEY: $CASSIS_API_KEY
|
|
184
|
+
CASSIS_PROJECT_ID: $CASSIS_PROJECT_ID
|
|
185
|
+
|
|
134
186
|
ontology-publish:
|
|
135
187
|
image: python:3.12-slim
|
|
136
188
|
rules:
|
|
@@ -4,6 +4,8 @@ Run Cassis actions from your CI pipelines:
|
|
|
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.
|
|
6
6
|
- `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.
|
|
7
|
+
- `cassis ontology pull` downloads the project's unpublished ontology into your repository checkout (full sync — stale local YAML files are pruned), so you can start editing from the current state, or bootstrap a repo that isn't git-synced (e.g. Bitbucket).
|
|
8
|
+
- `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.
|
|
7
9
|
|
|
8
10
|
## Install
|
|
9
11
|
|
|
@@ -15,7 +17,7 @@ pip install cassis-cli
|
|
|
15
17
|
|
|
16
18
|
1. Create an API key in Cassis under **Organization settings → API keys** (keys start with `sk-k6-`).
|
|
17
19
|
2. Store it as a CI secret and expose it as `CASSIS_API_KEY`.
|
|
18
|
-
3. For `upload`: find the project ID (UUID) in the project's URL and expose it as `CASSIS_PROJECT_ID` (or pass `--project`).
|
|
20
|
+
3. For `pull`, `upload` and `eval run`: find the project ID (UUID) in the project's URL and expose it as `CASSIS_PROJECT_ID` (or pass `--project`).
|
|
19
21
|
|
|
20
22
|
## Usage
|
|
21
23
|
|
|
@@ -26,6 +28,10 @@ cassis ontology check
|
|
|
26
28
|
# Or point at the checkout explicitly:
|
|
27
29
|
cassis ontology check /path/to/checkout
|
|
28
30
|
|
|
31
|
+
# Download the project's unpublished ontology into the checkout (full sync;
|
|
32
|
+
# review with git diff — pass --no-prune to keep local files it would delete):
|
|
33
|
+
cassis ontology pull --project 019f0000-0000-7000-8000-000000000000
|
|
34
|
+
|
|
29
35
|
# Upload the ontology to a project and publish it immediately:
|
|
30
36
|
cassis ontology upload --project 019f0000-0000-7000-8000-000000000000
|
|
31
37
|
|
|
@@ -37,7 +43,19 @@ cassis ontology upload --project ... --label "release 1.2"
|
|
|
37
43
|
|
|
38
44
|
# Machine-readable output:
|
|
39
45
|
cassis ontology check --json
|
|
46
|
+
cassis ontology pull --project ... --json
|
|
40
47
|
cassis ontology upload --project ... --json
|
|
48
|
+
cassis eval run --project ... --json
|
|
49
|
+
|
|
50
|
+
# Run the eval suite against the local ontology files and wait for results
|
|
51
|
+
# (the run is labelled with your git branch name in the Evals page):
|
|
52
|
+
cassis eval run --project ...
|
|
53
|
+
|
|
54
|
+
# Run against an existing Cassis ontology branch, or the unpublished ontology:
|
|
55
|
+
cassis eval run --project ... --branch feature-x
|
|
56
|
+
|
|
57
|
+
# Start the run and return immediately (poll in the webapp):
|
|
58
|
+
cassis eval run --project ... --no-wait
|
|
41
59
|
```
|
|
42
60
|
|
|
43
61
|
Configuration (flags take precedence over env vars):
|
|
@@ -47,20 +65,29 @@ Configuration (flags take precedence over env vars):
|
|
|
47
65
|
| `--api-key` | `CASSIS_API_KEY` | — (required) |
|
|
48
66
|
| `--api-url` | `CASSIS_API_URL` | `https://app.getcassis.com` |
|
|
49
67
|
| `--base-path` | `CASSIS_BASE_PATH` | `cassis` — must match the project's git-sync "Path" setting |
|
|
50
|
-
| `--project` (upload
|
|
68
|
+
| `--project` (pull, upload, eval run) | `CASSIS_PROJECT_ID` | — (required) |
|
|
69
|
+
|
|
70
|
+
`cassis eval run` also accepts `--label` (run label in the Evals page; defaults
|
|
71
|
+
to the branch name from the CI environment or the local git checkout; rejected
|
|
72
|
+
with `--branch`, whose runs are labelled with the branch name), `--wait/--no-wait`, `--poll-interval` (5 s),
|
|
73
|
+
`--timeout` (30 min — the run keeps going server-side if the CLI stops waiting),
|
|
74
|
+
and Ctrl-C cancels the run (exit 130). It prints a deep link to the run's page
|
|
75
|
+
in the Evals UI; `--app-url` / `CASSIS_APP_URL` overrides the link's base URL
|
|
76
|
+
when the webapp is not served from the API host (defaults to `--api-url`).
|
|
51
77
|
|
|
52
78
|
### Exit codes
|
|
53
79
|
|
|
54
80
|
| Code | Meaning |
|
|
55
81
|
| ---- | ------------------------------------------------------------------------------ |
|
|
56
|
-
| 0 | Ontology is valid (check) / uploaded (upload)
|
|
57
|
-
| 1 | Validation failed (check: findings printed; upload: nothing imported)
|
|
82
|
+
| 0 | Ontology is valid (check) / pulled (pull) / uploaded (upload) / eval run completed all-passed (eval run) |
|
|
83
|
+
| 1 | Validation failed (check: findings printed; upload: nothing imported; eval run: invalid tree, failed cases, or failed/cancelled run) |
|
|
58
84
|
| 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits) |
|
|
59
|
-
| 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response) |
|
|
85
|
+
| 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another run already active, or `--timeout` reached |
|
|
60
86
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
exit 2 before uploading anything;
|
|
87
|
+
Commands that send the local tree (`check`, `upload`, `eval run`) accept up to
|
|
88
|
+
2000 YAML files / 5 MB total — far above real ontologies (a few hundred small
|
|
89
|
+
files). Beyond that the CLI fails fast with exit 2 before uploading anything;
|
|
90
|
+
double-check `--base-path` if you hit it.
|
|
64
91
|
|
|
65
92
|
`upload` replaces the project's entire ontology with the uploaded tree. A
|
|
66
93
|
never-published project always goes live immediately on first upload (even
|
|
@@ -85,6 +112,20 @@ jobs:
|
|
|
85
112
|
env:
|
|
86
113
|
CASSIS_API_KEY: ${{ secrets.CASSIS_API_KEY }}
|
|
87
114
|
|
|
115
|
+
ontology-eval:
|
|
116
|
+
runs-on: ubuntu-latest
|
|
117
|
+
if: github.event_name == 'pull_request'
|
|
118
|
+
steps:
|
|
119
|
+
- uses: actions/checkout@v4
|
|
120
|
+
- uses: actions/setup-python@v5
|
|
121
|
+
with:
|
|
122
|
+
python-version: "3.12"
|
|
123
|
+
- run: pip install cassis-cli
|
|
124
|
+
- run: cassis eval run
|
|
125
|
+
env:
|
|
126
|
+
CASSIS_API_KEY: ${{ secrets.CASSIS_API_KEY }}
|
|
127
|
+
CASSIS_PROJECT_ID: ${{ vars.CASSIS_PROJECT_ID }}
|
|
128
|
+
|
|
88
129
|
ontology-publish:
|
|
89
130
|
runs-on: ubuntu-latest
|
|
90
131
|
if: github.ref == 'refs/heads/main'
|
|
@@ -111,6 +152,17 @@ ontology-check:
|
|
|
111
152
|
variables:
|
|
112
153
|
CASSIS_API_KEY: $CASSIS_API_KEY
|
|
113
154
|
|
|
155
|
+
ontology-eval:
|
|
156
|
+
image: python:3.12-slim
|
|
157
|
+
rules:
|
|
158
|
+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
159
|
+
script:
|
|
160
|
+
- pip install cassis-cli
|
|
161
|
+
- cassis eval run
|
|
162
|
+
variables:
|
|
163
|
+
CASSIS_API_KEY: $CASSIS_API_KEY
|
|
164
|
+
CASSIS_PROJECT_ID: $CASSIS_PROJECT_ID
|
|
165
|
+
|
|
114
166
|
ontology-publish:
|
|
115
167
|
image: python:3.12-slim
|
|
116
168
|
rules:
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Thin HTTP client for the Cassis API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
DEFAULT_API_URL = "https://app.getcassis.com"
|
|
10
|
+
TIMEOUT_SECONDS = 60.0
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ApiError(Exception):
|
|
14
|
+
"""Transport or HTTP-level failure talking to the Cassis API."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthError(ApiError):
|
|
18
|
+
"""The API rejected the API key (401)."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class UploadValidationError(ApiError):
|
|
22
|
+
"""The API rejected the uploaded ontology tree as invalid (400)."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class EvalStartValidationError(ApiError):
|
|
26
|
+
"""The API rejected the eval-run start request (400): invalid tree or no test cases.
|
|
27
|
+
|
|
28
|
+
``detail`` keeps the structured payload (``{"message", "findings"}`` for an
|
|
29
|
+
invalid tree, or a plain string) for display.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, detail: object) -> None:
|
|
33
|
+
super().__init__(str(detail))
|
|
34
|
+
self.detail = detail
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class EvalRunActiveError(ApiError):
|
|
38
|
+
"""Another eval run is already active for the project (409)."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _project_scope_error(response: httpx.Response) -> ApiError:
|
|
42
|
+
# Surface the server's own message when it names the missing resource
|
|
43
|
+
# (e.g. "Branch 'x' not found") — the generic hint covers the rest.
|
|
44
|
+
try:
|
|
45
|
+
detail = response.json().get("detail")
|
|
46
|
+
except ValueError:
|
|
47
|
+
detail = None
|
|
48
|
+
prefix = f"{detail} " if isinstance(detail, str) and detail else ""
|
|
49
|
+
return ApiError(
|
|
50
|
+
f"{prefix}(HTTP {response.status_code}). Check --project and that "
|
|
51
|
+
"the API key belongs to the project's organization and can edit it."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parse_json_response(response: httpx.Response, url: str) -> Any:
|
|
56
|
+
try:
|
|
57
|
+
return response.json()
|
|
58
|
+
except ValueError as exc: # json.JSONDecodeError — e.g. a proxy or portal answering HTML with a 200
|
|
59
|
+
raise ApiError(
|
|
60
|
+
f"The Cassis API at {url} returned a non-JSON response — check the API URL and any proxy in between."
|
|
61
|
+
) from exc
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def post_ontology_check(
|
|
65
|
+
*,
|
|
66
|
+
api_url: str,
|
|
67
|
+
api_key: str,
|
|
68
|
+
files: dict[str, str],
|
|
69
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
70
|
+
) -> dict[str, Any]:
|
|
71
|
+
"""POST the ontology tree to /api/ci/ontology-check and return the response body."""
|
|
72
|
+
url = api_url.rstrip("/") + "/api/ci/ontology-check"
|
|
73
|
+
try:
|
|
74
|
+
with httpx.Client(timeout=TIMEOUT_SECONDS, transport=transport) as client:
|
|
75
|
+
response = client.post(
|
|
76
|
+
url,
|
|
77
|
+
json={"files": files},
|
|
78
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
79
|
+
)
|
|
80
|
+
except httpx.HTTPError as exc:
|
|
81
|
+
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
82
|
+
|
|
83
|
+
if response.status_code == 401:
|
|
84
|
+
raise AuthError("The Cassis API rejected the API key (invalid or expired).")
|
|
85
|
+
if response.status_code >= 400:
|
|
86
|
+
raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
|
|
87
|
+
result = _parse_json_response(response, url)
|
|
88
|
+
if (
|
|
89
|
+
not isinstance(result, dict)
|
|
90
|
+
or not all(key in result for key in ("passed", "title", "summary"))
|
|
91
|
+
or not isinstance(result.get("findings"), list)
|
|
92
|
+
):
|
|
93
|
+
raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def post_ontology_import(
|
|
98
|
+
*,
|
|
99
|
+
api_url: str,
|
|
100
|
+
api_key: str,
|
|
101
|
+
project_id: str,
|
|
102
|
+
files: dict[str, str],
|
|
103
|
+
publish: bool,
|
|
104
|
+
label: Optional[str] = None,
|
|
105
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
106
|
+
) -> dict[str, Any]:
|
|
107
|
+
"""POST the ontology tree to /api/ci/projects/{project_id}/ontology/import and return the response body."""
|
|
108
|
+
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/ontology/import"
|
|
109
|
+
body: dict[str, Any] = {"files": files, "publish": publish}
|
|
110
|
+
if label is not None:
|
|
111
|
+
body["label"] = label
|
|
112
|
+
try:
|
|
113
|
+
with httpx.Client(timeout=TIMEOUT_SECONDS, transport=transport) as client:
|
|
114
|
+
response = client.post(
|
|
115
|
+
url,
|
|
116
|
+
json=body,
|
|
117
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
118
|
+
)
|
|
119
|
+
except httpx.HTTPError as exc:
|
|
120
|
+
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
121
|
+
|
|
122
|
+
if response.status_code == 401:
|
|
123
|
+
raise AuthError("The Cassis API rejected the API key (invalid or expired).")
|
|
124
|
+
if response.status_code == 400:
|
|
125
|
+
try:
|
|
126
|
+
detail = response.json().get("detail") or response.text[:500]
|
|
127
|
+
except ValueError:
|
|
128
|
+
detail = response.text[:500]
|
|
129
|
+
raise UploadValidationError(str(detail))
|
|
130
|
+
if response.status_code in (403, 404):
|
|
131
|
+
raise _project_scope_error(response)
|
|
132
|
+
if response.status_code >= 400:
|
|
133
|
+
raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
|
|
134
|
+
result = _parse_json_response(response, url)
|
|
135
|
+
if not isinstance(result, dict) or not all(
|
|
136
|
+
key in result for key in ("domain_count", "table_count", "join_count", "metric_count", "published_version")
|
|
137
|
+
):
|
|
138
|
+
raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
|
|
139
|
+
return result
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def get_ontology_export(
|
|
143
|
+
*,
|
|
144
|
+
api_url: str,
|
|
145
|
+
api_key: str,
|
|
146
|
+
project_id: str,
|
|
147
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
148
|
+
) -> dict[str, str]:
|
|
149
|
+
"""GET /api/ci/projects/{project_id}/ontology/export and return the files tree."""
|
|
150
|
+
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/ontology/export"
|
|
151
|
+
try:
|
|
152
|
+
with httpx.Client(timeout=TIMEOUT_SECONDS, transport=transport) as client:
|
|
153
|
+
response = client.get(url, headers={"Authorization": f"Bearer {api_key}"})
|
|
154
|
+
except httpx.HTTPError as exc:
|
|
155
|
+
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
156
|
+
|
|
157
|
+
if response.status_code == 401:
|
|
158
|
+
raise AuthError("The Cassis API rejected the API key (invalid or expired).")
|
|
159
|
+
if response.status_code in (403, 404):
|
|
160
|
+
raise _project_scope_error(response)
|
|
161
|
+
if response.status_code >= 400:
|
|
162
|
+
raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
|
|
163
|
+
result = _parse_json_response(response, url)
|
|
164
|
+
if not isinstance(result, dict) or not isinstance(result.get("files"), dict):
|
|
165
|
+
raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
|
|
166
|
+
return result["files"]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def post_eval_run_start(
|
|
170
|
+
*,
|
|
171
|
+
api_url: str,
|
|
172
|
+
api_key: str,
|
|
173
|
+
project_id: str,
|
|
174
|
+
files: Optional[dict[str, str]] = None,
|
|
175
|
+
branch: Optional[str] = None,
|
|
176
|
+
label: Optional[str] = None,
|
|
177
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
178
|
+
) -> dict[str, Any]:
|
|
179
|
+
"""POST to /api/ci/projects/{project_id}/eval/runs and return the run record."""
|
|
180
|
+
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/eval/runs"
|
|
181
|
+
body: dict[str, Any] = {}
|
|
182
|
+
if files is not None:
|
|
183
|
+
body["files"] = files
|
|
184
|
+
if branch is not None:
|
|
185
|
+
body["branch"] = branch
|
|
186
|
+
if label is not None:
|
|
187
|
+
body["label"] = label
|
|
188
|
+
try:
|
|
189
|
+
with httpx.Client(timeout=TIMEOUT_SECONDS, transport=transport) as client:
|
|
190
|
+
response = client.post(url, json=body, headers={"Authorization": f"Bearer {api_key}"})
|
|
191
|
+
except httpx.HTTPError as exc:
|
|
192
|
+
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
193
|
+
|
|
194
|
+
if response.status_code == 401:
|
|
195
|
+
raise AuthError("The Cassis API rejected the API key (invalid or expired).")
|
|
196
|
+
if response.status_code == 400:
|
|
197
|
+
try:
|
|
198
|
+
detail = response.json().get("detail") or response.text[:500]
|
|
199
|
+
except ValueError:
|
|
200
|
+
detail = response.text[:500]
|
|
201
|
+
raise EvalStartValidationError(detail)
|
|
202
|
+
if response.status_code == 409:
|
|
203
|
+
raise EvalRunActiveError(
|
|
204
|
+
"An eval run is already active for this project — wait for it to finish or cancel it "
|
|
205
|
+
"(in the webapp's Evals page, or with the run id printed when it was started)."
|
|
206
|
+
)
|
|
207
|
+
if response.status_code in (403, 404):
|
|
208
|
+
raise _project_scope_error(response)
|
|
209
|
+
if response.status_code >= 400:
|
|
210
|
+
raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
|
|
211
|
+
result = _parse_json_response(response, url)
|
|
212
|
+
if not isinstance(result, dict) or not all(key in result for key in ("run_id", "status", "total_cases")):
|
|
213
|
+
raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
|
|
214
|
+
return result
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _get_eval_json(url: str, api_key: str, transport: Optional[httpx.BaseTransport]) -> Any:
|
|
218
|
+
"""GET an eval-run URL with the shared error mapping."""
|
|
219
|
+
try:
|
|
220
|
+
with httpx.Client(timeout=TIMEOUT_SECONDS, transport=transport) as client:
|
|
221
|
+
response = client.get(url, headers={"Authorization": f"Bearer {api_key}"})
|
|
222
|
+
except httpx.HTTPError as exc:
|
|
223
|
+
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
224
|
+
|
|
225
|
+
if response.status_code == 401:
|
|
226
|
+
raise AuthError("The Cassis API rejected the API key (invalid or expired).")
|
|
227
|
+
if response.status_code in (403, 404):
|
|
228
|
+
raise _project_scope_error(response)
|
|
229
|
+
if response.status_code >= 400:
|
|
230
|
+
raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
|
|
231
|
+
return _parse_json_response(response, url)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def get_eval_run(
|
|
235
|
+
*,
|
|
236
|
+
api_url: str,
|
|
237
|
+
api_key: str,
|
|
238
|
+
project_id: str,
|
|
239
|
+
run_id: str,
|
|
240
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
241
|
+
) -> dict[str, Any]:
|
|
242
|
+
"""GET /api/ci/projects/{project_id}/eval/runs/{run_id} and return the run record."""
|
|
243
|
+
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/eval/runs/{run_id}"
|
|
244
|
+
result = _get_eval_json(url, api_key, transport)
|
|
245
|
+
if not isinstance(result, dict) or not all(key in result for key in ("run_id", "status", "total_cases")):
|
|
246
|
+
raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
|
|
247
|
+
return result
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def get_eval_run_results(
|
|
251
|
+
*,
|
|
252
|
+
api_url: str,
|
|
253
|
+
api_key: str,
|
|
254
|
+
project_id: str,
|
|
255
|
+
run_id: str,
|
|
256
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
257
|
+
) -> list[dict[str, Any]]:
|
|
258
|
+
"""GET /api/ci/projects/{project_id}/eval/runs/{run_id}/results and return the result list."""
|
|
259
|
+
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/eval/runs/{run_id}/results"
|
|
260
|
+
result = _get_eval_json(url, api_key, transport)
|
|
261
|
+
if not isinstance(result, list):
|
|
262
|
+
raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
|
|
263
|
+
return result
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def post_eval_run_cancel(
|
|
267
|
+
*,
|
|
268
|
+
api_url: str,
|
|
269
|
+
api_key: str,
|
|
270
|
+
project_id: str,
|
|
271
|
+
run_id: str,
|
|
272
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
273
|
+
) -> None:
|
|
274
|
+
"""POST /api/ci/projects/{project_id}/eval/runs/{run_id}/cancel."""
|
|
275
|
+
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/eval/runs/{run_id}/cancel"
|
|
276
|
+
try:
|
|
277
|
+
with httpx.Client(timeout=TIMEOUT_SECONDS, transport=transport) as client:
|
|
278
|
+
response = client.post(url, headers={"Authorization": f"Bearer {api_key}"})
|
|
279
|
+
except httpx.HTTPError as exc:
|
|
280
|
+
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
281
|
+
if response.status_code == 401:
|
|
282
|
+
raise AuthError("The Cassis API rejected the API key (invalid or expired).")
|
|
283
|
+
if response.status_code in (403, 404):
|
|
284
|
+
raise _project_scope_error(response)
|
|
285
|
+
if response.status_code >= 400:
|
|
286
|
+
raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Helpers shared by the `cassis` subcommands (tree collection, auth, exit codes)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
# Repository directory the ontology tree is exported under. Must match the
|
|
11
|
+
# project's git-sync "Path" setting in Cassis (default "cassis").
|
|
12
|
+
DEFAULT_BASE_PATH = "cassis"
|
|
13
|
+
|
|
14
|
+
# Exit codes (documented in the README; stable contract for CI scripts).
|
|
15
|
+
EXIT_OK = 0
|
|
16
|
+
EXIT_VALIDATION_FAILED = 1
|
|
17
|
+
EXIT_USAGE = 2
|
|
18
|
+
EXIT_TRANSPORT = 3
|
|
19
|
+
|
|
20
|
+
# Request ceilings of the /api/ci file-tree endpoints, mirrored so oversized
|
|
21
|
+
# trees fail fast with a clear message before any upload. Source of truth:
|
|
22
|
+
# backend/app/schemas/ci.py (the server's 422 remains the backstop).
|
|
23
|
+
MAX_FILES = 2000
|
|
24
|
+
MAX_TOTAL_BYTES = 5 * 1024 * 1024
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def collect_files(ontology_dir: Path) -> dict[str, str]:
|
|
28
|
+
"""Read every YAML file under the ontology dir, keyed by posix relpath.
|
|
29
|
+
|
|
30
|
+
Exits 2 (usage) on an unreadable or non-UTF-8 file — a local checkout
|
|
31
|
+
problem, reported before anything is sent to the API.
|
|
32
|
+
"""
|
|
33
|
+
files: dict[str, str] = {}
|
|
34
|
+
for pattern in ("**/*.yml", "**/*.yaml"):
|
|
35
|
+
for file in sorted(ontology_dir.glob(pattern)):
|
|
36
|
+
if file.is_file():
|
|
37
|
+
try:
|
|
38
|
+
files[file.relative_to(ontology_dir).as_posix()] = file.read_text(encoding="utf-8")
|
|
39
|
+
except (UnicodeDecodeError, OSError) as exc:
|
|
40
|
+
typer.secho(f"Cannot read {file}: {exc}", fg=typer.colors.RED, err=True)
|
|
41
|
+
raise typer.Exit(EXIT_USAGE) from exc
|
|
42
|
+
return files
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def require_api_key(api_key: Optional[str]) -> str:
|
|
46
|
+
"""Exit 2 (usage) when no API key was provided."""
|
|
47
|
+
if not api_key:
|
|
48
|
+
typer.secho(
|
|
49
|
+
"No API key. Set CASSIS_API_KEY or pass --api-key "
|
|
50
|
+
"(create one in Cassis under Organization settings -> API keys).",
|
|
51
|
+
fg=typer.colors.RED,
|
|
52
|
+
err=True,
|
|
53
|
+
)
|
|
54
|
+
raise typer.Exit(EXIT_USAGE)
|
|
55
|
+
return api_key
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def collect_tree(path: Path, base_path: str) -> "tuple[dict[str, str], str]":
|
|
59
|
+
"""Resolve the ontology dir under the checkout and read its YAML tree.
|
|
60
|
+
|
|
61
|
+
Returns ``(files, normalized_base_path)``. Exits 2 (usage) on an empty or
|
|
62
|
+
missing dir, or a tree beyond the API request ceilings — all local checkout
|
|
63
|
+
problems, reported before anything is sent to the API.
|
|
64
|
+
"""
|
|
65
|
+
base_path = base_path.strip().strip("/")
|
|
66
|
+
if not base_path:
|
|
67
|
+
typer.secho("--base-path must not be empty.", fg=typer.colors.RED, err=True)
|
|
68
|
+
raise typer.Exit(EXIT_USAGE)
|
|
69
|
+
|
|
70
|
+
ontology_dir = path / Path(base_path)
|
|
71
|
+
if not ontology_dir.is_dir():
|
|
72
|
+
typer.secho(
|
|
73
|
+
f"No {base_path}/ directory found under {path}. "
|
|
74
|
+
"If the project exports to a custom path, pass it with --base-path (or CASSIS_BASE_PATH).",
|
|
75
|
+
fg=typer.colors.RED,
|
|
76
|
+
err=True,
|
|
77
|
+
)
|
|
78
|
+
raise typer.Exit(EXIT_USAGE)
|
|
79
|
+
|
|
80
|
+
files = collect_files(ontology_dir)
|
|
81
|
+
if not files:
|
|
82
|
+
typer.secho(f"No YAML files found under {ontology_dir}.", fg=typer.colors.RED, err=True)
|
|
83
|
+
raise typer.Exit(EXIT_USAGE)
|
|
84
|
+
|
|
85
|
+
total_bytes = sum(len(content.encode()) for content in files.values())
|
|
86
|
+
if len(files) > MAX_FILES or total_bytes > MAX_TOTAL_BYTES:
|
|
87
|
+
typer.secho(
|
|
88
|
+
f"Ontology tree too large: {len(files)} files / {total_bytes / (1024 * 1024):.1f} MB "
|
|
89
|
+
f"(limits: {MAX_FILES} files / {MAX_TOTAL_BYTES // (1024 * 1024)} MB). "
|
|
90
|
+
"Check that --base-path points at the ontology directory, not a larger tree.",
|
|
91
|
+
fg=typer.colors.RED,
|
|
92
|
+
err=True,
|
|
93
|
+
)
|
|
94
|
+
raise typer.Exit(EXIT_USAGE)
|
|
95
|
+
return files, base_path
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""`cassis eval` subcommands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
from uuid import UUID
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from cassis_cli.api import (
|
|
15
|
+
DEFAULT_API_URL,
|
|
16
|
+
ApiError,
|
|
17
|
+
AuthError,
|
|
18
|
+
EvalRunActiveError,
|
|
19
|
+
EvalStartValidationError,
|
|
20
|
+
get_eval_run,
|
|
21
|
+
get_eval_run_results,
|
|
22
|
+
post_eval_run_cancel,
|
|
23
|
+
post_eval_run_start,
|
|
24
|
+
)
|
|
25
|
+
from cassis_cli.common import (
|
|
26
|
+
DEFAULT_BASE_PATH,
|
|
27
|
+
EXIT_OK,
|
|
28
|
+
EXIT_TRANSPORT,
|
|
29
|
+
EXIT_USAGE,
|
|
30
|
+
EXIT_VALIDATION_FAILED,
|
|
31
|
+
collect_tree,
|
|
32
|
+
require_api_key,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
app = typer.Typer(no_args_is_help=True, help="Eval commands.")
|
|
36
|
+
|
|
37
|
+
EXIT_INTERRUPTED = 130
|
|
38
|
+
|
|
39
|
+
# Result statuses that count as "passed"; everything else is a failure or error.
|
|
40
|
+
_PASSED = "passed"
|
|
41
|
+
_TERMINAL_RUN_STATUSES = {"completed", "failed", "cancelled"}
|
|
42
|
+
_STATUS_COLORS = {
|
|
43
|
+
"passed": typer.colors.GREEN,
|
|
44
|
+
"failed": typer.colors.RED,
|
|
45
|
+
"error": typer.colors.RED,
|
|
46
|
+
"gold_sql_error": typer.colors.YELLOW,
|
|
47
|
+
"missing_concept": typer.colors.YELLOW,
|
|
48
|
+
"plan_unexecutable": typer.colors.YELLOW,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _run_page_url(app_url: str, project_id: str, run_id: str) -> str:
|
|
53
|
+
"""Webapp URL of the run's detail view (Evals page, runs tab)."""
|
|
54
|
+
return f"{app_url.rstrip('/')}/eval?projectId={project_id}&tab=runs&runId={run_id}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# CI checkouts are usually detached HEAD (`git rev-parse` says "HEAD"), but
|
|
58
|
+
# the CI systems expose the branch through env vars — checked first so PR/MR
|
|
59
|
+
# eval runs get their branch label, the feature's primary use case.
|
|
60
|
+
_CI_BRANCH_ENV_VARS = (
|
|
61
|
+
"GITHUB_HEAD_REF", # GitHub Actions, pull_request events
|
|
62
|
+
"GITHUB_REF_NAME", # GitHub Actions, branch pushes
|
|
63
|
+
"CI_MERGE_REQUEST_SOURCE_BRANCH_NAME", # GitLab CI, merge_request pipelines
|
|
64
|
+
"CI_COMMIT_REF_NAME", # GitLab CI, branch pipelines
|
|
65
|
+
"BITBUCKET_BRANCH", # Bitbucket Pipelines
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _git_branch(path: Path) -> Optional[str]:
|
|
70
|
+
"""Return the checkout's branch name: CI env vars first, then git.
|
|
71
|
+
|
|
72
|
+
None outside a repo or on a detached HEAD with no CI env var.
|
|
73
|
+
"""
|
|
74
|
+
for var in _CI_BRANCH_ENV_VARS:
|
|
75
|
+
value = os.environ.get(var, "").strip()
|
|
76
|
+
if value:
|
|
77
|
+
return value
|
|
78
|
+
try:
|
|
79
|
+
proc = subprocess.run(
|
|
80
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
81
|
+
cwd=path,
|
|
82
|
+
capture_output=True,
|
|
83
|
+
text=True,
|
|
84
|
+
timeout=5,
|
|
85
|
+
)
|
|
86
|
+
# Two separate handlers: the repo-wide black targets py314 and would strip
|
|
87
|
+
# the parens off a tuple form, which is a SyntaxError on the CLI's
|
|
88
|
+
# supported Python (>=3.10).
|
|
89
|
+
except OSError:
|
|
90
|
+
return None
|
|
91
|
+
except subprocess.TimeoutExpired:
|
|
92
|
+
return None
|
|
93
|
+
branch = proc.stdout.strip()
|
|
94
|
+
if proc.returncode != 0 or not branch or branch == "HEAD":
|
|
95
|
+
return None
|
|
96
|
+
return branch
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _print_validation_failure(detail: object, base_path: str) -> None:
|
|
100
|
+
"""Print a structured 400 from the start endpoint (invalid tree findings, or a plain message)."""
|
|
101
|
+
if isinstance(detail, dict) and isinstance(detail.get("findings"), list):
|
|
102
|
+
typer.secho("Ontology validation failed:", fg=typer.colors.RED, bold=True, err=True)
|
|
103
|
+
message = detail.get("message")
|
|
104
|
+
if message:
|
|
105
|
+
typer.echo(message, err=True)
|
|
106
|
+
for finding in detail["findings"]:
|
|
107
|
+
location = f"{base_path}/{finding.get('path')}: " if finding.get("path") else ""
|
|
108
|
+
typer.echo(f" {location}{finding.get('message', '')} ({finding.get('stage', '?')})", err=True)
|
|
109
|
+
else:
|
|
110
|
+
typer.secho(str(detail), fg=typer.colors.RED, err=True)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _print_results_table(results: list[dict[str, Any]]) -> None:
|
|
114
|
+
for r in sorted(results, key=lambda r: (r.get("status") == _PASSED, r.get("question") or "")):
|
|
115
|
+
status = r.get("status", "?")
|
|
116
|
+
color = _STATUS_COLORS.get(status, typer.colors.WHITE)
|
|
117
|
+
icon = "✓" if status == _PASSED else "✗"
|
|
118
|
+
duration = f"{r['duration_seconds']:.0f}s" if r.get("duration_seconds") is not None else "-"
|
|
119
|
+
question = (r.get("question") or "").replace("\n", " ")
|
|
120
|
+
if len(question) > 70:
|
|
121
|
+
question = question[:67] + "..."
|
|
122
|
+
line = f" {icon} {status:<17} {duration:>5} {question}"
|
|
123
|
+
typer.secho(line, fg=color)
|
|
124
|
+
if r.get("error"):
|
|
125
|
+
typer.echo(f" {str(r['error'])[:200]}")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _print_summary(run: dict[str, Any]) -> None:
|
|
129
|
+
summary = run.get("summary") or {}
|
|
130
|
+
total = summary.get("total", run.get("total_cases"))
|
|
131
|
+
passed = summary.get("passed", 0)
|
|
132
|
+
accuracy = summary.get("accuracy")
|
|
133
|
+
parts = [f"{passed}/{total} passed"]
|
|
134
|
+
if accuracy is not None:
|
|
135
|
+
parts.append(f"accuracy {accuracy:.0%}")
|
|
136
|
+
timing = summary.get("timing") or {}
|
|
137
|
+
if timing.get("p50") is not None:
|
|
138
|
+
parts.append(f"p50 {timing['p50']:.0f}s")
|
|
139
|
+
typer.echo(", ".join(parts))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@app.command()
|
|
143
|
+
def run(
|
|
144
|
+
path: Path = typer.Argument(
|
|
145
|
+
Path("."),
|
|
146
|
+
help="Repository checkout root (the directory containing the ontology export path).",
|
|
147
|
+
),
|
|
148
|
+
project_id: str = typer.Option(
|
|
149
|
+
...,
|
|
150
|
+
"--project",
|
|
151
|
+
envvar="CASSIS_PROJECT_ID",
|
|
152
|
+
help="Target Cassis project ID (UUID, shown in the project's URL).",
|
|
153
|
+
),
|
|
154
|
+
api_key: Optional[str] = typer.Option(
|
|
155
|
+
None,
|
|
156
|
+
"--api-key",
|
|
157
|
+
envvar="CASSIS_API_KEY",
|
|
158
|
+
help="Cassis API key (sk-k6-...). Create one in Organization settings -> API keys.",
|
|
159
|
+
),
|
|
160
|
+
api_url: str = typer.Option(
|
|
161
|
+
DEFAULT_API_URL,
|
|
162
|
+
"--api-url",
|
|
163
|
+
envvar="CASSIS_API_URL",
|
|
164
|
+
help="Cassis API base URL.",
|
|
165
|
+
),
|
|
166
|
+
base_path: str = typer.Option(
|
|
167
|
+
DEFAULT_BASE_PATH,
|
|
168
|
+
"--base-path",
|
|
169
|
+
envvar="CASSIS_BASE_PATH",
|
|
170
|
+
help="Repository directory the ontology is exported under (the project's git-sync Path setting).",
|
|
171
|
+
),
|
|
172
|
+
branch: Optional[str] = typer.Option(
|
|
173
|
+
None,
|
|
174
|
+
"--branch",
|
|
175
|
+
help="Run against an existing Cassis ontology branch by name instead of local files.",
|
|
176
|
+
),
|
|
177
|
+
label: Optional[str] = typer.Option(
|
|
178
|
+
None,
|
|
179
|
+
"--label",
|
|
180
|
+
help="Run label shown in the webapp's Evals page (default: the local git branch name).",
|
|
181
|
+
),
|
|
182
|
+
wait: bool = typer.Option(
|
|
183
|
+
True,
|
|
184
|
+
"--wait/--no-wait",
|
|
185
|
+
help="Wait for the run to finish and print results (default), or just print the run id.",
|
|
186
|
+
),
|
|
187
|
+
poll_interval: float = typer.Option(5.0, "--poll-interval", help="Seconds between status polls."),
|
|
188
|
+
timeout: float = typer.Option(1800.0, "--timeout", help="Give up waiting after this many seconds."),
|
|
189
|
+
json_output: bool = typer.Option(False, "--json", help="Print the final run and results as raw JSON."),
|
|
190
|
+
app_url: Optional[str] = typer.Option(
|
|
191
|
+
None,
|
|
192
|
+
"--app-url",
|
|
193
|
+
envvar="CASSIS_APP_URL",
|
|
194
|
+
help="Cassis webapp base URL, used for the run-details link (default: the API URL).",
|
|
195
|
+
),
|
|
196
|
+
) -> None:
|
|
197
|
+
"""Run the project's eval suite against your local ontology files.
|
|
198
|
+
|
|
199
|
+
Uploads the local YAML tree and scores it in-memory — nothing is pushed or
|
|
200
|
+
persisted in Cassis besides the eval run itself. With --branch, runs against
|
|
201
|
+
an existing Cassis branch instead (no files are sent). Exits 0 when the run
|
|
202
|
+
completes with every case passed, 1 on any failed case / failed run /
|
|
203
|
+
invalid tree, 2 on usage errors, 3 on transport errors or --timeout.
|
|
204
|
+
"""
|
|
205
|
+
api_key = require_api_key(api_key)
|
|
206
|
+
try:
|
|
207
|
+
UUID(project_id)
|
|
208
|
+
except ValueError:
|
|
209
|
+
typer.secho(f"--project must be a project ID (UUID), got {project_id!r}.", fg=typer.colors.RED, err=True)
|
|
210
|
+
raise typer.Exit(EXIT_USAGE)
|
|
211
|
+
if branch is not None and label is not None:
|
|
212
|
+
typer.secho(
|
|
213
|
+
"--label cannot be used with --branch: branch runs are labelled with the branch name.",
|
|
214
|
+
fg=typer.colors.RED,
|
|
215
|
+
err=True,
|
|
216
|
+
)
|
|
217
|
+
raise typer.Exit(EXIT_USAGE)
|
|
218
|
+
|
|
219
|
+
files: Optional[dict[str, str]] = None
|
|
220
|
+
if branch is None:
|
|
221
|
+
files, base_path = collect_tree(path, base_path)
|
|
222
|
+
if label is None:
|
|
223
|
+
label = _git_branch(path)
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
run_record = post_eval_run_start(
|
|
227
|
+
api_url=api_url,
|
|
228
|
+
api_key=api_key,
|
|
229
|
+
project_id=project_id,
|
|
230
|
+
files=files,
|
|
231
|
+
branch=branch,
|
|
232
|
+
label=label,
|
|
233
|
+
)
|
|
234
|
+
except EvalStartValidationError as exc:
|
|
235
|
+
_print_validation_failure(exc.detail, base_path)
|
|
236
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED) from exc
|
|
237
|
+
except ApiError as exc: # covers AuthError and EvalRunActiveError too
|
|
238
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
239
|
+
raise typer.Exit(EXIT_TRANSPORT) from exc
|
|
240
|
+
|
|
241
|
+
run_id = str(run_record["run_id"])
|
|
242
|
+
total = run_record.get("total_cases", 0)
|
|
243
|
+
ontology_label = run_record.get("ontology_label") or "unpublished ontology"
|
|
244
|
+
run_url = _run_page_url(app_url or api_url, project_id, run_id)
|
|
245
|
+
|
|
246
|
+
if not wait:
|
|
247
|
+
if json_output:
|
|
248
|
+
typer.echo(json.dumps({"run": run_record, "run_url": run_url}, indent=2))
|
|
249
|
+
else:
|
|
250
|
+
typer.echo(f"Eval run {run_id} started: {total} cases against {ontology_label!r}.")
|
|
251
|
+
typer.echo(f"Follow it at: {run_url}")
|
|
252
|
+
raise typer.Exit(EXIT_OK)
|
|
253
|
+
|
|
254
|
+
typer.echo(f"Eval run {run_id} started: {total} cases against {ontology_label!r}.")
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
final_run, results = _wait_for_run(
|
|
258
|
+
api_url=api_url,
|
|
259
|
+
api_key=api_key,
|
|
260
|
+
project_id=project_id,
|
|
261
|
+
run_id=run_id,
|
|
262
|
+
total=total,
|
|
263
|
+
poll_interval=poll_interval,
|
|
264
|
+
timeout=timeout,
|
|
265
|
+
)
|
|
266
|
+
except KeyboardInterrupt:
|
|
267
|
+
typer.echo("")
|
|
268
|
+
typer.echo("Interrupted — cancelling the run...")
|
|
269
|
+
try:
|
|
270
|
+
post_eval_run_cancel(api_url=api_url, api_key=api_key, project_id=project_id, run_id=run_id)
|
|
271
|
+
typer.echo("Run cancelled.")
|
|
272
|
+
except ApiError as exc:
|
|
273
|
+
typer.secho(f"Could not cancel the run: {exc}", fg=typer.colors.RED, err=True)
|
|
274
|
+
raise typer.Exit(EXIT_INTERRUPTED)
|
|
275
|
+
|
|
276
|
+
if json_output:
|
|
277
|
+
typer.echo(json.dumps({"run": final_run, "results": results, "run_url": run_url}, indent=2))
|
|
278
|
+
else:
|
|
279
|
+
typer.echo("")
|
|
280
|
+
_print_results_table(results)
|
|
281
|
+
typer.echo("")
|
|
282
|
+
_print_summary(final_run)
|
|
283
|
+
typer.echo(f"View details: {run_url}")
|
|
284
|
+
|
|
285
|
+
status = final_run.get("status")
|
|
286
|
+
if status == "completed" and all(r.get("status") == _PASSED for r in results):
|
|
287
|
+
raise typer.Exit(EXIT_OK)
|
|
288
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# Consecutive poll failures tolerated before giving up: covers transient
|
|
292
|
+
# blips (LB hiccup, brief network loss) without letting a permanently-broken
|
|
293
|
+
# poll (deleted run/project) spin until --timeout.
|
|
294
|
+
_MAX_CONSECUTIVE_POLL_FAILURES = 5
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _wait_for_run(
|
|
298
|
+
*,
|
|
299
|
+
api_url: str,
|
|
300
|
+
api_key: str,
|
|
301
|
+
project_id: str,
|
|
302
|
+
run_id: str,
|
|
303
|
+
total: int,
|
|
304
|
+
poll_interval: float,
|
|
305
|
+
timeout: float,
|
|
306
|
+
) -> "tuple[dict[str, Any], list[dict[str, Any]]]":
|
|
307
|
+
"""Poll the run until terminal. Returns (run, results).
|
|
308
|
+
|
|
309
|
+
Exits 3 directly on timeout, on an auth failure (fail fast — retrying a
|
|
310
|
+
revoked key can't succeed), or after several consecutive poll failures.
|
|
311
|
+
The server-side run keeps going in all three cases.
|
|
312
|
+
"""
|
|
313
|
+
deadline = time.monotonic() + timeout
|
|
314
|
+
last_line = ""
|
|
315
|
+
failures = 0
|
|
316
|
+
while time.monotonic() < deadline:
|
|
317
|
+
try:
|
|
318
|
+
run_record = get_eval_run(api_url=api_url, api_key=api_key, project_id=project_id, run_id=run_id)
|
|
319
|
+
results = get_eval_run_results(api_url=api_url, api_key=api_key, project_id=project_id, run_id=run_id)
|
|
320
|
+
except AuthError as exc:
|
|
321
|
+
typer.secho(f"{exc} The run keeps going server-side.", fg=typer.colors.RED, err=True)
|
|
322
|
+
raise typer.Exit(EXIT_TRANSPORT) from exc
|
|
323
|
+
except ApiError as exc:
|
|
324
|
+
failures += 1
|
|
325
|
+
if failures >= _MAX_CONSECUTIVE_POLL_FAILURES:
|
|
326
|
+
typer.secho(
|
|
327
|
+
f"Polling failed {failures} times in a row ({exc}). "
|
|
328
|
+
"Giving up — the run keeps going server-side; see the webapp's Evals page.",
|
|
329
|
+
fg=typer.colors.RED,
|
|
330
|
+
err=True,
|
|
331
|
+
)
|
|
332
|
+
raise typer.Exit(EXIT_TRANSPORT) from exc
|
|
333
|
+
# A transient poll failure shouldn't kill a multi-minute run; keep waiting.
|
|
334
|
+
typer.secho(f"(poll failed, retrying: {exc})", fg=typer.colors.YELLOW, err=True)
|
|
335
|
+
time.sleep(poll_interval)
|
|
336
|
+
continue
|
|
337
|
+
failures = 0
|
|
338
|
+
|
|
339
|
+
done = len(results)
|
|
340
|
+
passed = sum(1 for r in results if r.get("status") == _PASSED)
|
|
341
|
+
line = f"{done}/{total} cases done — {passed} ✓ {done - passed} ✗"
|
|
342
|
+
if line != last_line:
|
|
343
|
+
typer.echo(line)
|
|
344
|
+
last_line = line
|
|
345
|
+
|
|
346
|
+
if run_record.get("status") in _TERMINAL_RUN_STATUSES:
|
|
347
|
+
return run_record, results
|
|
348
|
+
time.sleep(poll_interval)
|
|
349
|
+
|
|
350
|
+
typer.secho(
|
|
351
|
+
f"Timed out after {timeout:.0f}s waiting for run {run_id}. "
|
|
352
|
+
"The run keeps going server-side — see the webapp's Evals page.",
|
|
353
|
+
fg=typer.colors.YELLOW,
|
|
354
|
+
err=True,
|
|
355
|
+
)
|
|
356
|
+
raise typer.Exit(EXIT_TRANSPORT)
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import typer
|
|
6
6
|
from cassis_cli import __version__
|
|
7
|
+
from cassis_cli.eval import app as eval_app
|
|
7
8
|
from cassis_cli.ontology import app as ontology_app
|
|
8
9
|
|
|
9
10
|
app = typer.Typer(
|
|
@@ -11,6 +12,7 @@ app = typer.Typer(
|
|
|
11
12
|
help="Cassis CLI — run Cassis actions from your CI pipelines.",
|
|
12
13
|
)
|
|
13
14
|
app.add_typer(ontology_app, name="ontology")
|
|
15
|
+
app.add_typer(eval_app, name="eval")
|
|
14
16
|
|
|
15
17
|
|
|
16
18
|
@app.command()
|
|
@@ -13,99 +13,23 @@ from cassis_cli.api import (
|
|
|
13
13
|
ApiError,
|
|
14
14
|
AuthError,
|
|
15
15
|
UploadValidationError,
|
|
16
|
+
get_ontology_export,
|
|
16
17
|
post_ontology_check,
|
|
17
18
|
post_ontology_import,
|
|
18
19
|
)
|
|
20
|
+
from cassis_cli.common import (
|
|
21
|
+
DEFAULT_BASE_PATH,
|
|
22
|
+
EXIT_OK,
|
|
23
|
+
EXIT_TRANSPORT,
|
|
24
|
+
EXIT_USAGE,
|
|
25
|
+
EXIT_VALIDATION_FAILED,
|
|
26
|
+
)
|
|
27
|
+
from cassis_cli.common import collect_files as _collect_files
|
|
28
|
+
from cassis_cli.common import collect_tree as _collect_tree
|
|
29
|
+
from cassis_cli.common import require_api_key as _require_api_key
|
|
19
30
|
|
|
20
31
|
app = typer.Typer(no_args_is_help=True, help="Ontology commands.")
|
|
21
32
|
|
|
22
|
-
# Repository directory the ontology tree is exported under. Must match the
|
|
23
|
-
# project's git-sync "Path" setting in Cassis (default "cassis").
|
|
24
|
-
DEFAULT_BASE_PATH = "cassis"
|
|
25
|
-
|
|
26
|
-
# Exit codes (documented in the README; stable contract for CI scripts).
|
|
27
|
-
EXIT_OK = 0
|
|
28
|
-
EXIT_VALIDATION_FAILED = 1
|
|
29
|
-
EXIT_USAGE = 2
|
|
30
|
-
EXIT_TRANSPORT = 3
|
|
31
|
-
|
|
32
|
-
# Request ceilings of POST /api/ci/ontology-check, mirrored so oversized trees
|
|
33
|
-
# fail fast with a clear message before any upload. Source of truth:
|
|
34
|
-
# backend/app/schemas/ci.py (the server's 422 remains the backstop).
|
|
35
|
-
MAX_FILES = 2000
|
|
36
|
-
MAX_TOTAL_BYTES = 5 * 1024 * 1024
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def _collect_files(ontology_dir: Path) -> dict[str, str]:
|
|
40
|
-
"""Read every YAML file under the ontology dir, keyed by posix relpath.
|
|
41
|
-
|
|
42
|
-
Exits 2 (usage) on an unreadable or non-UTF-8 file — a local checkout
|
|
43
|
-
problem, reported before anything is sent to the API.
|
|
44
|
-
"""
|
|
45
|
-
files: dict[str, str] = {}
|
|
46
|
-
for pattern in ("**/*.yml", "**/*.yaml"):
|
|
47
|
-
for file in sorted(ontology_dir.glob(pattern)):
|
|
48
|
-
if file.is_file():
|
|
49
|
-
try:
|
|
50
|
-
files[file.relative_to(ontology_dir).as_posix()] = file.read_text(encoding="utf-8")
|
|
51
|
-
except (UnicodeDecodeError, OSError) as exc:
|
|
52
|
-
typer.secho(f"Cannot read {file}: {exc}", fg=typer.colors.RED, err=True)
|
|
53
|
-
raise typer.Exit(EXIT_USAGE) from exc
|
|
54
|
-
return files
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def _require_api_key(api_key: Optional[str]) -> str:
|
|
58
|
-
"""Exit 2 (usage) when no API key was provided."""
|
|
59
|
-
if not api_key:
|
|
60
|
-
typer.secho(
|
|
61
|
-
"No API key. Set CASSIS_API_KEY or pass --api-key "
|
|
62
|
-
"(create one in Cassis under Organization settings -> API keys).",
|
|
63
|
-
fg=typer.colors.RED,
|
|
64
|
-
err=True,
|
|
65
|
-
)
|
|
66
|
-
raise typer.Exit(EXIT_USAGE)
|
|
67
|
-
return api_key
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def _collect_tree(path: Path, base_path: str) -> "tuple[dict[str, str], str]":
|
|
71
|
-
"""Resolve the ontology dir under the checkout and read its YAML tree.
|
|
72
|
-
|
|
73
|
-
Returns ``(files, normalized_base_path)``. Exits 2 (usage) on an empty or
|
|
74
|
-
missing dir, or a tree beyond the API request ceilings — all local checkout
|
|
75
|
-
problems, reported before anything is sent to the API.
|
|
76
|
-
"""
|
|
77
|
-
base_path = base_path.strip().strip("/")
|
|
78
|
-
if not base_path:
|
|
79
|
-
typer.secho("--base-path must not be empty.", fg=typer.colors.RED, err=True)
|
|
80
|
-
raise typer.Exit(EXIT_USAGE)
|
|
81
|
-
|
|
82
|
-
ontology_dir = path / Path(base_path)
|
|
83
|
-
if not ontology_dir.is_dir():
|
|
84
|
-
typer.secho(
|
|
85
|
-
f"No {base_path}/ directory found under {path}. "
|
|
86
|
-
"If the project exports to a custom path, pass it with --base-path (or CASSIS_BASE_PATH).",
|
|
87
|
-
fg=typer.colors.RED,
|
|
88
|
-
err=True,
|
|
89
|
-
)
|
|
90
|
-
raise typer.Exit(EXIT_USAGE)
|
|
91
|
-
|
|
92
|
-
files = _collect_files(ontology_dir)
|
|
93
|
-
if not files:
|
|
94
|
-
typer.secho(f"No YAML files found under {ontology_dir}.", fg=typer.colors.RED, err=True)
|
|
95
|
-
raise typer.Exit(EXIT_USAGE)
|
|
96
|
-
|
|
97
|
-
total_bytes = sum(len(content.encode()) for content in files.values())
|
|
98
|
-
if len(files) > MAX_FILES or total_bytes > MAX_TOTAL_BYTES:
|
|
99
|
-
typer.secho(
|
|
100
|
-
f"Ontology tree too large: {len(files)} files / {total_bytes / (1024 * 1024):.1f} MB "
|
|
101
|
-
f"(limits: {MAX_FILES} files / {MAX_TOTAL_BYTES // (1024 * 1024)} MB). "
|
|
102
|
-
"Check that --base-path points at the ontology directory, not a larger tree.",
|
|
103
|
-
fg=typer.colors.RED,
|
|
104
|
-
err=True,
|
|
105
|
-
)
|
|
106
|
-
raise typer.Exit(EXIT_USAGE)
|
|
107
|
-
return files, base_path
|
|
108
|
-
|
|
109
33
|
|
|
110
34
|
@app.command()
|
|
111
35
|
def check(
|
|
@@ -165,6 +89,109 @@ def check(
|
|
|
165
89
|
raise typer.Exit(EXIT_OK if result["passed"] else EXIT_VALIDATION_FAILED)
|
|
166
90
|
|
|
167
91
|
|
|
92
|
+
@app.command()
|
|
93
|
+
def pull(
|
|
94
|
+
path: Path = typer.Argument(
|
|
95
|
+
Path("."),
|
|
96
|
+
help="Repository checkout root (the directory containing the ontology export path).",
|
|
97
|
+
),
|
|
98
|
+
project_id: str = typer.Option(
|
|
99
|
+
...,
|
|
100
|
+
"--project",
|
|
101
|
+
envvar="CASSIS_PROJECT_ID",
|
|
102
|
+
help="Source Cassis project ID (UUID, shown in the project's URL).",
|
|
103
|
+
),
|
|
104
|
+
api_key: Optional[str] = typer.Option(
|
|
105
|
+
None,
|
|
106
|
+
"--api-key",
|
|
107
|
+
envvar="CASSIS_API_KEY",
|
|
108
|
+
help="Cassis API key (sk-k6-...). Create one in Organization settings -> API keys.",
|
|
109
|
+
),
|
|
110
|
+
api_url: str = typer.Option(
|
|
111
|
+
DEFAULT_API_URL,
|
|
112
|
+
"--api-url",
|
|
113
|
+
envvar="CASSIS_API_URL",
|
|
114
|
+
help="Cassis API base URL.",
|
|
115
|
+
),
|
|
116
|
+
base_path: str = typer.Option(
|
|
117
|
+
DEFAULT_BASE_PATH,
|
|
118
|
+
"--base-path",
|
|
119
|
+
envvar="CASSIS_BASE_PATH",
|
|
120
|
+
help="Repository directory the ontology is exported under (the project's git-sync Path setting).",
|
|
121
|
+
),
|
|
122
|
+
prune: bool = typer.Option(
|
|
123
|
+
True,
|
|
124
|
+
"--prune/--no-prune",
|
|
125
|
+
help="Delete local YAML files that no longer exist in the project's ontology (default: prune).",
|
|
126
|
+
),
|
|
127
|
+
json_output: bool = typer.Option(False, "--json", help="Print a JSON summary of written/deleted files."),
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Download the project's unpublished ontology into a repository checkout.
|
|
130
|
+
|
|
131
|
+
Writes the ontology YAML tree under the export path (full sync: files are
|
|
132
|
+
overwritten and, unless --no-prune, stale local YAML files are deleted, so
|
|
133
|
+
the checkout ends up matching the project exactly). Review the changes with
|
|
134
|
+
git diff before committing. Exits 0 on success, 2 on usage errors, 3 on
|
|
135
|
+
transport/API errors.
|
|
136
|
+
"""
|
|
137
|
+
api_key = _require_api_key(api_key)
|
|
138
|
+
try:
|
|
139
|
+
UUID(project_id)
|
|
140
|
+
except ValueError:
|
|
141
|
+
typer.secho(f"--project must be a project ID (UUID), got {project_id!r}.", fg=typer.colors.RED, err=True)
|
|
142
|
+
raise typer.Exit(EXIT_USAGE)
|
|
143
|
+
base_path = base_path.strip().strip("/")
|
|
144
|
+
if not base_path:
|
|
145
|
+
typer.secho("--base-path must not be empty.", fg=typer.colors.RED, err=True)
|
|
146
|
+
raise typer.Exit(EXIT_USAGE)
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
files = get_ontology_export(api_url=api_url, api_key=api_key, project_id=project_id)
|
|
150
|
+
except AuthError as exc:
|
|
151
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
152
|
+
raise typer.Exit(EXIT_TRANSPORT) from exc
|
|
153
|
+
except ApiError as exc:
|
|
154
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
155
|
+
raise typer.Exit(EXIT_TRANSPORT) from exc
|
|
156
|
+
|
|
157
|
+
ontology_dir = (path / Path(base_path)).resolve()
|
|
158
|
+
written: list[str] = []
|
|
159
|
+
for rel, content in sorted(files.items()):
|
|
160
|
+
dest = (ontology_dir / rel).resolve()
|
|
161
|
+
# The server controls these paths; refuse anything escaping the
|
|
162
|
+
# ontology dir rather than trusting it blindly.
|
|
163
|
+
if not dest.is_relative_to(ontology_dir):
|
|
164
|
+
typer.secho(f"Refusing to write outside {ontology_dir}: {rel!r}", fg=typer.colors.RED, err=True)
|
|
165
|
+
raise typer.Exit(EXIT_TRANSPORT)
|
|
166
|
+
try:
|
|
167
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
168
|
+
dest.write_text(content, encoding="utf-8")
|
|
169
|
+
except OSError as exc: # local checkout problem (permissions, dir/file collision) — usage, not validation
|
|
170
|
+
typer.secho(f"Cannot write {dest}: {exc}", fg=typer.colors.RED, err=True)
|
|
171
|
+
raise typer.Exit(EXIT_USAGE) from exc
|
|
172
|
+
written.append(rel)
|
|
173
|
+
|
|
174
|
+
deleted: list[str] = []
|
|
175
|
+
if prune and ontology_dir.is_dir():
|
|
176
|
+
local = _collect_files(ontology_dir)
|
|
177
|
+
for rel in sorted(set(local) - set(files)):
|
|
178
|
+
try:
|
|
179
|
+
(ontology_dir / rel).unlink()
|
|
180
|
+
except OSError as exc:
|
|
181
|
+
typer.secho(f"Cannot delete {ontology_dir / rel}: {exc}", fg=typer.colors.RED, err=True)
|
|
182
|
+
raise typer.Exit(EXIT_USAGE) from exc
|
|
183
|
+
deleted.append(rel)
|
|
184
|
+
|
|
185
|
+
if json_output:
|
|
186
|
+
typer.echo(json.dumps({"written": written, "deleted": deleted}, indent=2))
|
|
187
|
+
else:
|
|
188
|
+
summary = f"✓ Pulled {len(written)} files into {ontology_dir}"
|
|
189
|
+
if deleted:
|
|
190
|
+
summary += f" ({len(deleted)} stale files deleted)"
|
|
191
|
+
typer.secho(f"{summary}.", fg=typer.colors.GREEN)
|
|
192
|
+
raise typer.Exit(EXIT_OK)
|
|
193
|
+
|
|
194
|
+
|
|
168
195
|
@app.command()
|
|
169
196
|
def upload(
|
|
170
197
|
path: Path = typer.Argument(
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
"""Thin HTTP client for the Cassis API."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from typing import Any, Optional
|
|
6
|
-
|
|
7
|
-
import httpx
|
|
8
|
-
|
|
9
|
-
DEFAULT_API_URL = "https://app.getcassis.com"
|
|
10
|
-
TIMEOUT_SECONDS = 60.0
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class ApiError(Exception):
|
|
14
|
-
"""Transport or HTTP-level failure talking to the Cassis API."""
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
class AuthError(ApiError):
|
|
18
|
-
"""The API rejected the API key (401)."""
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
class UploadValidationError(ApiError):
|
|
22
|
-
"""The API rejected the uploaded ontology tree as invalid (400)."""
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def post_ontology_check(
|
|
26
|
-
*,
|
|
27
|
-
api_url: str,
|
|
28
|
-
api_key: str,
|
|
29
|
-
files: dict[str, str],
|
|
30
|
-
transport: Optional[httpx.BaseTransport] = None,
|
|
31
|
-
) -> dict[str, Any]:
|
|
32
|
-
"""POST the ontology tree to /api/ci/ontology-check and return the response body."""
|
|
33
|
-
url = api_url.rstrip("/") + "/api/ci/ontology-check"
|
|
34
|
-
try:
|
|
35
|
-
with httpx.Client(timeout=TIMEOUT_SECONDS, transport=transport) as client:
|
|
36
|
-
response = client.post(
|
|
37
|
-
url,
|
|
38
|
-
json={"files": files},
|
|
39
|
-
headers={"Authorization": f"Bearer {api_key}"},
|
|
40
|
-
)
|
|
41
|
-
except httpx.HTTPError as exc:
|
|
42
|
-
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
43
|
-
|
|
44
|
-
if response.status_code == 401:
|
|
45
|
-
raise AuthError("The Cassis API rejected the API key (invalid or expired).")
|
|
46
|
-
if response.status_code >= 400:
|
|
47
|
-
raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
|
|
48
|
-
try:
|
|
49
|
-
result = response.json()
|
|
50
|
-
except ValueError as exc: # json.JSONDecodeError — e.g. a proxy or portal answering HTML with a 200
|
|
51
|
-
raise ApiError(
|
|
52
|
-
f"The Cassis API at {url} returned a non-JSON response — check the API URL and any proxy in between."
|
|
53
|
-
) from exc
|
|
54
|
-
if (
|
|
55
|
-
not isinstance(result, dict)
|
|
56
|
-
or not all(key in result for key in ("passed", "title", "summary"))
|
|
57
|
-
or not isinstance(result.get("findings"), list)
|
|
58
|
-
):
|
|
59
|
-
raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
|
|
60
|
-
return result
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def post_ontology_import(
|
|
64
|
-
*,
|
|
65
|
-
api_url: str,
|
|
66
|
-
api_key: str,
|
|
67
|
-
project_id: str,
|
|
68
|
-
files: dict[str, str],
|
|
69
|
-
publish: bool,
|
|
70
|
-
label: Optional[str] = None,
|
|
71
|
-
transport: Optional[httpx.BaseTransport] = None,
|
|
72
|
-
) -> dict[str, Any]:
|
|
73
|
-
"""POST the ontology tree to /api/ci/projects/{project_id}/ontology/import and return the response body."""
|
|
74
|
-
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/ontology/import"
|
|
75
|
-
body: dict[str, Any] = {"files": files, "publish": publish}
|
|
76
|
-
if label is not None:
|
|
77
|
-
body["label"] = label
|
|
78
|
-
try:
|
|
79
|
-
with httpx.Client(timeout=TIMEOUT_SECONDS, transport=transport) as client:
|
|
80
|
-
response = client.post(
|
|
81
|
-
url,
|
|
82
|
-
json=body,
|
|
83
|
-
headers={"Authorization": f"Bearer {api_key}"},
|
|
84
|
-
)
|
|
85
|
-
except httpx.HTTPError as exc:
|
|
86
|
-
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
87
|
-
|
|
88
|
-
if response.status_code == 401:
|
|
89
|
-
raise AuthError("The Cassis API rejected the API key (invalid or expired).")
|
|
90
|
-
if response.status_code == 400:
|
|
91
|
-
try:
|
|
92
|
-
detail = response.json().get("detail") or response.text[:500]
|
|
93
|
-
except ValueError:
|
|
94
|
-
detail = response.text[:500]
|
|
95
|
-
raise UploadValidationError(str(detail))
|
|
96
|
-
if response.status_code in (403, 404):
|
|
97
|
-
raise ApiError(
|
|
98
|
-
f"Project not found or not accessible (HTTP {response.status_code}). Check --project and that "
|
|
99
|
-
"the API key belongs to the project's organization and can edit it."
|
|
100
|
-
)
|
|
101
|
-
if response.status_code >= 400:
|
|
102
|
-
raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
|
|
103
|
-
try:
|
|
104
|
-
result = response.json()
|
|
105
|
-
except ValueError as exc: # json.JSONDecodeError — e.g. a proxy or portal answering HTML with a 200
|
|
106
|
-
raise ApiError(
|
|
107
|
-
f"The Cassis API at {url} returned a non-JSON response — check the API URL and any proxy in between."
|
|
108
|
-
) from exc
|
|
109
|
-
if not isinstance(result, dict) or not all(
|
|
110
|
-
key in result for key in ("domain_count", "table_count", "join_count", "metric_count", "published_version")
|
|
111
|
-
):
|
|
112
|
-
raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
|
|
113
|
-
return result
|