Skip to content

Commit 8db09b1

Browse files
committed
feat: mirror the API's validators as Click choices
Every enumeration the REST API validates is now restated in sp_cli/constants.py and wired into the commands as click.Choice, so a bad --status or --platform fails instantly as a usage error instead of costing a round trip and coming back as an HTTP 400. The values are not invented; each one mirrors a specific validator in the merged mod_api blueprint, and the module says which. The ones that are easy to guess wrong: - /runs?status= only accepts queued|running|canceled. It is derived from the latest TestProgress row, so pass/fail -- which are per-sample outcomes, not run states -- are deliberately absent. Use `sp run summary` for those. - _VALID_SAMPLE_STATUSES is pass|fail|missing_output|not_started. No 'skipped', no 'running'. - /regression-tests?active is a two-way switch, not tri-state: omitting it lists active tests only, so --active/--all was wrong and is now --active/--inactive. - There are seven token scopes, not six: system:write is separate from system:read so a monitoring token cannot reconfigure the platform. Also fills in filters the API supports but the CLI was not exposing (sample --sha256/--status, queue --limit/--offset, run --repository/--sort and the date window). ApiContractTests pins all of this, so a validator changing upstream fails here loudly rather than in production.
1 parent f149326 commit 8db09b1

7 files changed

Lines changed: 527 additions & 32 deletions

File tree

sp_cli/commands/auth.py

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,81 @@
1-
"""``sp auth`` — obtain and revoke API tokens."""
1+
"""``sp auth`` — obtain, list, and revoke API tokens."""
2+
3+
from typing import Optional, Tuple
24

35
import click
46

57
from sp_cli.client import ApiError
8+
from sp_cli.constants import TOKEN_MAX_DAYS, TOKEN_MIN_DAYS, TOKEN_SCOPES
69
from sp_cli.output import render, render_error
10+
from sp_cli.runner import clean_params, fetch_and_render
711

812

913
@click.group()
1014
def auth() -> None:
11-
"""Obtain and revoke API tokens."""
15+
"""Obtain, list, and revoke API tokens."""
1216

1317

1418
@auth.command('login')
1519
@click.option('--email', prompt=True, help='Account email.')
1620
@click.option('--password', prompt=True, hide_input=True, help='Account password (never stored).')
17-
@click.option('--name', 'token_name', default='sp-cli', show_default=True, help='Token label.')
18-
@click.option('--days', 'expires_in_days', type=int, default=30, show_default=True,
19-
help='Token lifetime in days (max 90).')
21+
@click.option('--name', 'token_name', default='sp-cli', show_default=True,
22+
help='Token label; must match ^[a-zA-Z0-9_-]+$.')
23+
@click.option('--days', 'expires_in_days', type=click.IntRange(TOKEN_MIN_DAYS, TOKEN_MAX_DAYS),
24+
default=TOKEN_MAX_DAYS, show_default=True, help='Token lifetime in days.')
25+
@click.option('--scope', 'scopes', multiple=True, type=click.Choice(TOKEN_SCOPES),
26+
help='Grant a specific scope; repeatable. Omit for the server default set.')
2027
@click.pass_context
21-
def auth_login(ctx: click.Context, email: str, password: str,
22-
token_name: str, expires_in_days: int) -> None:
23-
"""Create an API token; store the printed value in SP_API_TOKEN."""
28+
def auth_login(ctx: click.Context, email: str, password: str, token_name: str,
29+
expires_in_days: int, scopes: Tuple[str, ...]) -> None:
30+
"""Create an API token; store the printed value in SP_API_TOKEN.
31+
32+
The plaintext token is returned exactly once, at creation. Later `sp auth
33+
tokens` calls list metadata only, so capture it now or create a new one.
34+
"""
2435
client = ctx.obj['client']
2536
output = ctx.obj['output']
2637
body = {'email': email, 'password': password,
2738
'token_name': token_name, 'expires_in_days': expires_in_days}
39+
if scopes:
40+
body['scopes'] = list(scopes)
2841
try:
2942
result = client.request('POST', '/auth/tokens', json_body=body)
3043
except ApiError as error:
3144
render_error(error, output)
3245
raise SystemExit(error.exit_code)
46+
3347
render(result, output)
3448

3549

