xoople-cli 0.4.0__tar.gz → 0.6.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.
- {xoople_cli-0.4.0 → xoople_cli-0.6.0}/PKG-INFO +11 -6
- {xoople_cli-0.4.0 → xoople_cli-0.6.0}/README.md +9 -4
- {xoople_cli-0.4.0 → xoople_cli-0.6.0}/pyproject.toml +3 -3
- {xoople_cli-0.4.0 → xoople_cli-0.6.0}/pyproject.toml.orig +3 -3
- {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/_context.py +2 -0
- {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/_errors.py +21 -4
- {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/_output.py +55 -0
- {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/access.py +4 -4
- {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/analyses.py +10 -36
- {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/catalog.py +2 -2
- {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/main.py +2 -0
- {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/outputs.py +40 -1
- {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/packages.py +23 -1
- xoople_cli-0.6.0/src/xoople/cli/questions.py +209 -0
- {xoople_cli-0.4.0 → xoople_cli-0.6.0}/LICENSE +0 -0
- {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/__init__.py +0 -0
- {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/__main__.py +0 -0
- {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/_ciam.py +0 -0
- {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/_spec.py +0 -0
- {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/_version.py +0 -0
- {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/auth.py +0 -0
- {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/events.py +0 -0
- {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/projects.py +0 -0
- {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/runs.py +0 -0
- {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/schedules.py +0 -0
- {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/usage.py +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: xoople-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Command-line interface for the Xoople Products API.
|
|
5
5
|
License-Expression: LicenseRef-Xoople-Proprietary
|
|
6
6
|
License-File: LICENSE
|
|
7
7
|
Classifier: Development Status :: 3 - Alpha
|
|
8
8
|
Classifier: Programming Language :: Python :: 3.12
|
|
9
|
-
Requires-Dist: xoople-sdk>=0.
|
|
9
|
+
Requires-Dist: xoople-sdk>=0.8.0
|
|
10
10
|
Requires-Dist: cyclopts>=4.18,<5
|
|
11
11
|
Requires-Dist: rich>=13.7
|
|
12
12
|
Requires-Python: >=3.12
|
|
@@ -31,13 +31,14 @@ output pipes into `jq`.
|
|
|
31
31
|
|
|
32
32
|
| Group | Commands |
|
|
33
33
|
| --- | --- |
|
|
34
|
-
| `analyses` | `list` `get` `create` `update` `cancel` `estimate` |
|
|
34
|
+
| `analyses` | `list` `get` `create` `update` `cancel` `delete` `estimate` |
|
|
35
35
|
| `runs` | `list` `get` |
|
|
36
36
|
| `outputs` | `list` `get` `access-url` |
|
|
37
37
|
| `schedules` | `list` `get` `create` `pause` `resume` `cancel` `run` `estimate-run` |
|
|
38
38
|
| `events` | `list` `get` |
|
|
39
39
|
| `usage` | `history` `limits` |
|
|
40
40
|
| `catalog` | `products` `measures` `designs` |
|
|
41
|
+
| `questions` | `list` `show` `ask` |
|
|
41
42
|
| `access` | `me` `token` `features` |
|
|
42
43
|
| `auth` | `login` `logout` `status` |
|
|
43
44
|
|
|
@@ -89,11 +90,13 @@ The product spec is deeply nested and differs per product, so `create` and
|
|
|
89
90
|
`estimate` take it as JSON rather than as flags:
|
|
90
91
|
|
|
91
92
|
```console
|
|
92
|
-
$ xoople catalog designs --json | jq '.[0].spec_fragment' > spec.json
|
|
93
|
-
$ # fill in period and aoi, then:
|
|
93
|
+
$ xoople catalog designs --product tabular_time_series --json | jq '.[0].spec_fragment | {product: "tabular_time_series", period: {start: "2024-01-01", end: "2024-06-30"}, aoi: [-1.5, 52.0, -1.0, 52.5], spatial_partition: {type: "h3", target_cells: 5000}, measures}' > spec.json
|
|
94
94
|
$ xoople analyses create -f spec.json --display-name "South Wales NDVI" --wait
|
|
95
95
|
```
|
|
96
96
|
|
|
97
|
+
A design's `spec_fragment` holds only what to measure, so the `jq` filter adds the
|
|
98
|
+
product, period, area and grid around its `measures`. The cadence defaults to monthly.
|
|
99
|
+
|
|
97
100
|
The file holds either the bare spec or a whole create request (anything with a
|
|
98
101
|
`spec` key). `-f -` reads stdin. `--wait` polls the initial run and exits
|
|
99
102
|
non-zero if it does not reach `COMPLETE`.
|
|
@@ -105,4 +108,6 @@ non-zero if it does not reach `COMPLETE`.
|
|
|
105
108
|
`5` not found · `6` bad request or validation · `7` conflict · `8` rate limited ·
|
|
106
109
|
`9` server error · `10` connection failure · `11` unreadable response ·
|
|
107
110
|
`12` run finished in a non-`COMPLETE` state · `13` `--wait` timed out ·
|
|
108
|
-
`14` the analysis was deleted
|
|
111
|
+
`14` the analysis was deleted · `15` the view a question reads is still
|
|
112
|
+
being built, ask again shortly · `16` that view's last build failed, refresh
|
|
113
|
+
it explicitly; `17` the project was deleted.
|
|
@@ -16,13 +16,14 @@ output pipes into `jq`.
|
|
|
16
16
|
|
|
17
17
|
| Group | Commands |
|
|
18
18
|
| --- | --- |
|
|
19
|
-
| `analyses` | `list` `get` `create` `update` `cancel` `estimate` |
|
|
19
|
+
| `analyses` | `list` `get` `create` `update` `cancel` `delete` `estimate` |
|
|
20
20
|
| `runs` | `list` `get` |
|
|
21
21
|
| `outputs` | `list` `get` `access-url` |
|
|
22
22
|
| `schedules` | `list` `get` `create` `pause` `resume` `cancel` `run` `estimate-run` |
|
|
23
23
|
| `events` | `list` `get` |
|
|
24
24
|
| `usage` | `history` `limits` |
|
|
25
25
|
| `catalog` | `products` `measures` `designs` |
|
|
26
|
+
| `questions` | `list` `show` `ask` |
|
|
26
27
|
| `access` | `me` `token` `features` |
|
|
27
28
|
| `auth` | `login` `logout` `status` |
|
|
28
29
|
|
|
@@ -74,11 +75,13 @@ The product spec is deeply nested and differs per product, so `create` and
|
|
|
74
75
|
`estimate` take it as JSON rather than as flags:
|
|
75
76
|
|
|
76
77
|
```console
|
|
77
|
-
$ xoople catalog designs --json | jq '.[0].spec_fragment' > spec.json
|
|
78
|
-
$ # fill in period and aoi, then:
|
|
78
|
+
$ xoople catalog designs --product tabular_time_series --json | jq '.[0].spec_fragment | {product: "tabular_time_series", period: {start: "2024-01-01", end: "2024-06-30"}, aoi: [-1.5, 52.0, -1.0, 52.5], spatial_partition: {type: "h3", target_cells: 5000}, measures}' > spec.json
|
|
79
79
|
$ xoople analyses create -f spec.json --display-name "South Wales NDVI" --wait
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
+
A design's `spec_fragment` holds only what to measure, so the `jq` filter adds the
|
|
83
|
+
product, period, area and grid around its `measures`. The cadence defaults to monthly.
|
|
84
|
+
|
|
82
85
|
The file holds either the bare spec or a whole create request (anything with a
|
|
83
86
|
`spec` key). `-f -` reads stdin. `--wait` polls the initial run and exits
|
|
84
87
|
non-zero if it does not reach `COMPLETE`.
|
|
@@ -90,4 +93,6 @@ non-zero if it does not reach `COMPLETE`.
|
|
|
90
93
|
`5` not found · `6` bad request or validation · `7` conflict · `8` rate limited ·
|
|
91
94
|
`9` server error · `10` connection failure · `11` unreadable response ·
|
|
92
95
|
`12` run finished in a non-`COMPLETE` state · `13` `--wait` timed out ·
|
|
93
|
-
`14` the analysis was deleted
|
|
96
|
+
`14` the analysis was deleted · `15` the view a question reads is still
|
|
97
|
+
being built, ask again shortly · `16` that view's last build failed, refresh
|
|
98
|
+
it explicitly; `17` the project was deleted.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "xoople-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.6.0"
|
|
4
4
|
description = "Command-line interface for the Xoople Products API."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -11,7 +11,7 @@ classifiers = [
|
|
|
11
11
|
"Programming Language :: Python :: 3.12",
|
|
12
12
|
]
|
|
13
13
|
dependencies = [
|
|
14
|
-
"xoople-sdk>=0.
|
|
14
|
+
"xoople-sdk>=0.8.0",
|
|
15
15
|
"cyclopts>=4.18,<5",
|
|
16
16
|
"rich>=13.7",
|
|
17
17
|
]
|
|
@@ -23,7 +23,7 @@ xoople = "xoople.cli.main:main"
|
|
|
23
23
|
Homepage = "https://xoople.com"
|
|
24
24
|
|
|
25
25
|
[build-system]
|
|
26
|
-
requires = ["uv_build>=0.
|
|
26
|
+
requires = ["uv_build>=0.13.0,<0.14"]
|
|
27
27
|
build-backend = "uv_build"
|
|
28
28
|
|
|
29
29
|
[tool.uv.build-backend]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "xoople-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.6.0"
|
|
4
4
|
description = "Command-line interface for the Xoople Products API."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -13,7 +13,7 @@ classifiers = [
|
|
|
13
13
|
"Programming Language :: Python :: 3.12",
|
|
14
14
|
]
|
|
15
15
|
dependencies = [
|
|
16
|
-
"xoople-sdk>=0.
|
|
16
|
+
"xoople-sdk>=0.8.0",
|
|
17
17
|
# Cyclopts parses docstrings for parameter help so signatures stay plain,
|
|
18
18
|
# supports union types and Pydantic models natively, and owns its parser.
|
|
19
19
|
# Global options + dependency injection go through the meta-app launcher
|
|
@@ -29,7 +29,7 @@ xoople = "xoople.cli.main:main"
|
|
|
29
29
|
Homepage = "https://xoople.com"
|
|
30
30
|
|
|
31
31
|
[build-system]
|
|
32
|
-
requires = ["uv_build>=0.
|
|
32
|
+
requires = ["uv_build>=0.13.0,<0.14"]
|
|
33
33
|
build-backend = "uv_build"
|
|
34
34
|
|
|
35
35
|
[tool.uv.build-backend]
|
|
@@ -5,6 +5,7 @@ from typing import Annotated, Protocol
|
|
|
5
5
|
from cyclopts import Parameter
|
|
6
6
|
from xoople.cli import _ciam
|
|
7
7
|
from xoople.cli._errors import UsageError
|
|
8
|
+
from xoople.cli._version import version as cli_version
|
|
8
9
|
from xoople.sdk import Config, CredentialProvider, XoopleClient
|
|
9
10
|
from xoople.sdk import session as session_file
|
|
10
11
|
from xoople.sdk.auth import (
|
|
@@ -108,5 +109,6 @@ def build_client(credentials: Credentials) -> XoopleClient:
|
|
|
108
109
|
credentials=_credential_provider(credentials),
|
|
109
110
|
timeout=credentials.timeout,
|
|
110
111
|
max_retries=credentials.max_retries,
|
|
112
|
+
user_agent_prefix=f"xoople-cli/{cli_version()}",
|
|
111
113
|
)
|
|
112
114
|
)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from rich.markup import escape
|
|
1
2
|
from xoople.cli._output import note
|
|
2
3
|
from xoople.sdk import errors
|
|
3
4
|
|
|
@@ -6,6 +7,7 @@ EXIT_CODES: dict[type[errors.XoopleError], int] = {
|
|
|
6
7
|
errors.PermissionDeniedError: 4,
|
|
7
8
|
# Before its parent: `exit_code` takes the first class the error is an instance of.
|
|
8
9
|
errors.AnalysisDeletedError: 14,
|
|
10
|
+
errors.ProjectDeletedError: 17,
|
|
9
11
|
errors.NotFoundError: 5,
|
|
10
12
|
errors.BadRequestError: 6,
|
|
11
13
|
errors.ValidationError: 6,
|
|
@@ -20,6 +22,8 @@ _FALLBACK_EXIT = 1
|
|
|
20
22
|
USAGE_EXIT = 2
|
|
21
23
|
FAILED_RUN_EXIT = 12
|
|
22
24
|
WAIT_TIMEOUT_EXIT = 13
|
|
25
|
+
QUESTION_COMPUTING_EXIT = 15
|
|
26
|
+
QUESTION_FAILED_EXIT = 16
|
|
23
27
|
|
|
24
28
|
|
|
25
29
|
class CliError(Exception):
|
|
@@ -56,6 +60,18 @@ class WaitTimeout(CliError):
|
|
|
56
60
|
exit_code = WAIT_TIMEOUT_EXIT
|
|
57
61
|
|
|
58
62
|
|
|
63
|
+
class QuestionComputing(CliError):
|
|
64
|
+
"""The view a question reads is still being built; ask again shortly."""
|
|
65
|
+
|
|
66
|
+
exit_code = QUESTION_COMPUTING_EXIT
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class QuestionFailed(CliError):
|
|
70
|
+
"""The last build of the view a question reads failed; refresh it explicitly."""
|
|
71
|
+
|
|
72
|
+
exit_code = QUESTION_FAILED_EXIT
|
|
73
|
+
|
|
74
|
+
|
|
59
75
|
def exit_code(exc: errors.XoopleError) -> int:
|
|
60
76
|
for cls, code in EXIT_CODES.items():
|
|
61
77
|
if isinstance(exc, cls):
|
|
@@ -64,10 +80,11 @@ def exit_code(exc: errors.XoopleError) -> int:
|
|
|
64
80
|
|
|
65
81
|
|
|
66
82
|
def _describe(exc: errors.XoopleError) -> str:
|
|
83
|
+
# The API text is data, not markup: a detail that echoes a cell can read like `[red]`.
|
|
67
84
|
if isinstance(exc, errors.XoopleAPIError):
|
|
68
|
-
trace = f" [dim](trace {exc.trace_id})[/dim]" if exc.trace_id else ""
|
|
69
|
-
return f"[red]error[/red] {exc.status} {exc.code}: {exc.message}{trace}"
|
|
70
|
-
return f"[red]error[/red] {exc}"
|
|
85
|
+
trace = f" [dim](trace {escape(str(exc.trace_id))})[/dim]" if exc.trace_id else ""
|
|
86
|
+
return f"[red]error[/red] {exc.status} {escape(str(exc.code))}: {escape(str(exc.message))}{trace}"
|
|
87
|
+
return f"[red]error[/red] {escape(str(exc))}"
|
|
71
88
|
|
|
72
89
|
|
|
73
90
|
def report(exc: errors.XoopleError | CliError) -> int:
|
|
@@ -80,5 +97,5 @@ def report(exc: errors.XoopleError | CliError) -> int:
|
|
|
80
97
|
if isinstance(exc, errors.XoopleError):
|
|
81
98
|
note(_describe(exc))
|
|
82
99
|
return exit_code(exc)
|
|
83
|
-
note(f"[red]error[/red] {exc}")
|
|
100
|
+
note(f"[red]error[/red] {escape(str(exc))}")
|
|
84
101
|
return exc.exit_code
|
|
@@ -4,6 +4,7 @@ from collections.abc import Iterable, Sequence
|
|
|
4
4
|
from pydantic import BaseModel
|
|
5
5
|
from rich.console import Console
|
|
6
6
|
from rich.table import Table
|
|
7
|
+
from rich.text import Text
|
|
7
8
|
|
|
8
9
|
type Column = tuple[str, str]
|
|
9
10
|
|
|
@@ -103,6 +104,36 @@ def emit_one(model: BaseModel, fields: Sequence[Column], *, as_json: bool) -> No
|
|
|
103
104
|
_console.print(table)
|
|
104
105
|
|
|
105
106
|
|
|
107
|
+
def emit_grid(
|
|
108
|
+
model: BaseModel,
|
|
109
|
+
headers: Sequence[str],
|
|
110
|
+
rows: Iterable[Sequence[object]],
|
|
111
|
+
*,
|
|
112
|
+
as_json: bool,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Render positional rows under their headers: raw JSON of the whole
|
|
115
|
+
response, or a table whose columns come from the data, not a declared list."""
|
|
116
|
+
if as_json:
|
|
117
|
+
_raw_json(model.model_dump(mode="json", by_alias=True))
|
|
118
|
+
return
|
|
119
|
+
items = [list(row) for row in rows]
|
|
120
|
+
if not items:
|
|
121
|
+
_stderr.print("[dim]no rows[/dim]")
|
|
122
|
+
return
|
|
123
|
+
# Literal text throughout: a cell is customer data (a feature id can read
|
|
124
|
+
# like rich markup) and a measure keeps its full precision.
|
|
125
|
+
table = Table(box=None, pad_edge=False, header_style="bold")
|
|
126
|
+
for header in headers:
|
|
127
|
+
table.add_column(Text(header), overflow="ellipsis", no_wrap=True)
|
|
128
|
+
for row in items:
|
|
129
|
+
table.add_row(*(Text(_grid_cell(value)) for value in row))
|
|
130
|
+
_console.print(table)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _grid_cell(value: object) -> str:
|
|
134
|
+
return repr(value) if isinstance(value, float) else _scalar(value)
|
|
135
|
+
|
|
136
|
+
|
|
106
137
|
def emit_model(model: BaseModel, *, as_json: bool) -> None:
|
|
107
138
|
"""Render every declared field of a single resource.
|
|
108
139
|
|
|
@@ -114,6 +145,30 @@ def emit_model(model: BaseModel, *, as_json: bool) -> None:
|
|
|
114
145
|
emit_one(model, fields, as_json=as_json)
|
|
115
146
|
|
|
116
147
|
|
|
148
|
+
def emit_rows(headers: Sequence[str], rows: Iterable[Sequence[object]]) -> None:
|
|
149
|
+
"""Render rows that are already positional — a question's answer, say.
|
|
150
|
+
|
|
151
|
+
Goes through the same console as `emit`, so piped output does not depend
|
|
152
|
+
on the width of the terminal that launched it.
|
|
153
|
+
"""
|
|
154
|
+
table = Table(box=None, pad_edge=False, header_style="bold")
|
|
155
|
+
for header in headers:
|
|
156
|
+
table.add_column(header, overflow="ellipsis", no_wrap=True)
|
|
157
|
+
for row in rows:
|
|
158
|
+
table.add_row(*(_scalar(cell) for cell in row))
|
|
159
|
+
_console.print(table)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def emit_pairs(pairs: Iterable[tuple[str, object]]) -> None:
|
|
163
|
+
"""Render name/value lines for values that are not a model's fields."""
|
|
164
|
+
table = Table(box=None, pad_edge=False, show_header=False)
|
|
165
|
+
table.add_column(style="bold")
|
|
166
|
+
table.add_column(overflow="fold")
|
|
167
|
+
for name, value in pairs:
|
|
168
|
+
table.add_row(name, _scalar(value))
|
|
169
|
+
_console.print(table)
|
|
170
|
+
|
|
171
|
+
|
|
117
172
|
def heading(text: str) -> None:
|
|
118
173
|
"""A section label for a command that prints more than one table."""
|
|
119
174
|
_console.print(f"\n[bold]{text}[/bold]")
|
|
@@ -8,7 +8,7 @@ from xoople.cli._output import emit, emit_model
|
|
|
8
8
|
|
|
9
9
|
app = App(
|
|
10
10
|
name="access",
|
|
11
|
-
help="Delta Sharing credentials and
|
|
11
|
+
help="Delta Sharing credentials and preview features for your account.",
|
|
12
12
|
)
|
|
13
13
|
|
|
14
14
|
|
|
@@ -22,7 +22,7 @@ class _FlagRow(BaseModel):
|
|
|
22
22
|
|
|
23
23
|
@app.command(name="me")
|
|
24
24
|
def me(*, settings: SettingsParam) -> None:
|
|
25
|
-
"""Show your Delta Sharing recipient and the
|
|
25
|
+
"""Show your Delta Sharing recipient and the schema of your org's shared tables."""
|
|
26
26
|
emit_model(settings.client().data_access.me(), as_json=settings.as_json)
|
|
27
27
|
|
|
28
28
|
|
|
@@ -67,7 +67,7 @@ def rotate_token(
|
|
|
67
67
|
|
|
68
68
|
@app.command(name="features")
|
|
69
69
|
def features(*, settings: SettingsParam) -> None:
|
|
70
|
-
"""Show which
|
|
70
|
+
"""Show which preview features are on for you, and why."""
|
|
71
71
|
result = settings.client().features.get()
|
|
72
72
|
if settings.as_json:
|
|
73
73
|
emit_model(result, as_json=True)
|
|
@@ -78,6 +78,6 @@ def features(*, settings: SettingsParam) -> None:
|
|
|
78
78
|
]
|
|
79
79
|
emit(
|
|
80
80
|
flags,
|
|
81
|
-
[("
|
|
81
|
+
[("PREVIEW FEATURE", "flag"), ("ON", "value"), ("REASON", "reason")],
|
|
82
82
|
as_json=False,
|
|
83
83
|
)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
|
-
from typing import Annotated
|
|
2
|
+
from typing import Annotated
|
|
3
3
|
|
|
4
4
|
from cyclopts import App, Parameter
|
|
5
5
|
from xoople.cli._context import SettingsParam, at_least_one
|
|
@@ -29,7 +29,8 @@ FIELDS = [
|
|
|
29
29
|
("UID", "uid"),
|
|
30
30
|
("Display name", "display_name"),
|
|
31
31
|
("Product", "product"),
|
|
32
|
-
("
|
|
32
|
+
("Schema version", "schema_version"),
|
|
33
|
+
("Output schema outdated", "output_schema_outdated"),
|
|
33
34
|
("State", "state"),
|
|
34
35
|
("State message", "state_message"),
|
|
35
36
|
("Owner", "owner"),
|
|
@@ -178,46 +179,19 @@ def cancel(analysis: str, *, settings: SettingsParam) -> None:
|
|
|
178
179
|
emit_one(settings.client().analyses.cancel(analysis), FIELDS, as_json=settings.as_json)
|
|
179
180
|
|
|
180
181
|
|
|
181
|
-
@app.command(name="
|
|
182
|
-
def
|
|
183
|
-
analysis
|
|
184
|
-
*,
|
|
185
|
-
settings: SettingsParam,
|
|
186
|
-
scope: Literal["org"] = "org",
|
|
187
|
-
) -> None:
|
|
188
|
-
"""Let your organisation read one analysis.
|
|
189
|
-
|
|
190
|
-
Anyone in your organisation can then open it by name. It stays out of their
|
|
191
|
-
listings, and the share grants them no reuse action and no access to the
|
|
192
|
-
underlying data. Only the owner may share; repeating the call changes
|
|
193
|
-
nothing.
|
|
194
|
-
|
|
195
|
-
Parameters
|
|
196
|
-
----------
|
|
197
|
-
analysis:
|
|
198
|
-
The analysis to share.
|
|
199
|
-
scope:
|
|
200
|
-
Who to share with. Only your own organisation may be named.
|
|
201
|
-
"""
|
|
202
|
-
emit_one(settings.client().analyses.share(analysis, scope), FIELDS, as_json=settings.as_json)
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
@app.command(name="unshare")
|
|
206
|
-
def unshare(analysis: str, *, settings: SettingsParam, share: str = "org") -> None:
|
|
207
|
-
"""Revoke one grant on an analysis.
|
|
182
|
+
@app.command(name="delete")
|
|
183
|
+
def delete(analysis: str, *, settings: SettingsParam) -> None:
|
|
184
|
+
"""Delete a settled analysis and its outputs.
|
|
208
185
|
|
|
209
|
-
|
|
186
|
+
Removal runs in the background, so the analysis shows as DELETING. Once it
|
|
187
|
+
finishes, `analyses get` exits with the deleted-analysis code.
|
|
210
188
|
|
|
211
189
|
Parameters
|
|
212
190
|
----------
|
|
213
191
|
analysis:
|
|
214
|
-
The analysis to
|
|
215
|
-
share:
|
|
216
|
-
Which grant to revoke. `org` names your whole organisation, the only
|
|
217
|
-
audience an analysis can be shared with today.
|
|
192
|
+
The analysis to delete. Needs its owner; cancel a running analysis first.
|
|
218
193
|
"""
|
|
219
|
-
settings.client().analyses.
|
|
220
|
-
note(f"[dim]{analysis} is no longer shared with {share}[/dim]")
|
|
194
|
+
emit_one(settings.client().analyses.delete(analysis), FIELDS, as_json=settings.as_json)
|
|
221
195
|
|
|
222
196
|
|
|
223
197
|
@app.command(name="run-again")
|
|
@@ -17,7 +17,7 @@ def products(*, settings: SettingsParam) -> None:
|
|
|
17
17
|
[
|
|
18
18
|
("ID", "product_id"),
|
|
19
19
|
("NAME", "display_name"),
|
|
20
|
-
("VERSION", "
|
|
20
|
+
("SCHEMA VERSION", "schema_version"),
|
|
21
21
|
("LIFECYCLE", "lifecycle"),
|
|
22
22
|
("OUTPUTS", "output_kinds"),
|
|
23
23
|
],
|
|
@@ -121,7 +121,7 @@ def cloud_mask_presets(*, settings: SettingsParam) -> None:
|
|
|
121
121
|
|
|
122
122
|
@app.command(name="designs")
|
|
123
123
|
def designs(*, settings: SettingsParam, product: str | None = None) -> None:
|
|
124
|
-
"""List the starting-point analysis designs. Their `spec_fragment` seeds
|
|
124
|
+
"""List the starting-point analysis designs. Their `spec_fragment` seeds measures or bands only.
|
|
125
125
|
|
|
126
126
|
Parameters
|
|
127
127
|
----------
|
|
@@ -10,6 +10,7 @@ from xoople.cli import (
|
|
|
10
10
|
outputs,
|
|
11
11
|
packages,
|
|
12
12
|
projects,
|
|
13
|
+
questions,
|
|
13
14
|
runs,
|
|
14
15
|
schedules,
|
|
15
16
|
usage,
|
|
@@ -56,6 +57,7 @@ def create_app(
|
|
|
56
57
|
app.command(events.app)
|
|
57
58
|
app.command(usage.app)
|
|
58
59
|
app.command(catalog.app)
|
|
60
|
+
app.command(questions.app)
|
|
59
61
|
app.command(access.app)
|
|
60
62
|
app.command(auth.app)
|
|
61
63
|
|
|
@@ -2,7 +2,7 @@ from typing import Annotated
|
|
|
2
2
|
|
|
3
3
|
from cyclopts import App, Parameter
|
|
4
4
|
from xoople.cli._context import SettingsParam, at_least_one
|
|
5
|
-
from xoople.cli._output import emit, emit_model
|
|
5
|
+
from xoople.cli._output import emit, emit_grid, emit_model, note
|
|
6
6
|
|
|
7
7
|
app = App(
|
|
8
8
|
name="outputs",
|
|
@@ -94,6 +94,45 @@ def summary(analysis: str, output: str, *, settings: SettingsParam) -> None:
|
|
|
94
94
|
)
|
|
95
95
|
|
|
96
96
|
|
|
97
|
+
@app.command(name="preview")
|
|
98
|
+
def preview(
|
|
99
|
+
analysis: str,
|
|
100
|
+
output: str,
|
|
101
|
+
*,
|
|
102
|
+
settings: SettingsParam,
|
|
103
|
+
limit: Annotated[int | None, Parameter(validator=at_least_one)] = None,
|
|
104
|
+
columns: str | None = None,
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Show the first rows of an output's table, in no particular order.
|
|
107
|
+
|
|
108
|
+
Parameters
|
|
109
|
+
----------
|
|
110
|
+
analysis:
|
|
111
|
+
The analysis the output belongs to.
|
|
112
|
+
output:
|
|
113
|
+
The output whose rows to show.
|
|
114
|
+
limit:
|
|
115
|
+
Rows to show, up to 100 (also the default). For every row, read the
|
|
116
|
+
table instead.
|
|
117
|
+
columns:
|
|
118
|
+
Comma-separated column names to show, in this order; omit for every column.
|
|
119
|
+
"""
|
|
120
|
+
result = settings.client().analyses.outputs.get_preview(
|
|
121
|
+
analysis,
|
|
122
|
+
output,
|
|
123
|
+
limit=limit,
|
|
124
|
+
columns=[name.strip() for name in columns.split(",")] if columns is not None else None,
|
|
125
|
+
)
|
|
126
|
+
emit_grid(
|
|
127
|
+
result,
|
|
128
|
+
[column.name for column in result.columns],
|
|
129
|
+
result.rows,
|
|
130
|
+
as_json=settings.as_json,
|
|
131
|
+
)
|
|
132
|
+
if result.truncated and not settings.as_json:
|
|
133
|
+
note(f"[dim]showing the first {len(result.rows)} rows; the table holds more[/dim]")
|
|
134
|
+
|
|
135
|
+
|
|
97
136
|
@app.command(name="refresh-summary")
|
|
98
137
|
def refresh_summary(analysis: str, output: str, *, settings: SettingsParam) -> None:
|
|
99
138
|
"""Start (or join) an asynchronous recomputation of an output's summary.
|
|
@@ -2,7 +2,7 @@ from typing import Annotated
|
|
|
2
2
|
|
|
3
3
|
from cyclopts import App, Parameter
|
|
4
4
|
from xoople.cli._context import SettingsParam, at_least_one
|
|
5
|
-
from xoople.cli._output import emit, emit_model
|
|
5
|
+
from xoople.cli._output import emit, emit_model, note
|
|
6
6
|
|
|
7
7
|
app = App(
|
|
8
8
|
name="packages",
|
|
@@ -90,3 +90,25 @@ def access_url(
|
|
|
90
90
|
analysis, package, access_ttl_hours=access_ttl_hours
|
|
91
91
|
)
|
|
92
92
|
emit_model(result, as_json=settings.as_json)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command(name="delete")
|
|
96
|
+
def delete(analysis: str, package: str, *, settings: SettingsParam) -> None:
|
|
97
|
+
"""Delete a package and its files; the source table and other packages stay.
|
|
98
|
+
|
|
99
|
+
Cleanup runs in the background, so the package shows as DELETING until
|
|
100
|
+
`packages get` reports DELETED. Run this again on a DELETE_FAILED package
|
|
101
|
+
to retry it.
|
|
102
|
+
|
|
103
|
+
Parameters
|
|
104
|
+
----------
|
|
105
|
+
analysis:
|
|
106
|
+
The analysis the package belongs to.
|
|
107
|
+
package:
|
|
108
|
+
The package to delete. Needs its owner; a package still being built cannot be deleted.
|
|
109
|
+
"""
|
|
110
|
+
result = settings.client().analyses.packages.delete(analysis, package)
|
|
111
|
+
if result is None:
|
|
112
|
+
note(f"[dim]{package} is already deleted[/dim]")
|
|
113
|
+
return
|
|
114
|
+
emit_model(result, as_json=settings.as_json)
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Browse the question catalogue and ask one of a completed output."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
from cyclopts import App, Parameter
|
|
6
|
+
from xoople.cli._context import SettingsParam
|
|
7
|
+
from xoople.cli._errors import CliError, QuestionComputing, QuestionFailed, UsageError
|
|
8
|
+
from xoople.cli._output import emit, emit_model, emit_pairs, emit_rows, heading, note
|
|
9
|
+
from xoople.sdk import XoopleClient
|
|
10
|
+
from xoople.sdk.answers import QuestionAnswer
|
|
11
|
+
|
|
12
|
+
app = App(
|
|
13
|
+
name="questions",
|
|
14
|
+
help="Browse the questions an output can answer, and ask one.",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
_CATALOG_COLUMNS = [
|
|
18
|
+
("ID", "id"),
|
|
19
|
+
("QUESTION", "question"),
|
|
20
|
+
("CATEGORY", "category"),
|
|
21
|
+
("RESULT", "result_kind"),
|
|
22
|
+
("MEASURE", "requires_measure"),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command(name="list")
|
|
27
|
+
def list_questions(
|
|
28
|
+
*,
|
|
29
|
+
settings: SettingsParam,
|
|
30
|
+
analysis: str | None = None,
|
|
31
|
+
output: str | None = None,
|
|
32
|
+
category: list[str] | None = None,
|
|
33
|
+
available: bool = False,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""List the questions that can be asked.
|
|
36
|
+
|
|
37
|
+
Without an analysis this is the product catalogue, which answers before
|
|
38
|
+
anything has run. With one, each entry says whether that output can
|
|
39
|
+
answer it and what to do when it cannot.
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
analysis:
|
|
44
|
+
Narrow the catalogue to one analysis's output.
|
|
45
|
+
output:
|
|
46
|
+
Which output of that analysis; defaults to its primary table.
|
|
47
|
+
category:
|
|
48
|
+
Only these categories. Repeat to pass several.
|
|
49
|
+
available:
|
|
50
|
+
With an analysis, hide the questions this output cannot answer.
|
|
51
|
+
"""
|
|
52
|
+
client = settings.client()
|
|
53
|
+
if analysis is None:
|
|
54
|
+
result = client.questions.list(category=category)
|
|
55
|
+
emit(result.questions, _CATALOG_COLUMNS, as_json=settings.as_json)
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
bound = client.analyses.outputs.list_questions(
|
|
59
|
+
analysis, _resolve_output(client, analysis, output)
|
|
60
|
+
)
|
|
61
|
+
entries = [
|
|
62
|
+
entry
|
|
63
|
+
for entry in bound.questions or []
|
|
64
|
+
if (not available or entry.available)
|
|
65
|
+
and (not category or entry.question.category in category)
|
|
66
|
+
]
|
|
67
|
+
if settings.as_json:
|
|
68
|
+
emit_model(bound.model_copy(update={"questions": entries}), as_json=True)
|
|
69
|
+
return
|
|
70
|
+
note(
|
|
71
|
+
f"measures: {', '.join(bound.measures or [])} · "
|
|
72
|
+
f"grid: {bound.grid or '-'} · summary: {bound.summary_status} · "
|
|
73
|
+
f"grid build: {bound.sampler_status}"
|
|
74
|
+
)
|
|
75
|
+
if not entries:
|
|
76
|
+
note("no matching questions")
|
|
77
|
+
return
|
|
78
|
+
emit_rows(
|
|
79
|
+
("ID", "QUESTION", "ASKABLE", "WHY NOT"),
|
|
80
|
+
[
|
|
81
|
+
(
|
|
82
|
+
entry.question.id,
|
|
83
|
+
entry.question.question,
|
|
84
|
+
entry.available,
|
|
85
|
+
entry.unavailable_reason,
|
|
86
|
+
)
|
|
87
|
+
for entry in entries
|
|
88
|
+
],
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.command(name="show")
|
|
93
|
+
def show(question: str, *, settings: SettingsParam) -> None:
|
|
94
|
+
"""Show one question's parameters and what it needs an output to carry.
|
|
95
|
+
|
|
96
|
+
Parameters
|
|
97
|
+
----------
|
|
98
|
+
question:
|
|
99
|
+
The question id, as `questions list` prints it.
|
|
100
|
+
"""
|
|
101
|
+
result = settings.client().questions.list()
|
|
102
|
+
entry = next((item for item in result.questions or [] if item.id == question), None)
|
|
103
|
+
if entry is None:
|
|
104
|
+
raise CliError(f"unknown question: {question}")
|
|
105
|
+
if settings.as_json:
|
|
106
|
+
emit_model(entry, as_json=True)
|
|
107
|
+
return
|
|
108
|
+
emit_model(entry, as_json=False)
|
|
109
|
+
heading("parameters")
|
|
110
|
+
emit(
|
|
111
|
+
entry.parameters or [],
|
|
112
|
+
[
|
|
113
|
+
("NAME", "name"),
|
|
114
|
+
("TYPE", "type"),
|
|
115
|
+
("REQUIRED", "required"),
|
|
116
|
+
("DEFAULT", "default"),
|
|
117
|
+
("DESCRIPTION", "description"),
|
|
118
|
+
],
|
|
119
|
+
as_json=False,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@app.command(name="ask")
|
|
124
|
+
def ask(
|
|
125
|
+
analysis: str,
|
|
126
|
+
question: str,
|
|
127
|
+
*,
|
|
128
|
+
settings: SettingsParam,
|
|
129
|
+
output: str | None = None,
|
|
130
|
+
param: Annotated[list[str] | None, Parameter(name="--param")] = None,
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Ask one question of an analysis's output.
|
|
133
|
+
|
|
134
|
+
Parameters
|
|
135
|
+
----------
|
|
136
|
+
analysis:
|
|
137
|
+
The analysis whose output to ask about.
|
|
138
|
+
question:
|
|
139
|
+
The question id, as `questions list` prints it.
|
|
140
|
+
output:
|
|
141
|
+
Which output to ask about; defaults to the analysis's primary table.
|
|
142
|
+
param:
|
|
143
|
+
A `name=value` parameter. Repeat for several; `questions show` lists
|
|
144
|
+
what each question accepts.
|
|
145
|
+
"""
|
|
146
|
+
client = settings.client()
|
|
147
|
+
answer = client.analyses.outputs.ask(
|
|
148
|
+
analysis,
|
|
149
|
+
_resolve_output(client, analysis, output),
|
|
150
|
+
question,
|
|
151
|
+
**_parameters(param or []),
|
|
152
|
+
)
|
|
153
|
+
if settings.as_json:
|
|
154
|
+
emit_model(answer.response, as_json=True)
|
|
155
|
+
else:
|
|
156
|
+
_print_answer(answer)
|
|
157
|
+
# The payload is out either way; the exit code says it is not an answer yet.
|
|
158
|
+
if answer.computing:
|
|
159
|
+
raise QuestionComputing(answer.response.answer)
|
|
160
|
+
if answer.failed:
|
|
161
|
+
raise QuestionFailed(answer.response.answer)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _print_answer(answer: QuestionAnswer) -> None:
|
|
165
|
+
response = answer.response
|
|
166
|
+
if response.status != "ANSWERED":
|
|
167
|
+
return
|
|
168
|
+
note(response.answer)
|
|
169
|
+
if response.rows:
|
|
170
|
+
emit_rows(
|
|
171
|
+
[
|
|
172
|
+
f"{column.name} ({column.unit})" if column.unit else column.name
|
|
173
|
+
for column in response.columns or []
|
|
174
|
+
],
|
|
175
|
+
response.rows,
|
|
176
|
+
)
|
|
177
|
+
if response.totals:
|
|
178
|
+
heading("totals")
|
|
179
|
+
emit_pairs(response.totals.items())
|
|
180
|
+
for caveat in response.caveats or []:
|
|
181
|
+
note(f"[yellow]note[/yellow] {caveat}")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _parameters(values: list[str]) -> dict[str, bool | int | float | str | None]:
|
|
185
|
+
"""`name=value` pairs, sent as typed.
|
|
186
|
+
|
|
187
|
+
The question's schema converts and validates server-side, so a number
|
|
188
|
+
travels as the text it was typed as and `period=2024` stays a string.
|
|
189
|
+
Only a boolean needs reading here, because the schema takes no text for one.
|
|
190
|
+
"""
|
|
191
|
+
parsed: dict[str, bool | int | float | str | None] = {}
|
|
192
|
+
for item in values:
|
|
193
|
+
name, separator, raw = item.partition("=")
|
|
194
|
+
if not separator or not name:
|
|
195
|
+
raise UsageError(f"--param expects name=value, got {item!r}")
|
|
196
|
+
parsed[name] = raw.lower() == "true" if raw.lower() in ("true", "false") else raw
|
|
197
|
+
return parsed
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _resolve_output(client: XoopleClient, analysis: str, output: str | None) -> str:
|
|
201
|
+
"""The named output, or the analysis's only table output."""
|
|
202
|
+
if output is not None:
|
|
203
|
+
return output
|
|
204
|
+
outputs = [
|
|
205
|
+
item for item in client.analyses.outputs.list(analysis) if item.kind == "delta_table"
|
|
206
|
+
]
|
|
207
|
+
if not outputs:
|
|
208
|
+
raise CliError(f"analysis {analysis} has no table output to ask about")
|
|
209
|
+
return str(outputs[0].uid)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|