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.
Files changed (26) hide show
  1. {xoople_cli-0.4.0 → xoople_cli-0.6.0}/PKG-INFO +11 -6
  2. {xoople_cli-0.4.0 → xoople_cli-0.6.0}/README.md +9 -4
  3. {xoople_cli-0.4.0 → xoople_cli-0.6.0}/pyproject.toml +3 -3
  4. {xoople_cli-0.4.0 → xoople_cli-0.6.0}/pyproject.toml.orig +3 -3
  5. {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/_context.py +2 -0
  6. {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/_errors.py +21 -4
  7. {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/_output.py +55 -0
  8. {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/access.py +4 -4
  9. {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/analyses.py +10 -36
  10. {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/catalog.py +2 -2
  11. {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/main.py +2 -0
  12. {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/outputs.py +40 -1
  13. {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/packages.py +23 -1
  14. xoople_cli-0.6.0/src/xoople/cli/questions.py +209 -0
  15. {xoople_cli-0.4.0 → xoople_cli-0.6.0}/LICENSE +0 -0
  16. {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/__init__.py +0 -0
  17. {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/__main__.py +0 -0
  18. {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/_ciam.py +0 -0
  19. {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/_spec.py +0 -0
  20. {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/_version.py +0 -0
  21. {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/auth.py +0 -0
  22. {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/events.py +0 -0
  23. {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/projects.py +0 -0
  24. {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/runs.py +0 -0
  25. {xoople_cli-0.4.0 → xoople_cli-0.6.0}/src/xoople/cli/schedules.py +0 -0
  26. {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.4.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.6.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.4.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.6.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.11.6,<0.12"]
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.4.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.6.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.11.6,<0.12"]
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 feature flags for your account.",
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 share it reads."""
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 feature flags are on for you, and why."""
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
- [("FLAG", "flag"), ("ON", "value"), ("REASON", "reason")],
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, Literal
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
- ("Product version", "product_version"),
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="share")
182
- def share(
183
- analysis: str,
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
- Only the owner may unshare; repeating the call changes nothing.
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 revoke a grant on.
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.unshare(analysis, share)
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", "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 a spec file.
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