50+
@auth.command('tokens')
51+
@click.option('--limit', type=int, default=None, help='Page size (max 100).')
52+
@click.option('--offset', type=int, default=None, help='Pagination offset.')
53+
@click.pass_context
54+
def auth_tokens(ctx: click.Context, limit: Optional[int], offset: Optional[int]) -> None:
55+
"""List this account's API tokens (metadata only, never the token itself)."""
56+
params = clean_params({'limit': limit, 'offset': offset})
57+
fetch_and_render(ctx, '/auth/tokens', params)
58+
59+
60+
@auth.command('revoke')
61+
@click.argument('token_id', type=int)
62+
@click.pass_context
63+
def auth_revoke(ctx: click.Context, token_id: int) -> None:
64+
"""Revoke one token by id, leaving the token in use untouched.
65+
66+
Use `sp auth tokens` to find the id. To revoke the token you are currently
67+
authenticating with, use `sp auth logout` instead.
68+
"""
69+
client = ctx.obj['client']
70+
output = ctx.obj['output']
71+
try:
72+
client.request('DELETE', f'/auth/tokens/{token_id}')
73+
except ApiError as error:
74+
render_error(error, output)
75+
raise SystemExit(error.exit_code)
76+
click.echo(f'Token {token_id} revoked.')
77+
78+
3679
@auth.command('logout')
3780
@click.pass_context
3881
def auth_logout(ctx: click.Context) -> None:

sp_cli/commands/regression.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,21 @@ def regression() -> None:
1515
@regression.command('ls')
1616
@click.option('--category', default=None, help='Filter by category name.')
1717
@click.option('--tag', default=None, help='Filter by tag.')
18-
@click.option('--active/--all', 'active', default=None, help='Only active tests (default: all).')
18+
@click.option('--active/--inactive', 'active', default=None,
19+
help='Select active or inactive tests (default: active only).')
1920
@click.option('--sample-id', type=int, default=None, help='Filter by sample id.')
2021
@click.option('--limit', type=int, default=None, help='Page size (max 100).')
2122
@click.option('--offset', type=int, default=None, help='Pagination offset.')
2223
@click.pass_context
2324
def regression_ls(ctx: click.Context, category: Optional[str], tag: Optional[str],
2425
active: Optional[bool], sample_id: Optional[int],
2526
limit: Optional[int], offset: Optional[int]) -> None:
26-
"""List regression-test definitions."""
27+
"""List regression-test definitions.
28+
29+
The API's ``active`` filter is a two-way switch with no "everything"
30+
setting: omitting it lists active tests only, and ``--inactive`` lists
31+
inactive ones only. Run the command twice to see both.
32+
"""
2733
params = clean_params({'category': category, 'tag': tag, 'active': active,
2834
'sample_id': sample_id, 'limit': limit, 'offset': offset})
2935
fetch_and_render(ctx, '/regression-tests', params)

sp_cli/commands/run.py

Lines changed: 116 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import click
66

77
from sp_cli.client import ApiError
8+
from sp_cli.constants import (CANCEL_REASON_MIN_LENGTH, PLATFORMS,
9+
RUN_STATUSES, SAMPLE_STATUSES)
810
from sp_cli.output import render, render_error
911
from sp_cli.runner import clean_params, fetch_and_render
1012
from sp_cli.triage import classify_sample, is_failure
@@ -16,18 +18,34 @@ def run() -> None:
1618

1719

