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 ADDED
@@ -0,0 +1,5 @@
1
+ from xoople.cli._version import version
2
+
3
+ __version__ = version()
4
+
5
+ __all__ = ["__version__"]
xoople/cli/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from xoople.cli.main import main
2
+
3
+ main()
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
+ )