xoople-cli 0.2.0__tar.gz → 0.4.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.2.0 → xoople_cli-0.4.0}/PKG-INFO +49 -6
- xoople_cli-0.4.0/README.md +93 -0
- {xoople_cli-0.2.0 → xoople_cli-0.4.0}/pyproject.toml +2 -5
- {xoople_cli-0.2.0 → xoople_cli-0.4.0}/pyproject.toml.orig +2 -9
- xoople_cli-0.4.0/src/xoople/cli/_ciam.py +71 -0
- {xoople_cli-0.2.0 → xoople_cli-0.4.0}/src/xoople/cli/_context.py +34 -8
- {xoople_cli-0.2.0 → xoople_cli-0.4.0}/src/xoople/cli/_errors.py +12 -0
- {xoople_cli-0.2.0 → xoople_cli-0.4.0}/src/xoople/cli/_output.py +3 -6
- {xoople_cli-0.2.0 → xoople_cli-0.4.0}/src/xoople/cli/_spec.py +2 -2
- {xoople_cli-0.2.0 → xoople_cli-0.4.0}/src/xoople/cli/analyses.py +6 -1
- xoople_cli-0.4.0/src/xoople/cli/auth.py +70 -0
- {xoople_cli-0.2.0 → xoople_cli-0.4.0}/src/xoople/cli/catalog.py +20 -2
- {xoople_cli-0.2.0 → xoople_cli-0.4.0}/src/xoople/cli/main.py +36 -8
- {xoople_cli-0.2.0 → xoople_cli-0.4.0}/src/xoople/cli/outputs.py +34 -0
- xoople_cli-0.4.0/src/xoople/cli/packages.py +92 -0
- xoople_cli-0.4.0/src/xoople/cli/projects.py +234 -0
- xoople_cli-0.2.0/README.md +0 -48
- xoople_cli-0.2.0/src/xoople/cli/projects.py +0 -118
- {xoople_cli-0.2.0 → xoople_cli-0.4.0}/LICENSE +0 -0
- {xoople_cli-0.2.0 → xoople_cli-0.4.0}/src/xoople/cli/__init__.py +0 -0
- {xoople_cli-0.2.0 → xoople_cli-0.4.0}/src/xoople/cli/__main__.py +0 -0
- {xoople_cli-0.2.0 → xoople_cli-0.4.0}/src/xoople/cli/_version.py +0 -0
- {xoople_cli-0.2.0 → xoople_cli-0.4.0}/src/xoople/cli/access.py +0 -0
- {xoople_cli-0.2.0 → xoople_cli-0.4.0}/src/xoople/cli/events.py +0 -0
- {xoople_cli-0.2.0 → xoople_cli-0.4.0}/src/xoople/cli/runs.py +0 -0
- {xoople_cli-0.2.0 → xoople_cli-0.4.0}/src/xoople/cli/schedules.py +0 -0
- {xoople_cli-0.2.0 → xoople_cli-0.4.0}/src/xoople/cli/usage.py +0 -0
|
@@ -1,18 +1,16 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: xoople-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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.6.0
|
|
10
10
|
Requires-Dist: cyclopts>=4.18,<5
|
|
11
11
|
Requires-Dist: rich>=13.7
|
|
12
|
-
Requires-Dist: xoople-sdk[entra]>=0.3.0 ; extra == 'entra'
|
|
13
12
|
Requires-Python: >=3.12
|
|
14
13
|
Project-URL: Homepage, https://xoople.com
|
|
15
|
-
Provides-Extra: entra
|
|
16
14
|
Description-Content-Type: text/markdown
|
|
17
15
|
|
|
18
16
|
# xoople-cli
|
|
@@ -41,6 +39,49 @@ output pipes into `jq`.
|
|
|
41
39
|
| `usage` | `history` `limits` |
|
|
42
40
|
| `catalog` | `products` `measures` `designs` |
|
|
43
41
|
| `access` | `me` `token` `features` |
|
|
42
|
+
| `auth` | `login` `logout` `status` |
|
|
43
|
+
|
|
44
|
+
## Signing in
|
|
45
|
+
|
|
46
|
+
`auth login` signs in through the browser with PKCE and stores the session at
|
|
47
|
+
`$XDG_CONFIG_HOME/xoople/session.json` (`~/.config/xoople/session.json` by
|
|
48
|
+
default), readable only by you. Every later command uses that session when
|
|
49
|
+
no `--token` is given, and refreshes it when the
|
|
50
|
+
access token is close to expiry.
|
|
51
|
+
|
|
52
|
+
```console
|
|
53
|
+
$ export XOOPLE_API_URL=https://api.xoople.com
|
|
54
|
+
$ xoople auth login
|
|
55
|
+
$ xoople analyses list
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`auth login` reads the CIAM client ID from the API. `--ciam-client-id`
|
|
59
|
+
(`XOOPLE_CIAM_CLIENT_ID`) overrides it. `auth status` exits `3` when there is no
|
|
60
|
+
stored session, so a script can test for a sign-in. `--ciam-redirect-uri` (`XOOPLE_CIAM_REDIRECT_URI`) overrides the
|
|
61
|
+
loopback URI the browser returns to; it must match a URI registered for the
|
|
62
|
+
CIAM client.
|
|
63
|
+
|
|
64
|
+
## API keys
|
|
65
|
+
|
|
66
|
+
An API key lets a script or a CI job run the CLI without a browser sign-in.
|
|
67
|
+
Create one on the **API keys** section of your Xoople profile page (`/profile`).
|
|
68
|
+
The key starts with `sk_` and is shown once, so store it in a secret manager.
|
|
69
|
+
|
|
70
|
+
A key belongs to one organization membership. It acts as you, in the
|
|
71
|
+
organization that was active when you created it, with that membership's roles.
|
|
72
|
+
To work in a different organization, create a key there.
|
|
73
|
+
|
|
74
|
+
Pass the key as the token. It takes precedence over a stored session:
|
|
75
|
+
|
|
76
|
+
```console
|
|
77
|
+
$ export XOOPLE_API_URL=https://api.xoople.com XOOPLE_TOKEN=sk_...
|
|
78
|
+
$ xoople access me
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The API accepts at most 300 API key requests a minute from one IP address. The
|
|
82
|
+
CLI retries rate-limited GETs and replay-safe writes with backoff, then exits
|
|
83
|
+
`8`. Any other write exits `8` on the first 429, with no retry. Revoke a key on
|
|
84
|
+
the same profile page when you no longer need it.
|
|
44
85
|
|
|
45
86
|
## Submitting an analysis
|
|
46
87
|
|
|
@@ -59,7 +100,9 @@ non-zero if it does not reach `COMPLETE`.
|
|
|
59
100
|
|
|
60
101
|
## Exit codes
|
|
61
102
|
|
|
62
|
-
`0` success · `1` unexpected CLI error · `2` usage error ·
|
|
103
|
+
`0` success · `1` unexpected CLI error · `2` usage error ·
|
|
104
|
+
`3` unauthenticated, including a failed or absent sign-in · `4` forbidden ·
|
|
63
105
|
`5` not found · `6` bad request or validation · `7` conflict · `8` rate limited ·
|
|
64
106
|
`9` server error · `10` connection failure · `11` unreadable response ·
|
|
65
|
-
`12` run finished in a non-`COMPLETE` state · `13` `--wait` timed out
|
|
107
|
+
`12` run finished in a non-`COMPLETE` state · `13` `--wait` timed out ·
|
|
108
|
+
`14` the analysis was deleted.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# xoople-cli
|
|
2
|
+
|
|
3
|
+
Command-line interface for the Xoople Products API, over [`xoople-sdk`](../xoople-sdk).
|
|
4
|
+
|
|
5
|
+
```console
|
|
6
|
+
$ uv tool install xoople-cli
|
|
7
|
+
$ export XOOPLE_API_URL=https://api.xoople.com XOOPLE_TOKEN=...
|
|
8
|
+
$ xoople analyses list
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Every command reads `--api-url` and `--token` from `XOOPLE_API_URL` and
|
|
12
|
+
`XOOPLE_TOKEN`. `--json` prints the API response verbatim instead of a table, so
|
|
13
|
+
output pipes into `jq`.
|
|
14
|
+
|
|
15
|
+
## Commands
|
|
16
|
+
|
|
17
|
+
| Group | Commands |
|
|
18
|
+
| --- | --- |
|
|
19
|
+
| `analyses` | `list` `get` `create` `update` `cancel` `estimate` |
|
|
20
|
+
| `runs` | `list` `get` |
|
|
21
|
+
| `outputs` | `list` `get` `access-url` |
|
|
22
|
+
| `schedules` | `list` `get` `create` `pause` `resume` `cancel` `run` `estimate-run` |
|
|
23
|
+
| `events` | `list` `get` |
|
|
24
|
+
| `usage` | `history` `limits` |
|
|
25
|
+
| `catalog` | `products` `measures` `designs` |
|
|
26
|
+
| `access` | `me` `token` `features` |
|
|
27
|
+
| `auth` | `login` `logout` `status` |
|
|
28
|
+
|
|
29
|
+
## Signing in
|
|
30
|
+
|
|
31
|
+
`auth login` signs in through the browser with PKCE and stores the session at
|
|
32
|
+
`$XDG_CONFIG_HOME/xoople/session.json` (`~/.config/xoople/session.json` by
|
|
33
|
+
default), readable only by you. Every later command uses that session when
|
|
34
|
+
no `--token` is given, and refreshes it when the
|
|
35
|
+
access token is close to expiry.
|
|
36
|
+
|
|
37
|
+
```console
|
|
38
|
+
$ export XOOPLE_API_URL=https://api.xoople.com
|
|
39
|
+
$ xoople auth login
|
|
40
|
+
$ xoople analyses list
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`auth login` reads the CIAM client ID from the API. `--ciam-client-id`
|
|
44
|
+
(`XOOPLE_CIAM_CLIENT_ID`) overrides it. `auth status` exits `3` when there is no
|
|
45
|
+
stored session, so a script can test for a sign-in. `--ciam-redirect-uri` (`XOOPLE_CIAM_REDIRECT_URI`) overrides the
|
|
46
|
+
loopback URI the browser returns to; it must match a URI registered for the
|
|
47
|
+
CIAM client.
|
|
48
|
+
|
|
49
|
+
## API keys
|
|
50
|
+
|
|
51
|
+
An API key lets a script or a CI job run the CLI without a browser sign-in.
|
|
52
|
+
Create one on the **API keys** section of your Xoople profile page (`/profile`).
|
|
53
|
+
The key starts with `sk_` and is shown once, so store it in a secret manager.
|
|
54
|
+
|
|
55
|
+
A key belongs to one organization membership. It acts as you, in the
|
|
56
|
+
organization that was active when you created it, with that membership's roles.
|
|
57
|
+
To work in a different organization, create a key there.
|
|
58
|
+
|
|
59
|
+
Pass the key as the token. It takes precedence over a stored session:
|
|
60
|
+
|
|
61
|
+
```console
|
|
62
|
+
$ export XOOPLE_API_URL=https://api.xoople.com XOOPLE_TOKEN=sk_...
|
|
63
|
+
$ xoople access me
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The API accepts at most 300 API key requests a minute from one IP address. The
|
|
67
|
+
CLI retries rate-limited GETs and replay-safe writes with backoff, then exits
|
|
68
|
+
`8`. Any other write exits `8` on the first 429, with no retry. Revoke a key on
|
|
69
|
+
the same profile page when you no longer need it.
|
|
70
|
+
|
|
71
|
+
## Submitting an analysis
|
|
72
|
+
|
|
73
|
+
The product spec is deeply nested and differs per product, so `create` and
|
|
74
|
+
`estimate` take it as JSON rather than as flags:
|
|
75
|
+
|
|
76
|
+
```console
|
|
77
|
+
$ xoople catalog designs --json | jq '.[0].spec_fragment' > spec.json
|
|
78
|
+
$ # fill in period and aoi, then:
|
|
79
|
+
$ xoople analyses create -f spec.json --display-name "South Wales NDVI" --wait
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The file holds either the bare spec or a whole create request (anything with a
|
|
83
|
+
`spec` key). `-f -` reads stdin. `--wait` polls the initial run and exits
|
|
84
|
+
non-zero if it does not reach `COMPLETE`.
|
|
85
|
+
|
|
86
|
+
## Exit codes
|
|
87
|
+
|
|
88
|
+
`0` success · `1` unexpected CLI error · `2` usage error ·
|
|
89
|
+
`3` unauthenticated, including a failed or absent sign-in · `4` forbidden ·
|
|
90
|
+
`5` not found · `6` bad request or validation · `7` conflict · `8` rate limited ·
|
|
91
|
+
`9` server error · `10` connection failure · `11` unreadable response ·
|
|
92
|
+
`12` run finished in a non-`COMPLETE` state · `13` `--wait` timed out ·
|
|
93
|
+
`14` the analysis was deleted.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "xoople-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "Command-line interface for the Xoople Products API."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -11,14 +11,11 @@ classifiers = [
|
|
|
11
11
|
"Programming Language :: Python :: 3.12",
|
|
12
12
|
]
|
|
13
13
|
dependencies = [
|
|
14
|
-
"xoople-sdk>=0.
|
|
14
|
+
"xoople-sdk>=0.6.0",
|
|
15
15
|
"cyclopts>=4.18,<5",
|
|
16
16
|
"rich>=13.7",
|
|
17
17
|
]
|
|
18
18
|
|
|
19
|
-
[project.optional-dependencies]
|
|
20
|
-
entra = ["xoople-sdk[entra]>=0.3.0"]
|
|
21
|
-
|
|
22
19
|
[project.scripts]
|
|
23
20
|
xoople = "xoople.cli.main:main"
|
|
24
21
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "xoople-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.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.6.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
|
|
@@ -22,13 +22,6 @@ dependencies = [
|
|
|
22
22
|
"rich>=13.7",
|
|
23
23
|
]
|
|
24
24
|
|
|
25
|
-
[project.optional-dependencies]
|
|
26
|
-
# Mirrors xoople-sdk[entra]: internal callers authenticating with Entra ID via
|
|
27
|
-
# `--entra-scope`. Not needed for the token path.
|
|
28
|
-
entra = [
|
|
29
|
-
"xoople-sdk[entra]>=0.3.0",
|
|
30
|
-
]
|
|
31
|
-
|
|
32
25
|
[project.scripts]
|
|
33
26
|
xoople = "xoople.cli.main:main"
|
|
34
27
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Which CIAM client the CLI signs in as, and how a failed sign-in is reported.
|
|
2
|
+
|
|
3
|
+
The SDK owns the sign-in, browser leg included, and the session file, because
|
|
4
|
+
a `xoople-cli` connection profile reads that file too.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Protocol, Self
|
|
10
|
+
|
|
11
|
+
from xoople.cli._errors import LoginFailed
|
|
12
|
+
from xoople.sdk.auth import CiamUserSession, ciam_sign_in
|
|
13
|
+
from xoople.sdk.errors import CredentialError
|
|
14
|
+
|
|
15
|
+
DEFAULT_REDIRECT_URI = "http://localhost:8976/callback"
|
|
16
|
+
"""Fixed, because CIAM matches a redirect URI exactly and must hold this one."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _SignIn(Protocol):
|
|
20
|
+
def __call__(
|
|
21
|
+
self, *, client_id: str, redirect_uri: str, announce: Callable[[str], None]
|
|
22
|
+
) -> CiamUserSession: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Tests supply a lookup that makes no HTTP call.
|
|
26
|
+
type ClientIdLookup = Callable[[str], str]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class CiamConfig:
|
|
31
|
+
"""What a login needs: the CIAM client and the URI the browser comes back to."""
|
|
32
|
+
|
|
33
|
+
client_id: str
|
|
34
|
+
redirect_uri: str = DEFAULT_REDIRECT_URI
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def resolve(
|
|
38
|
+
cls,
|
|
39
|
+
client_id: str | None,
|
|
40
|
+
redirect_uri: str | None,
|
|
41
|
+
*,
|
|
42
|
+
base_url: str | None,
|
|
43
|
+
discover: ClientIdLookup,
|
|
44
|
+
) -> Self:
|
|
45
|
+
"""An explicit client wins. Otherwise the API names its own client."""
|
|
46
|
+
if not client_id:
|
|
47
|
+
if not base_url:
|
|
48
|
+
raise LoginFailed(
|
|
49
|
+
"no CIAM client: pass --api-url, --ciam-client-id or set XOOPLE_CIAM_CLIENT_ID"
|
|
50
|
+
)
|
|
51
|
+
try:
|
|
52
|
+
client_id = discover(base_url)
|
|
53
|
+
except CredentialError as exc:
|
|
54
|
+
raise LoginFailed(f"{exc}: check --api-url or pass --ciam-client-id") from None
|
|
55
|
+
return cls(client_id=client_id, redirect_uri=redirect_uri or DEFAULT_REDIRECT_URI)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def login(
|
|
59
|
+
config: CiamConfig,
|
|
60
|
+
*,
|
|
61
|
+
announce: Callable[[str], None],
|
|
62
|
+
sign_in: _SignIn = ciam_sign_in,
|
|
63
|
+
) -> CiamUserSession:
|
|
64
|
+
"""Run one browser sign-in and return the session it produced."""
|
|
65
|
+
try:
|
|
66
|
+
return sign_in(
|
|
67
|
+
client_id=config.client_id, redirect_uri=config.redirect_uri, announce=announce
|
|
68
|
+
)
|
|
69
|
+
except CredentialError as exc:
|
|
70
|
+
# One exit code covers every sign-in failure. The SDK message says which.
|
|
71
|
+
raise LoginFailed(str(exc)) from None
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
from collections.abc import Callable
|
|
2
2
|
from dataclasses import dataclass, field
|
|
3
|
-
from typing import Annotated
|
|
3
|
+
from typing import Annotated, Protocol
|
|
4
4
|
|
|
5
5
|
from cyclopts import Parameter
|
|
6
|
+
from xoople.cli import _ciam
|
|
6
7
|
from xoople.cli._errors import UsageError
|
|
7
8
|
from xoople.sdk import Config, CredentialProvider, XoopleClient
|
|
8
|
-
from xoople.sdk
|
|
9
|
+
from xoople.sdk import session as session_file
|
|
10
|
+
from xoople.sdk.auth import (
|
|
11
|
+
CiamUserCredentials,
|
|
12
|
+
CiamUserSession,
|
|
13
|
+
StaticToken,
|
|
14
|
+
ciam_discover_client_id,
|
|
15
|
+
)
|
|
9
16
|
|
|
10
17
|
|
|
11
18
|
@dataclass(frozen=True)
|
|
@@ -14,7 +21,8 @@ class Credentials:
|
|
|
14
21
|
|
|
15
22
|
base_url: str | None = None
|
|
16
23
|
token: str | None = None
|
|
17
|
-
|
|
24
|
+
ciam_client_id: str | None = None
|
|
25
|
+
ciam_redirect_uri: str | None = None
|
|
18
26
|
timeout: float = 30.0
|
|
19
27
|
max_retries: int = 3
|
|
20
28
|
|
|
@@ -22,6 +30,15 @@ class Credentials:
|
|
|
22
30
|
# The client factory is swapped for a fake in tests.
|
|
23
31
|
type ClientFactory = Callable[[Credentials], XoopleClient]
|
|
24
32
|
|
|
33
|
+
|
|
34
|
+
class LoginRunner(Protocol):
|
|
35
|
+
"""Runs one browser sign-in. Tests supply one that opens no browser."""
|
|
36
|
+
|
|
37
|
+
def __call__(
|
|
38
|
+
self, config: _ciam.CiamConfig, *, announce: Callable[[str], None]
|
|
39
|
+
) -> CiamUserSession: ...
|
|
40
|
+
|
|
41
|
+
|
|
25
42
|
# The launcher injects ``Settings`` via ``parse=False``; commands stay plain
|
|
26
43
|
# functions and tests drive them over a fake transport.
|
|
27
44
|
type SettingsParam = Annotated[Settings, Parameter(parse=False)]
|
|
@@ -45,6 +62,8 @@ class Settings:
|
|
|
45
62
|
|
|
46
63
|
credentials: Credentials
|
|
47
64
|
factory: ClientFactory
|
|
65
|
+
login: LoginRunner = _ciam.login
|
|
66
|
+
discover: _ciam.ClientIdLookup = ciam_discover_client_id
|
|
48
67
|
as_json: bool = False
|
|
49
68
|
_client: XoopleClient | None = field(default=None, repr=False)
|
|
50
69
|
|
|
@@ -59,12 +78,19 @@ class Settings:
|
|
|
59
78
|
self._client = None
|
|
60
79
|
|
|
61
80
|
|
|
81
|
+
def _persist_session(session: CiamUserSession) -> None:
|
|
82
|
+
session_file.write_session(session)
|
|
83
|
+
|
|
84
|
+
|
|
62
85
|
def _credential_provider(credentials: Credentials) -> CredentialProvider:
|
|
63
|
-
if credentials.
|
|
64
|
-
return
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
86
|
+
if credentials.token:
|
|
87
|
+
return StaticToken(credentials.token)
|
|
88
|
+
session = session_file.read_session()
|
|
89
|
+
if session is not None:
|
|
90
|
+
# The credential refreshes a user token itself. Store what it mints, so
|
|
91
|
+
# the next run does not spend another refresh token.
|
|
92
|
+
return CiamUserCredentials(session, on_refresh=_persist_session)
|
|
93
|
+
raise UsageError("no credentials: run `xoople auth login`, pass --token, or set XOOPLE_TOKEN")
|
|
68
94
|
|
|
69
95
|
|
|
70
96
|
def build_client(credentials: Credentials) -> XoopleClient:
|
|
@@ -4,6 +4,8 @@ from xoople.sdk import errors
|
|
|
4
4
|
EXIT_CODES: dict[type[errors.XoopleError], int] = {
|
|
5
5
|
errors.AuthenticationError: 3,
|
|
6
6
|
errors.PermissionDeniedError: 4,
|
|
7
|
+
# Before its parent: `exit_code` takes the first class the error is an instance of.
|
|
8
|
+
errors.AnalysisDeletedError: 14,
|
|
7
9
|
errors.NotFoundError: 5,
|
|
8
10
|
errors.BadRequestError: 6,
|
|
9
11
|
errors.ValidationError: 6,
|
|
@@ -32,6 +34,16 @@ class UsageError(CliError):
|
|
|
32
34
|
exit_code = USAGE_EXIT
|
|
33
35
|
|
|
34
36
|
|
|
37
|
+
class LoginFailed(CliError):
|
|
38
|
+
"""A CIAM sign-in did not produce a session.
|
|
39
|
+
|
|
40
|
+
Takes the API's unauthenticated code: the outcome a caller must handle is
|
|
41
|
+
the same, so a script does not need a second number for it.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
exit_code = EXIT_CODES[errors.AuthenticationError]
|
|
45
|
+
|
|
46
|
+
|
|
35
47
|
class RunFailed(CliError):
|
|
36
48
|
"""``--wait`` finished, but the run ended in a non-``COMPLETE`` state."""
|
|
37
49
|
|
|
@@ -7,12 +7,9 @@ from rich.table import Table
|
|
|
7
7
|
|
|
8
8
|
type Column = tuple[str, str]
|
|
9
9
|
|
|
10
|
-
# Fields holding a 0..1 fraction
|
|
11
|
-
#
|
|
12
|
-
#
|
|
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.
|
|
10
|
+
# Fields holding a 0..1 fraction, rendered as whole percent. Keyed on the field name
|
|
11
|
+
# because the unit belongs to the field, not the emitting command. The generated models
|
|
12
|
+
# carry no `ge`/`le` bounds to infer it from; if the spec adds them, read those instead.
|
|
16
13
|
_FRACTION_FIELDS = frozenset({"fraction", "estimate_confidence"})
|
|
17
14
|
|
|
18
15
|
# Piped output must not depend on the terminal that happened to launch it, so a
|
|
@@ -25,7 +25,7 @@ def _explain(exc: ValidationError) -> str:
|
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
def load_create_request(
|
|
28
|
-
path: Path, display_name: str | None = None
|
|
28
|
+
path: Path, display_name: str | None = None, project: str | None = None
|
|
29
29
|
) -> models.CreateAnalysisRequest:
|
|
30
30
|
"""Read a create/estimate body from a JSON file (``-`` for stdin).
|
|
31
31
|
|
|
@@ -40,6 +40,6 @@ def load_create_request(
|
|
|
40
40
|
# say so in its return type.
|
|
41
41
|
body: dict[str, object] = {str(key): value for key, value in document.items()}
|
|
42
42
|
try:
|
|
43
|
-
return create_request_from(body, display_name)
|
|
43
|
+
return create_request_from(body, display_name, project)
|
|
44
44
|
except ValidationError as exc:
|
|
45
45
|
raise UsageError(_explain(exc)) from exc
|
|
@@ -105,6 +105,7 @@ def create(
|
|
|
105
105
|
settings: SettingsParam,
|
|
106
106
|
spec_file: Annotated[Path, _SPEC_FILE],
|
|
107
107
|
display_name: str | None = None,
|
|
108
|
+
project: str | None = None,
|
|
108
109
|
idempotency_key: str | None = None,
|
|
109
110
|
wait: bool = False,
|
|
110
111
|
poll_interval: float = 5.0,
|
|
@@ -118,6 +119,10 @@ def create(
|
|
|
118
119
|
JSON spec, or a whole create request. Use - for stdin.
|
|
119
120
|
display_name:
|
|
120
121
|
Label for the analysis; overrides the spec file's.
|
|
122
|
+
project:
|
|
123
|
+
Project that holds the analysis, as a uid or projects/{uid}; overrides the spec
|
|
124
|
+
file's. Omit it to use the organisation's default project. An M2M token always
|
|
125
|
+
gets 403 for a named project other than the default project.
|
|
121
126
|
idempotency_key:
|
|
122
127
|
Reuse a previous key to replay a submission instead of duplicating it.
|
|
123
128
|
wait:
|
|
@@ -127,7 +132,7 @@ def create(
|
|
|
127
132
|
wait_timeout:
|
|
128
133
|
Give up waiting after this many seconds.
|
|
129
134
|
"""
|
|
130
|
-
body = load_create_request(spec_file, display_name)
|
|
135
|
+
body = load_create_request(spec_file, display_name, project)
|
|
131
136
|
analysis = settings.client().analyses.create(body, idempotency_key=idempotency_key)
|
|
132
137
|
if not wait:
|
|
133
138
|
emit_one(analysis, FIELDS, as_json=settings.as_json)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from cyclopts import App
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
from xoople.cli import _ciam
|
|
4
|
+
from xoople.cli._context import SettingsParam
|
|
5
|
+
from xoople.cli._errors import LoginFailed
|
|
6
|
+
from xoople.cli._output import emit_one, note
|
|
7
|
+
from xoople.sdk import session as session_file
|
|
8
|
+
from xoople.sdk.auth import normalize_api_url
|
|
9
|
+
|
|
10
|
+
app = App(
|
|
11
|
+
name="auth",
|
|
12
|
+
help="Sign in to Xoople, and show or discard the stored session.",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
FIELDS = [("EMAIL", "email"), ("ORGANIZATION", "org_id"), ("EXPIRES", "expires_at")]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _StatusRow(BaseModel):
|
|
19
|
+
"""The part of a session that is safe to print. It carries no token."""
|
|
20
|
+
|
|
21
|
+
email: str | None
|
|
22
|
+
org_id: str | None
|
|
23
|
+
expires_at: int
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _bound_api_url(base_url: str | None) -> str | None:
|
|
27
|
+
"""The API a profile may send this session's token to. None binds it to no profile."""
|
|
28
|
+
if not base_url:
|
|
29
|
+
return None
|
|
30
|
+
try:
|
|
31
|
+
return normalize_api_url(base_url)
|
|
32
|
+
except ValueError:
|
|
33
|
+
raise LoginFailed(f"{base_url!r} is not a URL: check --api-url") from None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command(name="login")
|
|
37
|
+
def login(*, settings: SettingsParam) -> None:
|
|
38
|
+
"""Sign in through the browser and store the session for later commands."""
|
|
39
|
+
config = _ciam.CiamConfig.resolve(
|
|
40
|
+
settings.credentials.ciam_client_id,
|
|
41
|
+
settings.credentials.ciam_redirect_uri,
|
|
42
|
+
base_url=settings.credentials.base_url,
|
|
43
|
+
discover=settings.discover,
|
|
44
|
+
)
|
|
45
|
+
api_url = _bound_api_url(settings.credentials.base_url)
|
|
46
|
+
note(f"Serving the sign-in callback on {config.redirect_uri}")
|
|
47
|
+
session = settings.login(config, announce=lambda url: note(f"Opening {url}"))
|
|
48
|
+
session = session.model_copy(update={"api_url": api_url})
|
|
49
|
+
path = session_file.write_session(session)
|
|
50
|
+
note(f"Signed in as {session.email or session.client_id}; session stored at {path}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.command(name="logout")
|
|
54
|
+
def logout(*, settings: SettingsParam) -> None:
|
|
55
|
+
"""Discard the stored session."""
|
|
56
|
+
del settings
|
|
57
|
+
if session_file.clear_session():
|
|
58
|
+
note("Signed out.")
|
|
59
|
+
return
|
|
60
|
+
note("No stored session.")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.command(name="status")
|
|
64
|
+
def status(*, settings: SettingsParam) -> None:
|
|
65
|
+
"""Show who the stored session belongs to, and when it expires."""
|
|
66
|
+
session = session_file.read_session()
|
|
67
|
+
if session is None:
|
|
68
|
+
raise LoginFailed("no stored session: run `xoople auth login`")
|
|
69
|
+
row = _StatusRow(email=session.email, org_id=session.org_id, expires_at=session.expires_at)
|
|
70
|
+
emit_one(row, FIELDS, as_json=settings.as_json)
|
|
@@ -32,7 +32,7 @@ def measures(
|
|
|
32
32
|
product: str | None = None,
|
|
33
33
|
domain: list[str] | None = None,
|
|
34
34
|
) -> None:
|
|
35
|
-
"""List the bands, indices and
|
|
35
|
+
"""List the bands, indices and indicators you can request.
|
|
36
36
|
|
|
37
37
|
Parameters
|
|
38
38
|
----------
|
|
@@ -58,7 +58,6 @@ def measures(
|
|
|
58
58
|
)
|
|
59
59
|
for label, items in (
|
|
60
60
|
("indices", result.indices),
|
|
61
|
-
("contrasts", result.contrasts),
|
|
62
61
|
("indicators", result.indicators),
|
|
63
62
|
):
|
|
64
63
|
heading(label)
|
|
@@ -101,6 +100,25 @@ def projections(*, settings: SettingsParam, bbox: str) -> None:
|
|
|
101
100
|
)
|
|
102
101
|
|
|
103
102
|
|
|
103
|
+
@app.command(name="cloud-mask-presets")
|
|
104
|
+
def cloud_mask_presets(*, settings: SettingsParam) -> None:
|
|
105
|
+
"""List the scene-class masking presets, and the scene classes each one discards.
|
|
106
|
+
|
|
107
|
+
A preset name is what `cloud_mask_mode` accepts on an imagery request. The
|
|
108
|
+
classes are published here so a request never has to restate them.
|
|
109
|
+
"""
|
|
110
|
+
result = settings.client().catalog.cloud_mask_presets()
|
|
111
|
+
emit(
|
|
112
|
+
result.presets,
|
|
113
|
+
[
|
|
114
|
+
("MODE", "mode"),
|
|
115
|
+
("EXCLUDED CLASSES", "excluded_classes"),
|
|
116
|
+
("EXCLUDED CLASS NAMES", "excluded_class_names"),
|
|
117
|
+
],
|
|
118
|
+
as_json=settings.as_json,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
104
122
|
@app.command(name="designs")
|
|
105
123
|
def designs(*, settings: SettingsParam, product: str | None = None) -> None:
|
|
106
124
|
"""List the starting-point analysis designs. Their `spec_fragment` seeds a spec file.
|
|
@@ -4,26 +4,43 @@ from cyclopts import App, CycloptsError, Parameter
|
|
|
4
4
|
from xoople.cli import (
|
|
5
5
|
access,
|
|
6
6
|
analyses,
|
|
7
|
+
auth,
|
|
7
8
|
catalog,
|
|
8
9
|
events,
|
|
9
10
|
outputs,
|
|
11
|
+
packages,
|
|
10
12
|
projects,
|
|
11
13
|
runs,
|
|
12
14
|
schedules,
|
|
13
15
|
usage,
|
|
14
16
|
)
|
|
15
|
-
from xoople.cli.
|
|
17
|
+
from xoople.cli._ciam import ClientIdLookup
|
|
18
|
+
from xoople.cli._ciam import login as ciam_login
|
|
19
|
+
from xoople.cli._context import (
|
|
20
|
+
ClientFactory,
|
|
21
|
+
Credentials,
|
|
22
|
+
LoginRunner,
|
|
23
|
+
Settings,
|
|
24
|
+
build_client,
|
|
25
|
+
)
|
|
16
26
|
from xoople.cli._errors import USAGE_EXIT, CliError, report
|
|
17
27
|
from xoople.cli._output import note
|
|
18
28
|
from xoople.cli._version import version
|
|
19
29
|
from xoople.sdk import errors
|
|
30
|
+
from xoople.sdk.auth import ciam_discover_client_id
|
|
20
31
|
|
|
21
32
|
|
|
22
|
-
def create_app(
|
|
23
|
-
|
|
33
|
+
def create_app(
|
|
34
|
+
factory: ClientFactory = build_client,
|
|
35
|
+
login: LoginRunner = ciam_login,
|
|
36
|
+
discover: ClientIdLookup = ciam_discover_client_id,
|
|
37
|
+
) -> App:
|
|
38
|
+
"""The command tree, bound to a way of building clients and signing in.
|
|
24
39
|
|
|
25
40
|
Tests build the tree over a fake transport rather than reaching into module
|
|
26
41
|
globals, so every command runs its real argument parsing and rendering.
|
|
42
|
+
`login` is the same seam for the sign-in, which opens a browser, and
|
|
43
|
+
`discover` for the client ID lookup, which calls the API.
|
|
27
44
|
"""
|
|
28
45
|
app = App(
|
|
29
46
|
name="xoople",
|
|
@@ -34,18 +51,23 @@ def create_app(factory: ClientFactory = build_client) -> App:
|
|
|
34
51
|
app.command(projects.app)
|
|
35
52
|
app.command(runs.app)
|
|
36
53
|
app.command(outputs.app)
|
|
54
|
+
app.command(packages.app)
|
|
37
55
|
app.command(schedules.app)
|
|
38
56
|
app.command(events.app)
|
|
39
57
|
app.command(usage.app)
|
|
40
58
|
app.command(catalog.app)
|
|
41
59
|
app.command(access.app)
|
|
60
|
+
app.command(auth.app)
|
|
42
61
|
|
|
43
62
|
@app.meta.default
|
|
44
63
|
def launcher(
|
|
45
64
|
*tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)],
|
|
46
65
|
api_url: Annotated[str | None, Parameter(env_var="XOOPLE_API_URL")] = None,
|
|
47
66
|
token: Annotated[str | None, Parameter(env_var="XOOPLE_TOKEN")] = None,
|
|
48
|
-
|
|
67
|
+
ciam_client_id: Annotated[str | None, Parameter(env_var="XOOPLE_CIAM_CLIENT_ID")] = None,
|
|
68
|
+
ciam_redirect_uri: Annotated[
|
|
69
|
+
str | None, Parameter(env_var="XOOPLE_CIAM_REDIRECT_URI")
|
|
70
|
+
] = None,
|
|
49
71
|
timeout: Annotated[float, Parameter()] = 30.0,
|
|
50
72
|
max_retries: Annotated[int, Parameter()] = 3,
|
|
51
73
|
json_output: Annotated[bool, Parameter(name="--json")] = False,
|
|
@@ -58,9 +80,12 @@ def create_app(factory: ClientFactory = build_client) -> App:
|
|
|
58
80
|
Base URL of the Xoople API.
|
|
59
81
|
token:
|
|
60
82
|
Bearer token.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
83
|
+
ciam_client_id:
|
|
84
|
+
Xoople CIAM client ID, used by `auth login`. Without it, the CLI
|
|
85
|
+
reads the client ID from the API.
|
|
86
|
+
ciam_redirect_uri:
|
|
87
|
+
Loopback URI the browser returns the sign-in to. It must match the
|
|
88
|
+
URI registered for the CIAM client.
|
|
64
89
|
timeout:
|
|
65
90
|
Per-request timeout, in seconds.
|
|
66
91
|
max_retries:
|
|
@@ -77,11 +102,14 @@ def create_app(factory: ClientFactory = build_client) -> App:
|
|
|
77
102
|
credentials=Credentials(
|
|
78
103
|
base_url=api_url,
|
|
79
104
|
token=token,
|
|
80
|
-
|
|
105
|
+
ciam_client_id=ciam_client_id,
|
|
106
|
+
ciam_redirect_uri=ciam_redirect_uri,
|
|
81
107
|
timeout=timeout,
|
|
82
108
|
max_retries=max_retries,
|
|
83
109
|
),
|
|
84
110
|
factory=factory,
|
|
111
|
+
login=login,
|
|
112
|
+
discover=discover,
|
|
85
113
|
as_json=json_output,
|
|
86
114
|
)
|
|
87
115
|
try:
|
|
@@ -75,3 +75,37 @@ def access_url(
|
|
|
75
75
|
analysis, output, ttl_seconds=ttl_seconds
|
|
76
76
|
)
|
|
77
77
|
emit_model(result, as_json=settings.as_json)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.command(name="summary")
|
|
81
|
+
def summary(analysis: str, output: str, *, settings: SettingsParam) -> None:
|
|
82
|
+
"""Show an output's summary: aggregate coverage, grain and pixel statistics.
|
|
83
|
+
|
|
84
|
+
Parameters
|
|
85
|
+
----------
|
|
86
|
+
analysis:
|
|
87
|
+
The analysis the output belongs to.
|
|
88
|
+
output:
|
|
89
|
+
The output whose summary to show.
|
|
90
|
+
"""
|
|
91
|
+
emit_model(
|
|
92
|
+
settings.client().analyses.outputs.get_summary(analysis, output),
|
|
93
|
+
as_json=settings.as_json,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@app.command(name="refresh-summary")
|
|
98
|
+
def refresh_summary(analysis: str, output: str, *, settings: SettingsParam) -> None:
|
|
99
|
+
"""Start (or join) an asynchronous recomputation of an output's summary.
|
|
100
|
+
|
|
101
|
+
Parameters
|
|
102
|
+
----------
|
|
103
|
+
analysis:
|
|
104
|
+
The analysis the output belongs to.
|
|
105
|
+
output:
|
|
106
|
+
The output whose summary to refresh.
|
|
107
|
+
"""
|
|
108
|
+
emit_model(
|
|
109
|
+
settings.client().analyses.outputs.refresh_summary(analysis, output),
|
|
110
|
+
as_json=settings.as_json,
|
|
111
|
+
)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from cyclopts import App, Parameter
|
|
4
|
+
from xoople.cli._context import SettingsParam, at_least_one
|
|
5
|
+
from xoople.cli._output import emit, emit_model
|
|
6
|
+
|
|
7
|
+
app = App(
|
|
8
|
+
name="packages",
|
|
9
|
+
help="Export an analysis as an immutable Parquet package and mint access links.",
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
COLUMNS = [
|
|
13
|
+
("PACKAGE", "package_id"),
|
|
14
|
+
("STATE", "state"),
|
|
15
|
+
("VERSION", "delta_version"),
|
|
16
|
+
("ROWS", "row_count"),
|
|
17
|
+
("CREATED", "create_time"),
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.command(name="create")
|
|
22
|
+
def create(analysis: str, *, settings: SettingsParam) -> None:
|
|
23
|
+
"""Package an analysis's current output, or return the package that already holds it.
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
analysis:
|
|
28
|
+
The ready analysis to package. Needs its owner and the parquet_packages feature.
|
|
29
|
+
"""
|
|
30
|
+
emit_model(settings.client().analyses.packages.create(analysis), as_json=settings.as_json)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.command(name="list")
|
|
34
|
+
def list_packages(
|
|
35
|
+
analysis: str,
|
|
36
|
+
*,
|
|
37
|
+
settings: SettingsParam,
|
|
38
|
+
limit: Annotated[int | None, Parameter(validator=at_least_one)] = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""List an analysis's packages, newest first.
|
|
41
|
+
|
|
42
|
+
Parameters
|
|
43
|
+
----------
|
|
44
|
+
analysis:
|
|
45
|
+
The analysis whose packages to list.
|
|
46
|
+
limit:
|
|
47
|
+
Stop after this many, instead of every page.
|
|
48
|
+
"""
|
|
49
|
+
items = settings.client().analyses.packages.list(analysis, limit=limit)
|
|
50
|
+
emit(items, COLUMNS, as_json=settings.as_json)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.command(name="get")
|
|
54
|
+
def get(analysis: str, package: str, *, settings: SettingsParam) -> None:
|
|
55
|
+
"""Show one package and whether it has settled.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
analysis:
|
|
60
|
+
The analysis the package belongs to.
|
|
61
|
+
package:
|
|
62
|
+
The package to show.
|
|
63
|
+
"""
|
|
64
|
+
emit_model(settings.client().analyses.packages.get(analysis, package), as_json=settings.as_json)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command(name="access-url")
|
|
68
|
+
def access_url(
|
|
69
|
+
analysis: str,
|
|
70
|
+
package: str,
|
|
71
|
+
*,
|
|
72
|
+
settings: SettingsParam,
|
|
73
|
+
access_ttl_hours: int | None = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Mint links to a ready package: one zip of it, and its directory.
|
|
76
|
+
|
|
77
|
+
`archive_url` downloads the whole package as one zip file. `url` opens the
|
|
78
|
+
directory; copy it with `azcopy copy "<url>" ./package --recursive`.
|
|
79
|
+
|
|
80
|
+
Parameters
|
|
81
|
+
----------
|
|
82
|
+
analysis:
|
|
83
|
+
The analysis the package belongs to.
|
|
84
|
+
package:
|
|
85
|
+
The ready package to mint a link for.
|
|
86
|
+
access_ttl_hours:
|
|
87
|
+
How many hours the link stays valid, 1 to 168; the API picks 24.
|
|
88
|
+
"""
|
|
89
|
+
result = settings.client().analyses.packages.generate_access_url(
|
|
90
|
+
analysis, package, access_ttl_hours=access_ttl_hours
|
|
91
|
+
)
|
|
92
|
+
emit_model(result, as_json=settings.as_json)
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from cyclopts import App, Parameter
|
|
4
|
+
from xoople.cli._context import SettingsParam, at_least_one
|
|
5
|
+
from xoople.cli._output import emit, emit_model, emit_one, note
|
|
6
|
+
|
|
7
|
+
app = App(
|
|
8
|
+
name="projects",
|
|
9
|
+
help="Organise analyses into projects and manage who belongs to them.",
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
COLUMNS = [
|
|
13
|
+
("UID", "uid"),
|
|
14
|
+
("NAME", "display_name"),
|
|
15
|
+
("DEFAULT", "is_default"),
|
|
16
|
+
("ARCHIVED", "archive_time"),
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
FIELDS = [
|
|
20
|
+
("Name", "name"),
|
|
21
|
+
("UID", "uid"),
|
|
22
|
+
("Display name", "display_name"),
|
|
23
|
+
("Description", "description"),
|
|
24
|
+
("Default", "is_default"),
|
|
25
|
+
("Archive time", "archive_time"),
|
|
26
|
+
("Create time", "create_time"),
|
|
27
|
+
("Update time", "update_time"),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.command(name="list")
|
|
32
|
+
def list_projects(
|
|
33
|
+
*,
|
|
34
|
+
settings: SettingsParam,
|
|
35
|
+
limit: Annotated[int | None, Parameter(validator=at_least_one)] = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""List your organisation's projects.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
limit:
|
|
42
|
+
Stop after this many, instead of every page.
|
|
43
|
+
"""
|
|
44
|
+
emit(settings.client().projects.list(limit=limit), COLUMNS, as_json=settings.as_json)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.command(name="create")
|
|
48
|
+
def create(name: str, *, description: str | None = None, settings: SettingsParam) -> None:
|
|
49
|
+
"""Create a project.
|
|
50
|
+
|
|
51
|
+
Parameters
|
|
52
|
+
----------
|
|
53
|
+
name:
|
|
54
|
+
The project's display name.
|
|
55
|
+
description:
|
|
56
|
+
The project's description, at most 500 characters.
|
|
57
|
+
"""
|
|
58
|
+
emit_one(settings.client().projects.create(name, description), FIELDS, as_json=settings.as_json)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.command(name="get")
|
|
62
|
+
def get(project: str, *, settings: SettingsParam) -> None:
|
|
63
|
+
"""Show one project, archived or not.
|
|
64
|
+
|
|
65
|
+
Parameters
|
|
66
|
+
----------
|
|
67
|
+
project:
|
|
68
|
+
The project to show.
|
|
69
|
+
"""
|
|
70
|
+
emit_one(settings.client().projects.get(project), FIELDS, as_json=settings.as_json)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@app.command(name="update")
|
|
74
|
+
def update(
|
|
75
|
+
project: str,
|
|
76
|
+
*,
|
|
77
|
+
display_name: str | None = None,
|
|
78
|
+
description: str | None = None,
|
|
79
|
+
settings: SettingsParam,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Change a project's display name, its description, or both.
|
|
82
|
+
|
|
83
|
+
Parameters
|
|
84
|
+
----------
|
|
85
|
+
project:
|
|
86
|
+
The project to change.
|
|
87
|
+
display_name:
|
|
88
|
+
The new display name.
|
|
89
|
+
description:
|
|
90
|
+
The new description. Pass an empty string to clear it.
|
|
91
|
+
"""
|
|
92
|
+
emit_one(
|
|
93
|
+
settings.client().projects.update(
|
|
94
|
+
project, display_name=display_name, description=description
|
|
95
|
+
),
|
|
96
|
+
FIELDS,
|
|
97
|
+
as_json=settings.as_json,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
MEMBER_COLUMNS = [
|
|
102
|
+
("SUBJECT", "subject_id"),
|
|
103
|
+
("NAME", "display_name"),
|
|
104
|
+
("EMAIL", "email"),
|
|
105
|
+
("ROLE", "role"),
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@app.command(name="members")
|
|
110
|
+
def members(project: str, *, settings: SettingsParam) -> None:
|
|
111
|
+
"""List a project's members and their project role.
|
|
112
|
+
|
|
113
|
+
The default project admits every organisation member, so it lists none.
|
|
114
|
+
|
|
115
|
+
Parameters
|
|
116
|
+
----------
|
|
117
|
+
project:
|
|
118
|
+
The project to list.
|
|
119
|
+
"""
|
|
120
|
+
listed = settings.client().projects.list_members(project)
|
|
121
|
+
if settings.as_json:
|
|
122
|
+
emit_model(listed, as_json=True)
|
|
123
|
+
elif listed.admits_whole_organization:
|
|
124
|
+
note(f"[dim]Every organisation member belongs to {project}[/dim]")
|
|
125
|
+
else:
|
|
126
|
+
emit(listed.members, MEMBER_COLUMNS, as_json=False)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@app.command(name="add-member")
|
|
130
|
+
def add_member(project: str, subject_id: str, *, settings: SettingsParam) -> None:
|
|
131
|
+
"""Add a subject to a project.
|
|
132
|
+
|
|
133
|
+
Parameters
|
|
134
|
+
----------
|
|
135
|
+
project:
|
|
136
|
+
The project to add to.
|
|
137
|
+
subject_id:
|
|
138
|
+
The subject to add.
|
|
139
|
+
"""
|
|
140
|
+
emit_model(settings.client().projects.add_member(project, subject_id), as_json=settings.as_json)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@app.command(name="remove-member")
|
|
144
|
+
def remove_member(project: str, subject: str, *, settings: SettingsParam) -> None:
|
|
145
|
+
"""Remove a subject from a project.
|
|
146
|
+
|
|
147
|
+
Parameters
|
|
148
|
+
----------
|
|
149
|
+
project:
|
|
150
|
+
The project to remove from.
|
|
151
|
+
subject:
|
|
152
|
+
The subject to remove.
|
|
153
|
+
"""
|
|
154
|
+
settings.client().projects.remove_member(project, subject)
|
|
155
|
+
note(f"[dim]{subject} is no longer a member of {project}[/dim]")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@app.command(name="move")
|
|
159
|
+
def move(project: str, destination: str, analyses: list[str], *, settings: SettingsParam) -> None:
|
|
160
|
+
"""Move named analyses into another project, pausing their schedules.
|
|
161
|
+
|
|
162
|
+
Parameters
|
|
163
|
+
----------
|
|
164
|
+
project:
|
|
165
|
+
The project the analyses are in now.
|
|
166
|
+
destination:
|
|
167
|
+
The project to move them into.
|
|
168
|
+
analyses:
|
|
169
|
+
The analysis identifiers to move, at most 500 per call.
|
|
170
|
+
"""
|
|
171
|
+
emit_model(
|
|
172
|
+
settings.client().projects.move_analyses_to_project(project, analyses, destination),
|
|
173
|
+
as_json=settings.as_json,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@app.command(name="archive")
|
|
178
|
+
def archive(project: str, *, settings: SettingsParam) -> None:
|
|
179
|
+
"""Archive a project and pause its schedules.
|
|
180
|
+
|
|
181
|
+
The default project cannot be archived.
|
|
182
|
+
|
|
183
|
+
Parameters
|
|
184
|
+
----------
|
|
185
|
+
project:
|
|
186
|
+
The project to archive.
|
|
187
|
+
"""
|
|
188
|
+
settings.client().projects.archive(project)
|
|
189
|
+
note(f"[dim]{project} is archived[/dim]")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@app.command(name="delete")
|
|
193
|
+
def delete(project: str, *, settings: SettingsParam) -> None:
|
|
194
|
+
"""Delete an archived project and remove its stored data.
|
|
195
|
+
|
|
196
|
+
The project must hold no analyses. It counts against the project limit
|
|
197
|
+
until storage removal finishes.
|
|
198
|
+
|
|
199
|
+
Parameters
|
|
200
|
+
----------
|
|
201
|
+
project:
|
|
202
|
+
The archived project to delete.
|
|
203
|
+
"""
|
|
204
|
+
settings.client().projects.delete(project)
|
|
205
|
+
note(f"[dim]{project} is deleted; its storage is being removed[/dim]")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@app.command(name="record-access")
|
|
209
|
+
def record_access(project: str, *, settings: SettingsParam) -> None:
|
|
210
|
+
"""Record that you opened a project, which orders `projects recent`.
|
|
211
|
+
|
|
212
|
+
Parameters
|
|
213
|
+
----------
|
|
214
|
+
project:
|
|
215
|
+
The project you opened.
|
|
216
|
+
"""
|
|
217
|
+
settings.client().projects.record_access(project)
|
|
218
|
+
note(f"[dim]{project} is your most recent project[/dim]")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@app.command(name="recent")
|
|
222
|
+
def recent(
|
|
223
|
+
*,
|
|
224
|
+
settings: SettingsParam,
|
|
225
|
+
limit: Annotated[int | None, Parameter(validator=at_least_one)] = None,
|
|
226
|
+
) -> None:
|
|
227
|
+
"""List the projects you opened most recently.
|
|
228
|
+
|
|
229
|
+
Parameters
|
|
230
|
+
----------
|
|
231
|
+
limit:
|
|
232
|
+
Return this many, instead of the default four.
|
|
233
|
+
"""
|
|
234
|
+
emit(settings.client().projects.recent(limit=limit), COLUMNS, as_json=settings.as_json)
|
xoople_cli-0.2.0/README.md
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
# xoople-cli
|
|
2
|
-
|
|
3
|
-
Command-line interface for the Xoople Products API, over [`xoople-sdk`](../xoople-sdk).
|
|
4
|
-
|
|
5
|
-
```console
|
|
6
|
-
$ uv tool install xoople-cli
|
|
7
|
-
$ export XOOPLE_API_URL=https://api.xoople.com XOOPLE_TOKEN=...
|
|
8
|
-
$ xoople analyses list
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
Every command reads `--api-url` and `--token` from `XOOPLE_API_URL` and
|
|
12
|
-
`XOOPLE_TOKEN`. `--json` prints the API response verbatim instead of a table, so
|
|
13
|
-
output pipes into `jq`.
|
|
14
|
-
|
|
15
|
-
## Commands
|
|
16
|
-
|
|
17
|
-
| Group | Commands |
|
|
18
|
-
| --- | --- |
|
|
19
|
-
| `analyses` | `list` `get` `create` `update` `cancel` `estimate` |
|
|
20
|
-
| `runs` | `list` `get` |
|
|
21
|
-
| `outputs` | `list` `get` `access-url` |
|
|
22
|
-
| `schedules` | `list` `get` `create` `pause` `resume` `cancel` `run` `estimate-run` |
|
|
23
|
-
| `events` | `list` `get` |
|
|
24
|
-
| `usage` | `history` `limits` |
|
|
25
|
-
| `catalog` | `products` `measures` `designs` |
|
|
26
|
-
| `access` | `me` `token` `features` |
|
|
27
|
-
|
|
28
|
-
## Submitting an analysis
|
|
29
|
-
|
|
30
|
-
The product spec is deeply nested and differs per product, so `create` and
|
|
31
|
-
`estimate` take it as JSON rather than as flags:
|
|
32
|
-
|
|
33
|
-
```console
|
|
34
|
-
$ xoople catalog designs --json | jq '.[0].spec_fragment' > spec.json
|
|
35
|
-
$ # fill in period and aoi, then:
|
|
36
|
-
$ xoople analyses create -f spec.json --display-name "South Wales NDVI" --wait
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
The file holds either the bare spec or a whole create request (anything with a
|
|
40
|
-
`spec` key). `-f -` reads stdin. `--wait` polls the initial run and exits
|
|
41
|
-
non-zero if it does not reach `COMPLETE`.
|
|
42
|
-
|
|
43
|
-
## Exit codes
|
|
44
|
-
|
|
45
|
-
`0` success · `1` unexpected CLI error · `2` usage error · `3` unauthenticated · `4` forbidden ·
|
|
46
|
-
`5` not found · `6` bad request or validation · `7` conflict · `8` rate limited ·
|
|
47
|
-
`9` server error · `10` connection failure · `11` unreadable response ·
|
|
48
|
-
`12` run finished in a non-`COMPLETE` state · `13` `--wait` timed out.
|
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
from typing import Annotated
|
|
2
|
-
|
|
3
|
-
from cyclopts import App, Parameter
|
|
4
|
-
from xoople.cli._context import SettingsParam, at_least_one
|
|
5
|
-
from xoople.cli._output import emit, emit_model, emit_one, note
|
|
6
|
-
|
|
7
|
-
app = App(
|
|
8
|
-
name="projects",
|
|
9
|
-
help="Organise analyses into projects and manage who belongs to them.",
|
|
10
|
-
)
|
|
11
|
-
|
|
12
|
-
COLUMNS = [
|
|
13
|
-
("UID", "uid"),
|
|
14
|
-
("NAME", "display_name"),
|
|
15
|
-
("DEFAULT", "is_default"),
|
|
16
|
-
("ARCHIVED", "archive_time"),
|
|
17
|
-
]
|
|
18
|
-
|
|
19
|
-
FIELDS = [
|
|
20
|
-
("Name", "name"),
|
|
21
|
-
("UID", "uid"),
|
|
22
|
-
("Display name", "display_name"),
|
|
23
|
-
("Default", "is_default"),
|
|
24
|
-
("Archive time", "archive_time"),
|
|
25
|
-
("Create time", "create_time"),
|
|
26
|
-
("Update time", "update_time"),
|
|
27
|
-
]
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
@app.command(name="list")
|
|
31
|
-
def list_projects(
|
|
32
|
-
*,
|
|
33
|
-
settings: SettingsParam,
|
|
34
|
-
limit: Annotated[int | None, Parameter(validator=at_least_one)] = None,
|
|
35
|
-
) -> None:
|
|
36
|
-
"""List your organisation's projects.
|
|
37
|
-
|
|
38
|
-
Parameters
|
|
39
|
-
----------
|
|
40
|
-
limit:
|
|
41
|
-
Stop after this many, instead of every page.
|
|
42
|
-
"""
|
|
43
|
-
emit(settings.client().projects.list(limit=limit), COLUMNS, as_json=settings.as_json)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
@app.command(name="create")
|
|
47
|
-
def create(name: str, *, settings: SettingsParam) -> None:
|
|
48
|
-
"""Create a project.
|
|
49
|
-
|
|
50
|
-
Parameters
|
|
51
|
-
----------
|
|
52
|
-
name:
|
|
53
|
-
The project's display name.
|
|
54
|
-
"""
|
|
55
|
-
emit_one(settings.client().projects.create(name), FIELDS, as_json=settings.as_json)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
@app.command(name="add-member")
|
|
59
|
-
def add_member(project: str, subject_id: str, *, settings: SettingsParam) -> None:
|
|
60
|
-
"""Add a subject to a project.
|
|
61
|
-
|
|
62
|
-
Parameters
|
|
63
|
-
----------
|
|
64
|
-
project:
|
|
65
|
-
The project to add to.
|
|
66
|
-
subject_id:
|
|
67
|
-
The subject to add.
|
|
68
|
-
"""
|
|
69
|
-
emit_model(settings.client().projects.add_member(project, subject_id), as_json=settings.as_json)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
@app.command(name="remove-member")
|
|
73
|
-
def remove_member(project: str, subject: str, *, settings: SettingsParam) -> None:
|
|
74
|
-
"""Remove a subject from a project.
|
|
75
|
-
|
|
76
|
-
Parameters
|
|
77
|
-
----------
|
|
78
|
-
project:
|
|
79
|
-
The project to remove from.
|
|
80
|
-
subject:
|
|
81
|
-
The subject to remove.
|
|
82
|
-
"""
|
|
83
|
-
settings.client().projects.remove_member(project, subject)
|
|
84
|
-
note(f"[dim]{subject} is no longer a member of {project}[/dim]")
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
@app.command(name="move")
|
|
88
|
-
def move(project: str, destination: str, analyses: list[str], *, settings: SettingsParam) -> None:
|
|
89
|
-
"""Move named analyses into another project, pausing their schedules.
|
|
90
|
-
|
|
91
|
-
Parameters
|
|
92
|
-
----------
|
|
93
|
-
project:
|
|
94
|
-
The project the analyses are in now.
|
|
95
|
-
destination:
|
|
96
|
-
The project to move them into.
|
|
97
|
-
analyses:
|
|
98
|
-
The analysis identifiers to move, at most 500 per call.
|
|
99
|
-
"""
|
|
100
|
-
emit_model(
|
|
101
|
-
settings.client().projects.move_analyses_to_project(project, analyses, destination),
|
|
102
|
-
as_json=settings.as_json,
|
|
103
|
-
)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
@app.command(name="archive")
|
|
107
|
-
def archive(project: str, *, settings: SettingsParam) -> None:
|
|
108
|
-
"""Archive a project and pause its schedules.
|
|
109
|
-
|
|
110
|
-
The default project cannot be archived.
|
|
111
|
-
|
|
112
|
-
Parameters
|
|
113
|
-
----------
|
|
114
|
-
project:
|
|
115
|
-
The project to archive.
|
|
116
|
-
"""
|
|
117
|
-
settings.client().projects.archive(project)
|
|
118
|
-
note(f"[dim]{project} is archived[/dim]")
|
|
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
|