1820
@run.command('ls')
19-
@click.option('--status', default=None, help='queued|running|pass|fail|canceled|error|incomplete')
20-
@click.option('--platform', default=None, help='linux|windows')
21+
@click.option('--status', type=click.Choice(RUN_STATUSES), default=None,
22+
help='Filter by lifecycle status. The API only tracks these three.')
23+
@click.option('--platform', type=click.Choice(PLATFORMS), default=None, help='Test platform.')
2124
@click.option('--branch', default=None, help='Filter by branch name.')
2225
@click.option('--commit', 'commit_sha', default=None, help='Full 40-char commit SHA.')
26+
@click.option('--repository', default=None, help='Filter by fork, as owner/repo.')
27+
@click.option('--sort', default=None, help='Sort key, e.g. -created_at (default) or run_id.')
28+
@click.option('--created-after', 'created_after', default=None,
29+
help='Only runs first seen at/after this time (ISO 8601).')
30+
@click.option('--created-before', 'created_before', default=None,
31+
help='Only runs first seen at/before this time (ISO 8601).')
2332
@click.option('--limit', type=int, default=None, help='Page size (max 100).')
2433
@click.option('--offset', type=int, default=None, help='Pagination offset.')
2534
@click.pass_context
2635
def run_ls(ctx: click.Context, status: Optional[str], platform: Optional[str], branch: Optional[str],
27-
commit_sha: Optional[str], limit: Optional[int], offset: Optional[int]) -> None:
28-
"""List CI runs (newest first)."""
36+
commit_sha: Optional[str], repository: Optional[str], sort: Optional[str],
37+
created_after: Optional[str], created_before: Optional[str],
38+
limit: Optional[int], offset: Optional[int]) -> None:
39+
"""List CI runs (newest first).
40+
41+
--status only accepts queued, running, and canceled: the API derives them
42+
from the latest TestProgress row, and pass/fail are per-sample outcomes
43+
rather than run states. Use `sp run summary` for a run's pass/fail split.
44+
"""
2945
params = clean_params({'status': status, 'platform': platform, 'branch': branch,
30-
'commit_sha': commit_sha, 'limit': limit, 'offset': offset})
46+
'commit_sha': commit_sha, 'repository': repository, 'sort': sort,
47+
'created_after': created_after, 'created_before': created_before,
48+
'limit': limit, 'offset': offset})
3149
fetch_and_render(ctx, '/runs', params)
3250

3351

@@ -66,14 +84,20 @@ def run_failures(ctx: click.Context, run_id: int) -> None:
6684

