xoople-cli 0.1.0__py3-none-any.whl
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.
- xoople/cli/__init__.py +5 -0
- xoople/cli/__main__.py +3 -0
- xoople/cli/_context.py +87 -0
- xoople/cli/_errors.py +75 -0
- xoople/cli/_output.py +137 -0
- xoople/cli/_spec.py +45 -0
- xoople/cli/_version.py +17 -0
- xoople/cli/access.py +47 -0
- xoople/cli/analyses.py +161 -0
- xoople/cli/catalog.py +98 -0
- xoople/cli/events.py +69 -0
- xoople/cli/main.py +105 -0
- xoople/cli/outputs.py +77 -0
- xoople/cli/runs.py +100 -0
- xoople/cli/schedules.py +214 -0
- xoople/cli/usage.py +99 -0
- xoople_cli-0.1.0.dist-info/METADATA +65 -0
- xoople_cli-0.1.0.dist-info/RECORD +21 -0
- xoople_cli-0.1.0.dist-info/WHEEL +4 -0
- xoople_cli-0.1.0.dist-info/entry_points.txt +3 -0
- xoople_cli-0.1.0.dist-info/licenses/LICENSE +23 -0
xoople/cli/__init__.py
ADDED
xoople/cli/__main__.py
ADDED
xoople/cli/_context.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
from cyclopts import Parameter
|
|
6
|
+
from xoople.cli._errors import UsageError
|
|
7
|
+
from xoople.sdk import Config, CredentialProvider, XoopleClient
|
|
8
|
+
from xoople.sdk.auth import EntraCredential, StaticToken
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Credentials:
|
|
13
|
+
"""The global options that decide which API and identity a command talks to."""
|
|
14
|
+
|
|
15
|
+
base_url: str | None = None
|
|
16
|
+
token: str | None = None
|
|
17
|
+
entra_scope: str | None = None
|
|
18
|
+
timeout: float = 30.0
|
|
19
|
+
max_retries: int = 3
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# How Credentials become a client — swapped for a fake in tests.
|
|
23
|
+
type ClientFactory = Callable[[Credentials], XoopleClient]
|
|
24
|
+
|
|
25
|
+
# Every command declares this keyword-only parameter; the meta-app launcher in
|
|
26
|
+
# ``main.py`` injects the resolved ``Settings`` via ``parse=False``, so commands
|
|
27
|
+
# stay plain functions and tests drive them over a fake transport.
|
|
28
|
+
type SettingsParam = Annotated[Settings, Parameter(parse=False)]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def at_least_one(_type: object, value: int | None) -> None:
|
|
32
|
+
"""Cyclopts validator for ``--limit``: a sub-1 read pays for nothing and the
|
|
33
|
+
SDK would reject it anyway — fail it at parse time with a usage error."""
|
|
34
|
+
if value is not None and value < 1:
|
|
35
|
+
raise ValueError("must be at least 1")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Settings:
|
|
40
|
+
"""Resolved global options, injected into every command by the meta launcher.
|
|
41
|
+
|
|
42
|
+
The client is built lazily so ``--help`` and argument errors never require
|
|
43
|
+
credentials, and cached so a command making several calls reuses one
|
|
44
|
+
connection pool.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
credentials: Credentials
|
|
48
|
+
factory: ClientFactory
|
|
49
|
+
as_json: bool = False
|
|
50
|
+
_client: XoopleClient | None = field(default=None, repr=False)
|
|
51
|
+
|
|
52
|
+
def client(self) -> XoopleClient:
|
|
53
|
+
if self._client is None:
|
|
54
|
+
self._client = self.factory(self.credentials)
|
|
55
|
+
return self._client
|
|
56
|
+
|
|
57
|
+
def close(self) -> None:
|
|
58
|
+
if self._client is not None:
|
|
59
|
+
self._client.close()
|
|
60
|
+
self._client = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _credential_provider(credentials: Credentials) -> CredentialProvider:
|
|
64
|
+
if credentials.entra_scope:
|
|
65
|
+
return EntraCredential(credentials.entra_scope)
|
|
66
|
+
if not credentials.token:
|
|
67
|
+
raise UsageError("no credentials: pass --token, set XOOPLE_TOKEN, or pass --entra-scope")
|
|
68
|
+
return StaticToken(credentials.token)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def build_client(credentials: Credentials) -> XoopleClient:
|
|
72
|
+
"""Resolve credentials into a client.
|
|
73
|
+
|
|
74
|
+
The environment is read by Cyclopts' ``env_var=`` on the launcher options,
|
|
75
|
+
not here — a second `os.environ` fallback would be unreachable and would
|
|
76
|
+
drift from the names `--help` advertises.
|
|
77
|
+
"""
|
|
78
|
+
if not credentials.base_url:
|
|
79
|
+
raise UsageError("no API URL: pass --api-url or set XOOPLE_API_URL")
|
|
80
|
+
return XoopleClient(
|
|
81
|
+
Config(
|
|
82
|
+
base_url=credentials.base_url,
|
|
83
|
+
credentials=_credential_provider(credentials),
|
|
84
|
+
timeout=credentials.timeout,
|
|
85
|
+
max_retries=credentials.max_retries,
|
|
86
|
+
)
|
|
87
|
+
)
|
xoople/cli/_errors.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from xoople.cli._output import note
|
|
2
|
+
from xoople.sdk import errors
|
|
3
|
+
|
|
4
|
+
EXIT_CODES: dict[type[errors.XoopleError], int] = {
|
|
5
|
+
errors.AuthenticationError: 3,
|
|
6
|
+
errors.PermissionDeniedError: 4,
|
|
7
|
+
errors.NotFoundError: 5,
|
|
8
|
+
errors.BadRequestError: 6,
|
|
9
|
+
errors.ValidationError: 6,
|
|
10
|
+
errors.ConflictError: 7,
|
|
11
|
+
errors.RateLimitError: 8,
|
|
12
|
+
errors.ServerError: 9,
|
|
13
|
+
errors.XoopleConnectionError: 10,
|
|
14
|
+
errors.XoopleResponseError: 11,
|
|
15
|
+
}
|
|
16
|
+
_FALLBACK_EXIT = 1
|
|
17
|
+
# ``CliError`` with no specific subclass falls back to 1. Nothing raises a bare
|
|
18
|
+
# ``CliError`` today; every CLI-detected outcome has its own subclass below.
|
|
19
|
+
# Outcomes the CLI decides rather than the API. Kept here with the status map so
|
|
20
|
+
# the README's exit-code table has one source.
|
|
21
|
+
USAGE_EXIT = 2
|
|
22
|
+
FAILED_RUN_EXIT = 12
|
|
23
|
+
WAIT_TIMEOUT_EXIT = 13
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CliError(Exception):
|
|
27
|
+
"""A failure the CLI itself detects, reported the same way as an API error."""
|
|
28
|
+
|
|
29
|
+
exit_code = _FALLBACK_EXIT
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class UsageError(CliError):
|
|
33
|
+
"""A bad flag or spec file: the user can fix it and re-run."""
|
|
34
|
+
|
|
35
|
+
exit_code = USAGE_EXIT
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RunFailed(CliError):
|
|
39
|
+
"""``--wait`` finished, but the run ended in a non-``COMPLETE`` state."""
|
|
40
|
+
|
|
41
|
+
exit_code = FAILED_RUN_EXIT
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class WaitTimeout(CliError):
|
|
45
|
+
"""``--wait`` gave up before the run reached a terminal state."""
|
|
46
|
+
|
|
47
|
+
exit_code = WAIT_TIMEOUT_EXIT
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def exit_code(exc: errors.XoopleError) -> int:
|
|
51
|
+
for cls, code in EXIT_CODES.items():
|
|
52
|
+
if isinstance(exc, cls):
|
|
53
|
+
return code
|
|
54
|
+
return _FALLBACK_EXIT
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _describe(exc: errors.XoopleError) -> str:
|
|
58
|
+
if isinstance(exc, errors.XoopleAPIError):
|
|
59
|
+
trace = f" [dim](trace {exc.trace_id})[/dim]" if exc.trace_id else ""
|
|
60
|
+
return f"[red]error[/red] {exc.status} {exc.code}: {exc.message}{trace}"
|
|
61
|
+
return f"[red]error[/red] {exc}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def report(exc: errors.XoopleError | CliError) -> int:
|
|
65
|
+
"""Print an error as one line and return the exit code it maps to.
|
|
66
|
+
|
|
67
|
+
The meta-app launcher in ``main.py`` calls this for every exception that
|
|
68
|
+
escapes a command, so a stack trace never reaches the user and the API
|
|
69
|
+
status returns as an exit code instead of a generic 1.
|
|
70
|
+
"""
|
|
71
|
+
if isinstance(exc, errors.XoopleError):
|
|
72
|
+
note(_describe(exc))
|
|
73
|
+
return exit_code(exc)
|
|
74
|
+
note(f"[red]error[/red] {exc}")
|
|
75
|
+
return exc.exit_code
|
xoople/cli/_output.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from collections.abc import Iterable, Sequence
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
type Column = tuple[str, str]
|
|
9
|
+
|
|
10
|
+
# Fields holding a 0..1 fraction. A column headed PROGRESS showing `0.42` reads
|
|
11
|
+
# as a bug, and `estimate_confidence` is the same unit, so both render as whole
|
|
12
|
+
# percent. Keyed on the field name rather than declared per column: the unit
|
|
13
|
+
# belongs to the field, so it must not depend on which emit function a command
|
|
14
|
+
# happened to call. The generated models carry no `ge`/`le` metadata to infer it
|
|
15
|
+
# from — if the spec ever declares bounds, read them instead of this set.
|
|
16
|
+
_FRACTION_FIELDS = frozenset({"fraction", "estimate_confidence"})
|
|
17
|
+
|
|
18
|
+
# Piped output must not depend on the terminal that happened to launch it, so a
|
|
19
|
+
# non-tty stdout gets a width no table will wrap at. On a terminal, cells
|
|
20
|
+
# truncate instead of folding: half a UUID on each of two lines cannot be copied.
|
|
21
|
+
_PIPED_WIDTH = 10_000
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _console_for(*, stderr: bool) -> Console:
|
|
25
|
+
# `is_terminal` rather than `isatty()`: it also honours TERM=dumb, NO_COLOR
|
|
26
|
+
# and FORCE_COLOR, which a caller redirecting output may well be setting.
|
|
27
|
+
console = Console(stderr=stderr)
|
|
28
|
+
if not console.is_terminal:
|
|
29
|
+
console.width = _PIPED_WIDTH
|
|
30
|
+
return console
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_console = _console_for(stderr=False)
|
|
34
|
+
# Messages must stay greppable too: an error split across lines defeats
|
|
35
|
+
# `2>&1 | grep`, and a wrapped URL cannot be clicked.
|
|
36
|
+
_stderr = _console_for(stderr=True)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _scalar(value: object) -> str:
|
|
40
|
+
if value is None:
|
|
41
|
+
return "-"
|
|
42
|
+
if isinstance(value, bool):
|
|
43
|
+
return "yes" if value else "no"
|
|
44
|
+
# Credit and runtime figures carry full float precision; 15 decimal places of
|
|
45
|
+
# a unit count is noise in a table. `--json` is unaffected — it dumps the
|
|
46
|
+
# model, so anything doing arithmetic still gets the exact value.
|
|
47
|
+
if isinstance(value, float):
|
|
48
|
+
return f"{value:.2f}"
|
|
49
|
+
if isinstance(value, BaseModel):
|
|
50
|
+
return _scalar(value.model_dump(mode="json"))
|
|
51
|
+
if isinstance(value, dict):
|
|
52
|
+
return json.dumps(value, default=str)
|
|
53
|
+
if isinstance(value, list | tuple):
|
|
54
|
+
return ", ".join(_scalar(item) for item in value)
|
|
55
|
+
return str(value)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _percent(value: object) -> str:
|
|
59
|
+
return "-" if not isinstance(value, int | float) else f"{value:.0%}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _dotted(model: BaseModel, path: str) -> object:
|
|
63
|
+
"""Read ``a.b.c`` off nested models, treating a missing link as ``None``."""
|
|
64
|
+
current: object = model
|
|
65
|
+
for part in path.split("."):
|
|
66
|
+
if current is None:
|
|
67
|
+
return None
|
|
68
|
+
current = getattr(current, part, None)
|
|
69
|
+
return current
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _cell(model: BaseModel, column: Column) -> str:
|
|
73
|
+
_, path = column
|
|
74
|
+
value = _dotted(model, path)
|
|
75
|
+
leaf = path.rsplit(".", 1)[-1]
|
|
76
|
+
return _percent(value) if leaf in _FRACTION_FIELDS else _scalar(value)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def emit(models: Iterable[BaseModel], columns: Sequence[Column], *, as_json: bool) -> None:
|
|
80
|
+
"""Render a collection: raw JSON array, or a table of ``(header, path)`` columns."""
|
|
81
|
+
items = list(models)
|
|
82
|
+
if as_json:
|
|
83
|
+
_raw_json([item.model_dump(mode="json", by_alias=True) for item in items])
|
|
84
|
+
return
|
|
85
|
+
if not items:
|
|
86
|
+
_stderr.print("[dim]no matching items[/dim]")
|
|
87
|
+
return
|
|
88
|
+
table = Table(box=None, pad_edge=False, header_style="bold")
|
|
89
|
+
for column in columns:
|
|
90
|
+
table.add_column(column[0], overflow="ellipsis", no_wrap=True)
|
|
91
|
+
for item in items:
|
|
92
|
+
table.add_row(*(_cell(item, column) for column in columns))
|
|
93
|
+
_console.print(table)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def emit_one(model: BaseModel, fields: Sequence[Column], *, as_json: bool) -> None:
|
|
97
|
+
"""Render a single resource: raw JSON object, or the named fields as rows."""
|
|
98
|
+
if as_json:
|
|
99
|
+
_raw_json(model.model_dump(mode="json", by_alias=True))
|
|
100
|
+
return
|
|
101
|
+
table = Table(box=None, pad_edge=False, show_header=False)
|
|
102
|
+
table.add_column(style="bold")
|
|
103
|
+
table.add_column(overflow="fold")
|
|
104
|
+
for field in fields:
|
|
105
|
+
table.add_row(field[0], _cell(model, field))
|
|
106
|
+
_console.print(table)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def emit_model(model: BaseModel, *, as_json: bool) -> None:
|
|
110
|
+
"""Render every declared field of a single resource.
|
|
111
|
+
|
|
112
|
+
For responses whose shape varies by product — the estimate union, whose
|
|
113
|
+
variants share almost no fields — deriving rows from the model beats a
|
|
114
|
+
hardcoded list that would be wrong for one of them.
|
|
115
|
+
"""
|
|
116
|
+
fields = [(name.replace("_", " ").capitalize(), name) for name in type(model).model_fields]
|
|
117
|
+
emit_one(model, fields, as_json=as_json)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def heading(text: str) -> None:
|
|
121
|
+
"""A section label for a command that prints more than one table."""
|
|
122
|
+
_console.print(f"\n[bold]{text}[/bold]")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _raw_json(payload: object) -> None:
|
|
126
|
+
"""Print JSON on stdout, unstyled.
|
|
127
|
+
|
|
128
|
+
Deliberately not rich's ``print_json``: it soft-wraps to the console width,
|
|
129
|
+
which breaks a long value across lines and can leave the piped output
|
|
130
|
+
unparseable. ``--json`` exists to be piped, so it stays plain.
|
|
131
|
+
"""
|
|
132
|
+
print(json.dumps(payload, indent=2, default=str))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def note(message: str) -> None:
|
|
136
|
+
"""Progress and confirmation text — stderr, so ``--json`` stdout stays parseable."""
|
|
137
|
+
_stderr.print(message)
|
xoople/cli/_spec.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic import ValidationError
|
|
6
|
+
from xoople.cli._errors import UsageError
|
|
7
|
+
from xoople.sdk import create_request_from, models
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _read(path: Path) -> object:
|
|
11
|
+
raw = sys.stdin.read() if str(path) == "-" else path.read_text()
|
|
12
|
+
try:
|
|
13
|
+
return json.loads(raw)
|
|
14
|
+
except ValueError as exc:
|
|
15
|
+
source = "stdin" if str(path) == "-" else str(path)
|
|
16
|
+
raise UsageError(f"{source} is not valid JSON: {exc}") from exc
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _explain(exc: ValidationError) -> str:
|
|
20
|
+
lines = []
|
|
21
|
+
for error in exc.errors():
|
|
22
|
+
location = ".".join(str(part) for part in error["loc"]) or "(root)"
|
|
23
|
+
lines.append(f" {location}: {error['msg']}")
|
|
24
|
+
return "the spec does not match the API schema:\n" + "\n".join(lines)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_create_request(
|
|
28
|
+
path: Path, display_name: str | None = None
|
|
29
|
+
) -> models.CreateAnalysisRequest:
|
|
30
|
+
"""Read a create/estimate body from a JSON file (``-`` for stdin).
|
|
31
|
+
|
|
32
|
+
Which document shapes are accepted is the SDK's rule, not the CLI's; this
|
|
33
|
+
adds only what a CLI owes its user — reading the file and turning a
|
|
34
|
+
validation error into a message that points at the offending field.
|
|
35
|
+
"""
|
|
36
|
+
document = _read(path)
|
|
37
|
+
if not isinstance(document, dict):
|
|
38
|
+
raise UsageError("the spec file must contain a JSON object")
|
|
39
|
+
# A JSON object's keys are strings by construction; `json.loads` just does not
|
|
40
|
+
# say so in its return type.
|
|
41
|
+
body: dict[str, object] = {str(key): value for key, value in document.items()}
|
|
42
|
+
try:
|
|
43
|
+
return create_request_from(body, display_name)
|
|
44
|
+
except ValidationError as exc:
|
|
45
|
+
raise UsageError(_explain(exc)) from exc
|
xoople/cli/_version.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from functools import cache
|
|
2
|
+
from importlib.metadata import PackageNotFoundError
|
|
3
|
+
from importlib.metadata import version as _distribution_version
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@cache
|
|
7
|
+
def version() -> str:
|
|
8
|
+
"""The installed distribution version, reported by ``xoople --version``.
|
|
9
|
+
|
|
10
|
+
Read from installed metadata so ``pyproject.toml`` stays the only place the
|
|
11
|
+
version is written. Falls back to ``0.0.0`` when the package is imported
|
|
12
|
+
from a source tree that was never installed.
|
|
13
|
+
"""
|
|
14
|
+
try:
|
|
15
|
+
return _distribution_version("xoople-cli")
|
|
16
|
+
except PackageNotFoundError:
|
|
17
|
+
return "0.0.0"
|
xoople/cli/access.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from cyclopts import App
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
from xoople.cli._context import SettingsParam
|
|
4
|
+
from xoople.cli._output import emit, emit_model
|
|
5
|
+
|
|
6
|
+
app = App(
|
|
7
|
+
name="access",
|
|
8
|
+
help="Delta Sharing credentials and feature flags for your account.",
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _FlagRow(BaseModel):
|
|
13
|
+
"""A flat row for the flag map, which is a dict rather than a list on the wire."""
|
|
14
|
+
|
|
15
|
+
flag: str
|
|
16
|
+
value: bool
|
|
17
|
+
reason: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command(name="me")
|
|
21
|
+
def me(*, settings: SettingsParam) -> None:
|
|
22
|
+
"""Show your Delta Sharing recipient and the share it reads."""
|
|
23
|
+
emit_model(settings.client().data_access.me(), as_json=settings.as_json)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command(name="token")
|
|
27
|
+
def token(*, settings: SettingsParam) -> None:
|
|
28
|
+
"""Print your Delta Sharing bearer token and host."""
|
|
29
|
+
emit_model(settings.client().data_access.token(), as_json=settings.as_json)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.command(name="features")
|
|
33
|
+
def features(*, settings: SettingsParam) -> None:
|
|
34
|
+
"""Show which feature flags are on for you, and why."""
|
|
35
|
+
result = settings.client().features.get()
|
|
36
|
+
if settings.as_json:
|
|
37
|
+
emit_model(result, as_json=True)
|
|
38
|
+
return
|
|
39
|
+
flags = [
|
|
40
|
+
_FlagRow(flag=name, value=flag.value, reason=flag.reason)
|
|
41
|
+
for name, flag in result.flags.items()
|
|
42
|
+
]
|
|
43
|
+
emit(
|
|
44
|
+
flags,
|
|
45
|
+
[("FLAG", "flag"), ("ON", "value"), ("REASON", "reason")],
|
|
46
|
+
as_json=False,
|
|
47
|
+
)
|
xoople/cli/analyses.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
|
|
4
|
+
from cyclopts import App, Parameter
|
|
5
|
+
from xoople.cli._context import SettingsParam, at_least_one
|
|
6
|
+
from xoople.cli._errors import RunFailed
|
|
7
|
+
from xoople.cli._output import emit, emit_model, emit_one, note
|
|
8
|
+
from xoople.cli._spec import load_create_request
|
|
9
|
+
from xoople.cli.runs import FIELDS as RUN_FIELDS
|
|
10
|
+
from xoople.cli.runs import wait_for_run
|
|
11
|
+
from xoople.sdk import models
|
|
12
|
+
|
|
13
|
+
app = App(
|
|
14
|
+
name="analyses",
|
|
15
|
+
help="Create, inspect and cancel analyses.",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
COLUMNS = [
|
|
19
|
+
("UID", "uid"),
|
|
20
|
+
("DISPLAY NAME", "display_name"),
|
|
21
|
+
("PRODUCT", "product"),
|
|
22
|
+
("STATE", "state"),
|
|
23
|
+
("AOI", "aoi_display_name"),
|
|
24
|
+
("CREATED", "create_time"),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
FIELDS = [
|
|
28
|
+
("Name", "name"),
|
|
29
|
+
("UID", "uid"),
|
|
30
|
+
("Display name", "display_name"),
|
|
31
|
+
("Product", "product"),
|
|
32
|
+
("Product version", "product_version"),
|
|
33
|
+
("State", "state"),
|
|
34
|
+
("State message", "state_message"),
|
|
35
|
+
("Owner", "owner"),
|
|
36
|
+
("AOI", "aoi_display_name"),
|
|
37
|
+
("AOI id", "aoi_id"),
|
|
38
|
+
("Created", "create_time"),
|
|
39
|
+
("Updated", "update_time"),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
_SPEC_FILE = Parameter(name=["--spec-file", "-f"], allow_leading_hyphen=True)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.command(name="list")
|
|
46
|
+
def list_analyses(
|
|
47
|
+
*,
|
|
48
|
+
settings: SettingsParam,
|
|
49
|
+
aoi_id: str | None = None,
|
|
50
|
+
limit: Annotated[int | None, Parameter(validator=at_least_one)] = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""List every analysis, following pagination to completion.
|
|
53
|
+
|
|
54
|
+
Parameters
|
|
55
|
+
----------
|
|
56
|
+
aoi_id:
|
|
57
|
+
Only analyses over this AOI.
|
|
58
|
+
limit:
|
|
59
|
+
Stop after this many, instead of every page.
|
|
60
|
+
"""
|
|
61
|
+
items = settings.client().analyses.list(aoi_id=aoi_id, limit=limit)
|
|
62
|
+
emit(items, COLUMNS, as_json=settings.as_json)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.command(name="get")
|
|
66
|
+
def get(analysis: str, *, settings: SettingsParam) -> None:
|
|
67
|
+
"""Show one analysis.
|
|
68
|
+
|
|
69
|
+
Parameters
|
|
70
|
+
----------
|
|
71
|
+
analysis:
|
|
72
|
+
The analysis to show.
|
|
73
|
+
"""
|
|
74
|
+
emit_one(settings.client().analyses.get(analysis), FIELDS, as_json=settings.as_json)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command(name="create")
|
|
78
|
+
def create(
|
|
79
|
+
*,
|
|
80
|
+
settings: SettingsParam,
|
|
81
|
+
spec_file: Annotated[Path, _SPEC_FILE],
|
|
82
|
+
display_name: str | None = None,
|
|
83
|
+
idempotency_key: str | None = None,
|
|
84
|
+
wait: bool = False,
|
|
85
|
+
poll_interval: float = 5.0,
|
|
86
|
+
wait_timeout: float | None = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Submit an analysis from a spec file.
|
|
89
|
+
|
|
90
|
+
Parameters
|
|
91
|
+
----------
|
|
92
|
+
spec_file:
|
|
93
|
+
JSON spec, or a whole create request. Use - for stdin.
|
|
94
|
+
display_name:
|
|
95
|
+
Label for the analysis; overrides the spec file's.
|
|
96
|
+
idempotency_key:
|
|
97
|
+
Reuse a previous key to replay a submission instead of duplicating it.
|
|
98
|
+
wait:
|
|
99
|
+
Poll the analysis's newest run until it reaches a terminal state.
|
|
100
|
+
poll_interval:
|
|
101
|
+
Seconds between polls while waiting.
|
|
102
|
+
wait_timeout:
|
|
103
|
+
Give up waiting after this many seconds.
|
|
104
|
+
"""
|
|
105
|
+
body = load_create_request(spec_file, display_name)
|
|
106
|
+
analysis = settings.client().analyses.create(body, idempotency_key=idempotency_key)
|
|
107
|
+
if not wait:
|
|
108
|
+
emit_one(analysis, FIELDS, as_json=settings.as_json)
|
|
109
|
+
return
|
|
110
|
+
note(f"[dim]submitted {analysis.uid}; waiting for the initial run[/dim]")
|
|
111
|
+
run = wait_for_run(
|
|
112
|
+
settings.client(), analysis.uid, poll_interval=poll_interval, timeout=wait_timeout
|
|
113
|
+
)
|
|
114
|
+
emit_one(run, RUN_FIELDS, as_json=settings.as_json)
|
|
115
|
+
if run.state is not models.RunState.COMPLETE:
|
|
116
|
+
raise RunFailed(f"run {run.uid} ended in {run.state}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@app.command(name="update")
|
|
120
|
+
def update(
|
|
121
|
+
analysis: str,
|
|
122
|
+
*,
|
|
123
|
+
settings: SettingsParam,
|
|
124
|
+
display_name: str,
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Rename an analysis.
|
|
127
|
+
|
|
128
|
+
Parameters
|
|
129
|
+
----------
|
|
130
|
+
analysis:
|
|
131
|
+
The analysis to rename.
|
|
132
|
+
display_name:
|
|
133
|
+
The new label.
|
|
134
|
+
"""
|
|
135
|
+
body = models.UpdateAnalysisRequest(display_name=display_name)
|
|
136
|
+
emit_one(settings.client().analyses.update(analysis, body), FIELDS, as_json=settings.as_json)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@app.command(name="cancel")
|
|
140
|
+
def cancel(analysis: str, *, settings: SettingsParam) -> None:
|
|
141
|
+
"""Cancel an analysis.
|
|
142
|
+
|
|
143
|
+
Parameters
|
|
144
|
+
----------
|
|
145
|
+
analysis:
|
|
146
|
+
The analysis to cancel.
|
|
147
|
+
"""
|
|
148
|
+
emit_one(settings.client().analyses.cancel(analysis), FIELDS, as_json=settings.as_json)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@app.command(name="estimate")
|
|
152
|
+
def estimate(*, settings: SettingsParam, spec_file: Annotated[Path, _SPEC_FILE]) -> None:
|
|
153
|
+
"""Estimate what a spec would cost, without submitting it.
|
|
154
|
+
|
|
155
|
+
Parameters
|
|
156
|
+
----------
|
|
157
|
+
spec_file:
|
|
158
|
+
JSON spec, or a whole create request. Use - for stdin.
|
|
159
|
+
"""
|
|
160
|
+
result = settings.client().analyses.estimate(load_create_request(spec_file))
|
|
161
|
+
emit_model(result, as_json=settings.as_json)
|
xoople/cli/catalog.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from cyclopts import App
|
|
2
|
+
from xoople.cli._context import SettingsParam
|
|
3
|
+
from xoople.cli._output import emit, emit_model, heading
|
|
4
|
+
|
|
5
|
+
app = App(
|
|
6
|
+
name="catalog",
|
|
7
|
+
help="Browse products, measures and analysis designs.",
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@app.command(name="products")
|
|
12
|
+
def products(*, settings: SettingsParam) -> None:
|
|
13
|
+
"""List the products you may submit to."""
|
|
14
|
+
result = settings.client().catalog.products()
|
|
15
|
+
emit(
|
|
16
|
+
result.products,
|
|
17
|
+
[
|
|
18
|
+
("ID", "product_id"),
|
|
19
|
+
("NAME", "display_name"),
|
|
20
|
+
("VERSION", "version"),
|
|
21
|
+
("LIFECYCLE", "lifecycle"),
|
|
22
|
+
("OUTPUTS", "output_kinds"),
|
|
23
|
+
],
|
|
24
|
+
as_json=settings.as_json,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.command(name="measures")
|
|
29
|
+
def measures(
|
|
30
|
+
*,
|
|
31
|
+
settings: SettingsParam,
|
|
32
|
+
product: str | None = None,
|
|
33
|
+
domain: list[str] | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""List the bands, indices and contrasts you can request.
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
product:
|
|
40
|
+
Only measures this product offers.
|
|
41
|
+
domain:
|
|
42
|
+
Only these domains. Repeat to pass several.
|
|
43
|
+
"""
|
|
44
|
+
result = settings.client().catalog.measures(product=product, domain=domain)
|
|
45
|
+
if settings.as_json:
|
|
46
|
+
emit_model(result, as_json=True)
|
|
47
|
+
return
|
|
48
|
+
heading("bands")
|
|
49
|
+
emit(
|
|
50
|
+
result.bands,
|
|
51
|
+
[
|
|
52
|
+
("ABBREVIATION", "abbreviation"),
|
|
53
|
+
("NAME", "band_name"),
|
|
54
|
+
("SINCE", "since_version"),
|
|
55
|
+
("EXPERIMENTAL", "experimental"),
|
|
56
|
+
],
|
|
57
|
+
as_json=False,
|
|
58
|
+
)
|
|
59
|
+
for label, items in (
|
|
60
|
+
("indices", result.indices),
|
|
61
|
+
("contrasts", result.contrasts),
|
|
62
|
+
("indicators", result.indicators),
|
|
63
|
+
):
|
|
64
|
+
heading(label)
|
|
65
|
+
emit(
|
|
66
|
+
items or [],
|
|
67
|
+
[
|
|
68
|
+
("ABBREVIATION", "abbreviation"),
|
|
69
|
+
("DOMAIN", "domain"),
|
|
70
|
+
("FORMULA", "formula"),
|
|
71
|
+
("REQUIRED BANDS", "required_bands"),
|
|
72
|
+
("SINCE", "since_version"),
|
|
73
|
+
],
|
|
74
|
+
as_json=False,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.command(name="designs")
|
|
79
|
+
def designs(*, settings: SettingsParam, product: str | None = None) -> None:
|
|
80
|
+
"""List the starting-point analysis designs. Their `spec_fragment` seeds a spec file.
|
|
81
|
+
|
|
82
|
+
Parameters
|
|
83
|
+
----------
|
|
84
|
+
product:
|
|
85
|
+
Only designs for this product.
|
|
86
|
+
"""
|
|
87
|
+
result = settings.client().analysis_designs.list(product=product)
|
|
88
|
+
emit(
|
|
89
|
+
result.designs,
|
|
90
|
+
[
|
|
91
|
+
("ID", "design_id"),
|
|
92
|
+
("NAME", "display_name"),
|
|
93
|
+
("PRODUCT", "product_id"),
|
|
94
|
+
("DOMAINS", "domains"),
|
|
95
|
+
("OUTPUT", "output_kind"),
|
|
96
|
+
],
|
|
97
|
+
as_json=settings.as_json,
|
|
98
|
+
)
|