6785
@run.command('results')
6886
@click.argument('run_id', type=int)
69-
@click.option('--status', default=None, help='pass|fail|skipped|missing_output|running|not_started')
87+
@click.option('--status', type=click.Choice(SAMPLE_STATUSES), default=None,
88+
help='Filter by per-sample outcome.')
89+
@click.option('--name', default=None, help='Substring match on the sample name.')
90+
@click.option('--tag', default=None, help='Filter by sample tag.')
91+
@click.option('--category', default=None, help='Filter by regression-test category.')
7092
@click.option('--limit', type=int, default=None, help='Page size (max 100).')
7193
@click.option('--offset', type=int, default=None, help='Pagination offset.')
7294
@click.pass_context
73-
def run_results(ctx: click.Context, run_id: int, status: Optional[str],
95+
def run_results(ctx: click.Context, run_id: int, status: Optional[str], name: Optional[str],
96+
tag: Optional[str], category: Optional[str],
7497
limit: Optional[int], offset: Optional[int]) -> None:
7598
"""List all regression-test results in a run."""
76-
params = clean_params({'status': status, 'limit': limit, 'offset': offset})
99+
params = clean_params({'status': status, 'name': name, 'tag': tag, 'category': category,
100+
'limit': limit, 'offset': offset})
77101
fetch_and_render(ctx, f'/runs/{run_id}/samples', params)
78102

79103

@@ -167,6 +191,90 @@ def run_approve_baseline(ctx: click.Context, run_id: int, sample_id: int,
167191
render(result, output)
168192

169193

194+
@run.command('progress')
195+
@click.argument('run_id', type=int)
196+
@click.option('--limit', type=int, default=None, help='Page size (max 100).')
197+
@click.option('--offset', type=int, default=None, help='Pagination offset.')
198+
@click.pass_context
199+
def run_progress(ctx: click.Context, run_id: int, limit: Optional[int],
200+
offset: Optional[int]) -> None:
201+
"""Show the timeline of progress events the CI worker recorded for a run."""
202+
params = clean_params({'limit': limit, 'offset': offset})
203+
fetch_and_render(ctx, f'/runs/{run_id}/progress', params)
204+
205+
206+
@run.command('config')
207+
@click.argument('run_id', type=int)
208+
@click.pass_context
209+
def run_config(ctx: click.Context, run_id: int) -> None:
210+
"""Show the platform, branch, commit, and regression tests a run was launched with."""
211+
fetch_and_render(ctx, f'/runs/{run_id}/config')
212+
213+
214+
@run.command('cancel')
215+
@click.argument('run_id', type=int)
216+
@click.option('--reason', default=None,
217+
help=f'Why the run is being canceled (min {CANCEL_REASON_MIN_LENGTH} characters).')
218+
@click.pass_context
219+
def run_cancel(ctx: click.Context, run_id: int, reason: Optional[str]) -> None:
220+
"""Cancel a queued or running test.
221+
222+
Requires the ``runs:write`` scope. Idempotent: canceling a run that has
223+
already finished succeeds and reports ``status=no_op`` rather than failing.
224+
"""
225+
client = ctx.obj['client']
226+
output = ctx.obj['output']
227+
if reason is not None and len(reason.strip()) < CANCEL_REASON_MIN_LENGTH:
228+
raise click.BadParameter(
229+
f'must be at least {CANCEL_REASON_MIN_LENGTH} characters', param_hint='--reason')
230+
body = {'reason': reason} if reason else None
231+
try:
232+
result = client.request('POST', f'/runs/{run_id}/cancel', json_body=body)
233+
except ApiError as error:
234+
render_error(error, output)
235+
raise SystemExit(error.exit_code)
236+
render(result, output)
237+
238+
239+
@run.command('output')
240+
@click.argument('run_id', type=int)
241+
@click.argument('sample_id', type=int)
242+
@click.option('--regression', 'regression_id', type=int, default=None,
243+
help='Regression test id (auto-resolved if omitted).')
244+
@click.option('--output', 'output_id', type=int, default=None,
245+
help='Output file id (auto-resolved if omitted).')
246+
@click.option('--side', type=click.Choice(('expected', 'actual')), default='actual',
247+
show_default=True, help='Which side of the comparison to fetch.')
248+
@click.option('--format', 'fmt', default=None, help='Response format accepted by the API.')
249+
@click.pass_context
250+
def run_output(ctx: click.Context, run_id: int, sample_id: int, regression_id: Optional[int],
251+
output_id: Optional[int], side: str, fmt: Optional[str]) -> None:
252+
"""Fetch one side of a result's output file.
253+
254+
Resolves the (media sample, regression, output) ids the same way `sp run
255+
diff` does, so the hidden ids the web UI needs are not required here.
256+
257+
Note: for an output that matched, the API answers ``actual`` with a 303
258+
redirect to ``expected`` -- requests follows it, so the expected content is
259+
what comes back.
260+
"""
261+
client = ctx.obj['client']
262+
output = ctx.obj['output']
263+
try:
264+
targets = _resolve_diff_targets(client, run_id, sample_id, regression_id, output_id)
265+
if not targets:
266+
raise ApiError('not_found', 'No output to fetch for this result', 404)
267+
media_sample_id, reg_id, out_id = targets[0]
268+
payload = client.get(
269+
f'/runs/{run_id}/samples/{media_sample_id}'
270+
f'/regression-tests/{reg_id}/outputs/{out_id}/{side}',
271+
params=clean_params({'format': fmt}))
272+
except ApiError as error:
273+
render_error(error, output)
274+
raise SystemExit(error.exit_code)
275+
render(payload, output)
276+
277+
170278
@run.command('artifacts')
171279
@click.argument('run_id', type=int)
172280
@click.pass_context

sp_cli/commands/sample.py

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
import click
66

7+
from sp_cli.constants import (PLATFORMS, SAMPLE_CATALOG_STATUSES,
8+
SAMPLE_STATUSES)
79
from sp_cli.runner import clean_params, fetch_and_render
810

911

@@ -13,17 +15,21 @@ def sample() -> None:
1315

1416

1517
@sample.command('ls')
16-
@click.option('--name', default=None, help='Filter by sample name.')
18+
@click.option('--name', default=None, help='Substring match on the original sample name.')
1719
@click.option('--tag', default=None, help='Filter by tag.')
1820
@click.option('--extension', default=None, help='Filter by file extension.')
21+
@click.option('--sha256', default=None, help='Filter by exact SHA-256 hash.')
22+
@click.option('--status', type=click.Choice(SAMPLE_CATALOG_STATUSES), default=None,
23+
help='Catalog visibility, not a test outcome.')
1924
@click.option('--limit', type=int, default=None, help='Page size (max 100).')
2025
@click.option('--offset', type=int, default=None, help='Pagination offset.')
2126
@click.pass_context
22-
def sample_ls(ctx: click.Context, name: Optional[str], tag: Optional[str],
23-
extension: Optional[str], limit: Optional[int], offset: Optional[int]) -> None:
27+
def sample_ls(ctx: click.Context, name: Optional[str], tag: Optional[str], extension: Optional[str],
28+
sha256: Optional[str], status: Optional[str],
29+
limit: Optional[int], offset: Optional[int]) -> None:
2430
"""List known media samples."""
25-
params = clean_params({'name': name, 'tag': tag, 'extension': extension,
26-
'limit': limit, 'offset': offset})
31+
params = clean_params({'name': name, 'tag': tag, 'extension': extension, 'sha256': sha256,
32+
'status': status, 'limit': limit, 'offset': offset})
2733
fetch_and_render(ctx, '/samples', params)
2834

2935

@@ -37,11 +43,27 @@ def sample_show(ctx: click.Context, sample_id: int) -> None:
3743

3844
@sample.command('history')
3945
@click.argument('sample_id', type=int)
40-
@click.option('--platform', default=None, help='linux|windows')
46+
@click.option('--platform', type=click.Choice(PLATFORMS), default=None, help='Test platform.')
47+
@click.option('--branch', default=None, help='Filter by branch name.')
48+
@click.option('--status', type=click.Choice(SAMPLE_STATUSES), default=None,
49+
help='Filter by this sample\'s outcome in each run.')
50+
@click.option('--created-after', 'created_after', default=None,
51+
help='Only runs first seen at/after this time (ISO 8601).')
52+
@click.option('--created-before', 'created_before', default=None,
53+
help='Only runs first seen at/before this time (ISO 8601).')
4154
@click.option('--limit', type=int, default=None, help='Page size (max 100).')
55+
@click.option('--offset', type=int, default=None, help='Pagination offset.')
4256
@click.pass_context
4357
def sample_history(ctx: click.Context, sample_id: int, platform: Optional[str],
44-
limit: Optional[int]) -> None:
45-
"""Show this sample's result history across runs."""
46-
params = clean_params({'platform': platform, 'limit': limit})
58+
branch: Optional[str], status: Optional[str], created_after: Optional[str],
59+
created_before: Optional[str], limit: Optional[int],
60+
offset: Optional[int]) -> None:
61+
"""Show this sample's result history across runs.
62+
63+
Each entry carries a ``failure_signature``, which is what separates a real
64+
regression from an infra flake that happens to fail the same test.
65+
"""
66+
params = clean_params({'platform': platform, 'branch': branch, 'status': status,
67+
'created_after': created_after, 'created_before': created_before,
68+
'limit': limit, 'offset': offset})
4769
fetch_and_render(ctx, f'/samples/{sample_id}/history', params)

sp_cli/commands/system.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import click
66

7+
from sp_cli.constants import PLATFORMS, QUEUE_STATUSES
78
from sp_cli.runner import clean_params, fetch_and_render
89

910

@@ -15,9 +16,19 @@ def health(ctx: click.Context) -> None:
1516

1617

1718
@click.command('queue')
18-
@click.option('--platform', default=None, help='linux|windows')
19-
@click.option('--status', default=None, help='queued|running')
19+
@click.option('--platform', type=click.Choice(PLATFORMS), default=None, help='Test platform.')
20+
@click.option('--status', type=click.Choice(QUEUE_STATUSES), default=None,
21+
help='Restrict to one side of the queue.')
22+
@click.option('--limit', type=int, default=None, help='Page size (max 100).')
23+
@click.option('--offset', type=int, default=None, help='Pagination offset.')
2024
@click.pass_context
21-
def queue(ctx: click.Context, platform: Optional[str], status: Optional[str]) -> None:
22-
"""Show queue depth and currently running jobs."""
23-
fetch_and_render(ctx, '/system/queue', clean_params({'platform': platform, 'status': status}))
25+
def queue(ctx: click.Context, platform: Optional[str], status: Optional[str],
26+
limit: Optional[int], offset: Optional[int]) -> None:
27+
"""Show queue depth and currently running jobs.
28+
29+
Completed and canceled runs are excluded. The per-item ``position`` field
30+
is only populated when ``--status queued`` is passed; otherwise it is null.
31+
"""
32+
params = clean_params({'platform': platform, 'status': status,
33+
'limit': limit, 'offset': offset})
34+
fetch_and_render(ctx, '/system/queue', params)

0 commit comments

Comments
 (0)