xoople-cli 0.1.0__tar.gz → 0.3.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.1.0 → xoople_cli-0.3.0}/PKG-INFO +26 -5
- {xoople_cli-0.1.0 → xoople_cli-0.3.0}/README.md +23 -2
- {xoople_cli-0.1.0 → xoople_cli-0.3.0}/pyproject.toml +3 -3
- {xoople_cli-0.1.0 → xoople_cli-0.3.0}/pyproject.toml.orig +3 -3
- xoople_cli-0.3.0/src/xoople/cli/_ciam.py +190 -0
- {xoople_cli-0.1.0 → xoople_cli-0.3.0}/src/xoople/cli/_context.py +38 -9
- {xoople_cli-0.1.0 → xoople_cli-0.3.0}/src/xoople/cli/_errors.py +11 -4
- {xoople_cli-0.1.0 → xoople_cli-0.3.0}/src/xoople/cli/_output.py +3 -6
- {xoople_cli-0.1.0 → xoople_cli-0.3.0}/src/xoople/cli/access.py +38 -2
- {xoople_cli-0.1.0 → xoople_cli-0.3.0}/src/xoople/cli/analyses.py +121 -1
- xoople_cli-0.3.0/src/xoople/cli/auth.py +53 -0
- {xoople_cli-0.1.0 → xoople_cli-0.3.0}/src/xoople/cli/catalog.py +45 -0
- {xoople_cli-0.1.0 → xoople_cli-0.3.0}/src/xoople/cli/main.py +37 -4
- {xoople_cli-0.1.0 → xoople_cli-0.3.0}/src/xoople/cli/outputs.py +34 -0
- xoople_cli-0.3.0/src/xoople/cli/projects.py +190 -0
- {xoople_cli-0.1.0 → xoople_cli-0.3.0}/LICENSE +0 -0
- {xoople_cli-0.1.0 → xoople_cli-0.3.0}/src/xoople/cli/__init__.py +0 -0
- {xoople_cli-0.1.0 → xoople_cli-0.3.0}/src/xoople/cli/__main__.py +0 -0
- {xoople_cli-0.1.0 → xoople_cli-0.3.0}/src/xoople/cli/_spec.py +0 -0
- {xoople_cli-0.1.0 → xoople_cli-0.3.0}/src/xoople/cli/_version.py +0 -0
- {xoople_cli-0.1.0 → xoople_cli-0.3.0}/src/xoople/cli/events.py +0 -0
- {xoople_cli-0.1.0 → xoople_cli-0.3.0}/src/xoople/cli/runs.py +0 -0
- {xoople_cli-0.1.0 → xoople_cli-0.3.0}/src/xoople/cli/schedules.py +0 -0
- {xoople_cli-0.1.0 → xoople_cli-0.3.0}/src/xoople/cli/usage.py +0 -0
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: xoople-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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.5.0
|
|
10
10
|
Requires-Dist: cyclopts>=4.18,<5
|
|
11
11
|
Requires-Dist: rich>=13.7
|
|
12
|
-
Requires-Dist: xoople-sdk[entra]>=0.
|
|
12
|
+
Requires-Dist: xoople-sdk[entra]>=0.5.0 ; extra == 'entra'
|
|
13
13
|
Requires-Python: >=3.12
|
|
14
14
|
Project-URL: Homepage, https://xoople.com
|
|
15
15
|
Provides-Extra: entra
|
|
@@ -41,6 +41,26 @@ output pipes into `jq`.
|
|
|
41
41
|
| `usage` | `history` `limits` |
|
|
42
42
|
| `catalog` | `products` `measures` `designs` |
|
|
43
43
|
| `access` | `me` `token` `features` |
|
|
44
|
+
| `auth` | `login` `logout` `status` |
|
|
45
|
+
|
|
46
|
+
## Signing in
|
|
47
|
+
|
|
48
|
+
`auth login` signs in through the browser with PKCE and stores the session at
|
|
49
|
+
`$XDG_CONFIG_HOME/xoople/session.json` (`~/.config/xoople/session.json` by
|
|
50
|
+
default), readable only by you. Every later command uses that session when
|
|
51
|
+
neither `--token` nor `--entra-scope` is given, and refreshes it when the
|
|
52
|
+
access token is close to expiry.
|
|
53
|
+
|
|
54
|
+
```console
|
|
55
|
+
$ export XOOPLE_API_URL=https://api.xoople.com XOOPLE_CIAM_CLIENT_ID=client_...
|
|
56
|
+
$ xoople auth login
|
|
57
|
+
$ xoople analyses list
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`auth status` exits `3` when there is no stored session, so a script can test
|
|
61
|
+
for a sign-in. `--ciam-redirect-uri` (`XOOPLE_CIAM_REDIRECT_URI`) overrides the
|
|
62
|
+
loopback URI the browser returns to; it must match a URI registered for the
|
|
63
|
+
CIAM client.
|
|
44
64
|
|
|
45
65
|
## Submitting an analysis
|
|
46
66
|
|
|
@@ -48,7 +68,7 @@ The product spec is deeply nested and differs per product, so `create` and
|
|
|
48
68
|
`estimate` take it as JSON rather than as flags:
|
|
49
69
|
|
|
50
70
|
```console
|
|
51
|
-
$ xoople catalog designs --json | jq '.
|
|
71
|
+
$ xoople catalog designs --json | jq '.[0].spec_fragment' > spec.json
|
|
52
72
|
$ # fill in period and aoi, then:
|
|
53
73
|
$ xoople analyses create -f spec.json --display-name "South Wales NDVI" --wait
|
|
54
74
|
```
|
|
@@ -59,7 +79,8 @@ non-zero if it does not reach `COMPLETE`.
|
|
|
59
79
|
|
|
60
80
|
## Exit codes
|
|
61
81
|
|
|
62
|
-
`0` success · `1` unexpected CLI error · `2` usage error ·
|
|
82
|
+
`0` success · `1` unexpected CLI error · `2` usage error ·
|
|
83
|
+
`3` unauthenticated, including a failed or absent sign-in · `4` forbidden ·
|
|
63
84
|
`5` not found · `6` bad request or validation · `7` conflict · `8` rate limited ·
|
|
64
85
|
`9` server error · `10` connection failure · `11` unreadable response ·
|
|
65
86
|
`12` run finished in a non-`COMPLETE` state · `13` `--wait` timed out.
|
|
@@ -24,6 +24,26 @@ output pipes into `jq`.
|
|
|
24
24
|
| `usage` | `history` `limits` |
|
|
25
25
|
| `catalog` | `products` `measures` `designs` |
|
|
26
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
|
+
neither `--token` nor `--entra-scope` 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 XOOPLE_CIAM_CLIENT_ID=client_...
|
|
39
|
+
$ xoople auth login
|
|
40
|
+
$ xoople analyses list
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`auth status` exits `3` when there is no stored session, so a script can test
|
|
44
|
+
for a sign-in. `--ciam-redirect-uri` (`XOOPLE_CIAM_REDIRECT_URI`) overrides the
|
|
45
|
+
loopback URI the browser returns to; it must match a URI registered for the
|
|
46
|
+
CIAM client.
|
|
27
47
|
|
|
28
48
|
## Submitting an analysis
|
|
29
49
|
|
|
@@ -31,7 +51,7 @@ The product spec is deeply nested and differs per product, so `create` and
|
|
|
31
51
|
`estimate` take it as JSON rather than as flags:
|
|
32
52
|
|
|
33
53
|
```console
|
|
34
|
-
$ xoople catalog designs --json | jq '.
|
|
54
|
+
$ xoople catalog designs --json | jq '.[0].spec_fragment' > spec.json
|
|
35
55
|
$ # fill in period and aoi, then:
|
|
36
56
|
$ xoople analyses create -f spec.json --display-name "South Wales NDVI" --wait
|
|
37
57
|
```
|
|
@@ -42,7 +62,8 @@ non-zero if it does not reach `COMPLETE`.
|
|
|
42
62
|
|
|
43
63
|
## Exit codes
|
|
44
64
|
|
|
45
|
-
`0` success · `1` unexpected CLI error · `2` usage error ·
|
|
65
|
+
`0` success · `1` unexpected CLI error · `2` usage error ·
|
|
66
|
+
`3` unauthenticated, including a failed or absent sign-in · `4` forbidden ·
|
|
46
67
|
`5` not found · `6` bad request or validation · `7` conflict · `8` rate limited ·
|
|
47
68
|
`9` server error · `10` connection failure · `11` unreadable response ·
|
|
48
69
|
`12` run finished in a non-`COMPLETE` state · `13` `--wait` timed out.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "xoople-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Command-line interface for the Xoople Products API."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -11,13 +11,13 @@ classifiers = [
|
|
|
11
11
|
"Programming Language :: Python :: 3.12",
|
|
12
12
|
]
|
|
13
13
|
dependencies = [
|
|
14
|
-
"xoople-sdk>=0.
|
|
14
|
+
"xoople-sdk>=0.5.0",
|
|
15
15
|
"cyclopts>=4.18,<5",
|
|
16
16
|
"rich>=13.7",
|
|
17
17
|
]
|
|
18
18
|
|
|
19
19
|
[project.optional-dependencies]
|
|
20
|
-
entra = ["xoople-sdk[entra]>=0.
|
|
20
|
+
entra = ["xoople-sdk[entra]>=0.5.0"]
|
|
21
21
|
|
|
22
22
|
[project.scripts]
|
|
23
23
|
xoople = "xoople.cli.main:main"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "xoople-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.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.5.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
|
|
@@ -26,7 +26,7 @@ dependencies = [
|
|
|
26
26
|
# Mirrors xoople-sdk[entra]: internal callers authenticating with Entra ID via
|
|
27
27
|
# `--entra-scope`. Not needed for the token path.
|
|
28
28
|
entra = [
|
|
29
|
-
"xoople-sdk[entra]>=0.
|
|
29
|
+
"xoople-sdk[entra]>=0.5.0",
|
|
30
30
|
]
|
|
31
31
|
|
|
32
32
|
[project.scripts]
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""The browser half of a CIAM sign-in, and where the session is stored.
|
|
2
|
+
|
|
3
|
+
The SDK owns the protocol. It holds the endpoints, PKCE, both grants and the
|
|
4
|
+
refresh. This module holds what needs a terminal. It opens a browser, binds the
|
|
5
|
+
loopback port and writes the session file.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import secrets
|
|
10
|
+
import tempfile
|
|
11
|
+
import time
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import webbrowser
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Protocol, Self
|
|
19
|
+
|
|
20
|
+
from pydantic import ValidationError
|
|
21
|
+
from xoople.cli._errors import LoginFailed
|
|
22
|
+
from xoople.sdk.auth import (
|
|
23
|
+
CiamUserSession,
|
|
24
|
+
ciam_authorize_url,
|
|
25
|
+
ciam_exchange_code,
|
|
26
|
+
ciam_pkce_pair,
|
|
27
|
+
)
|
|
28
|
+
from xoople.sdk.errors import CredentialError
|
|
29
|
+
|
|
30
|
+
DEFAULT_REDIRECT_URI = "http://localhost:8976/callback"
|
|
31
|
+
"""Fixed, because CIAM matches a redirect URI exactly and must hold this one."""
|
|
32
|
+
|
|
33
|
+
_CALLBACK_TIMEOUT_SECONDS = 300
|
|
34
|
+
|
|
35
|
+
_DONE_PAGE = b"<html><body><p>Xoople sign-in is complete. Close this tab.</p></body></html>"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _Listener(Protocol):
|
|
39
|
+
def __call__(self, redirect_uri: str, timeout: float) -> dict[str, str]: ...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _Exchanger(Protocol):
|
|
43
|
+
def __call__(self, *, client_id: str, code: str, verifier: str) -> CiamUserSession: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class CiamConfig:
|
|
48
|
+
"""What a login needs: the CIAM client and the URI the browser comes back to."""
|
|
49
|
+
|
|
50
|
+
client_id: str
|
|
51
|
+
redirect_uri: str = DEFAULT_REDIRECT_URI
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def resolve(cls, client_id: str | None, redirect_uri: str | None) -> Self:
|
|
55
|
+
if not client_id:
|
|
56
|
+
raise LoginFailed("no CIAM client: pass --ciam-client-id or set XOOPLE_CIAM_CLIENT_ID")
|
|
57
|
+
return cls(client_id=client_id, redirect_uri=redirect_uri or DEFAULT_REDIRECT_URI)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def session_path() -> Path:
|
|
61
|
+
"""Where the session file lives, honouring `XDG_CONFIG_HOME`."""
|
|
62
|
+
root = os.environ.get("XDG_CONFIG_HOME")
|
|
63
|
+
base = Path(root) if root else Path.home() / ".config"
|
|
64
|
+
return base / "xoople" / "session.json"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def read_session(path: Path | None = None) -> CiamUserSession | None:
|
|
68
|
+
"""The stored session, or None when there is none this CLI can read."""
|
|
69
|
+
target = path or session_path()
|
|
70
|
+
try:
|
|
71
|
+
raw = target.read_bytes()
|
|
72
|
+
except OSError:
|
|
73
|
+
return None
|
|
74
|
+
try:
|
|
75
|
+
return CiamUserSession.model_validate_json(raw)
|
|
76
|
+
except ValidationError:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def write_session(session: CiamUserSession, path: Path | None = None) -> Path:
|
|
81
|
+
"""Store the session so only this user can read it.
|
|
82
|
+
|
|
83
|
+
The mode argument of `os.open` applies to a new file only, so a rewrite of
|
|
84
|
+
an existing permissive file would keep its mode. This writes a private
|
|
85
|
+
temporary file and renames it over the target.
|
|
86
|
+
"""
|
|
87
|
+
target = path or session_path()
|
|
88
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
handle, temporary = tempfile.mkstemp(dir=target.parent, prefix=".session-")
|
|
90
|
+
try:
|
|
91
|
+
os.fchmod(handle, 0o600)
|
|
92
|
+
with os.fdopen(handle, "w", encoding="utf-8") as stream:
|
|
93
|
+
stream.write(session.model_dump_json())
|
|
94
|
+
# Rename is atomic within one directory, so a reader never sees a
|
|
95
|
+
# half-written session.
|
|
96
|
+
os.replace(temporary, target)
|
|
97
|
+
except BaseException:
|
|
98
|
+
Path(temporary).unlink(missing_ok=True)
|
|
99
|
+
raise
|
|
100
|
+
return target
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def clear_session(path: Path | None = None) -> bool:
|
|
104
|
+
"""Remove the stored session. False when there was nothing to remove."""
|
|
105
|
+
target = path or session_path()
|
|
106
|
+
try:
|
|
107
|
+
target.unlink()
|
|
108
|
+
except OSError:
|
|
109
|
+
return False
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class _CallbackHandler(BaseHTTPRequestHandler):
|
|
114
|
+
"""Reads the query off one browser redirect and answers with a closing page."""
|
|
115
|
+
|
|
116
|
+
captured: dict[str, str]
|
|
117
|
+
callback_path: str
|
|
118
|
+
|
|
119
|
+
def do_GET(self) -> None:
|
|
120
|
+
parsed = urllib.parse.urlsplit(self.path)
|
|
121
|
+
if parsed.path != self.callback_path:
|
|
122
|
+
self.send_error(404)
|
|
123
|
+
return
|
|
124
|
+
for key, values in urllib.parse.parse_qs(parsed.query).items():
|
|
125
|
+
self.captured[key] = values[0]
|
|
126
|
+
self.send_response(200)
|
|
127
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
128
|
+
self.send_header("Content-Length", str(len(_DONE_PAGE)))
|
|
129
|
+
self.end_headers()
|
|
130
|
+
self.wfile.write(_DONE_PAGE)
|
|
131
|
+
|
|
132
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
133
|
+
del format, args # The server must not write to the CLI's streams.
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def await_callback(redirect_uri: str, timeout: float) -> dict[str, str]:
|
|
137
|
+
"""Serve the redirect URI until the browser returns, and give back its query."""
|
|
138
|
+
parsed = urllib.parse.urlsplit(redirect_uri)
|
|
139
|
+
if parsed.hostname is None or parsed.port is None:
|
|
140
|
+
raise LoginFailed(f"redirect URI needs an explicit host and port: {redirect_uri}")
|
|
141
|
+
captured: dict[str, str] = {}
|
|
142
|
+
handler = type(
|
|
143
|
+
"_BoundCallbackHandler",
|
|
144
|
+
(_CallbackHandler,),
|
|
145
|
+
{"captured": captured, "callback_path": parsed.path or "/"},
|
|
146
|
+
)
|
|
147
|
+
deadline = time.monotonic() + timeout
|
|
148
|
+
with HTTPServer((parsed.hostname, parsed.port), handler) as server:
|
|
149
|
+
# A browser also asks for /favicon.ico, so serve until the callback lands.
|
|
150
|
+
while not captured and time.monotonic() < deadline:
|
|
151
|
+
server.timeout = max(1.0, deadline - time.monotonic())
|
|
152
|
+
server.handle_request()
|
|
153
|
+
if not captured:
|
|
154
|
+
raise LoginFailed("the browser did not return a sign-in result in time")
|
|
155
|
+
return captured
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def login(
|
|
159
|
+
config: CiamConfig,
|
|
160
|
+
*,
|
|
161
|
+
announce: Callable[[str], None],
|
|
162
|
+
listener: _Listener = await_callback,
|
|
163
|
+
open_browser: Callable[[str], bool] = webbrowser.open,
|
|
164
|
+
exchange: _Exchanger = ciam_exchange_code,
|
|
165
|
+
callback_timeout: float = _CALLBACK_TIMEOUT_SECONDS,
|
|
166
|
+
) -> CiamUserSession:
|
|
167
|
+
"""Run one browser login and return the session it produced."""
|
|
168
|
+
verifier, challenge = ciam_pkce_pair()
|
|
169
|
+
state = secrets.token_urlsafe(16)
|
|
170
|
+
url = ciam_authorize_url(
|
|
171
|
+
client_id=config.client_id,
|
|
172
|
+
redirect_uri=config.redirect_uri,
|
|
173
|
+
challenge=challenge,
|
|
174
|
+
state=state,
|
|
175
|
+
)
|
|
176
|
+
announce(url)
|
|
177
|
+
open_browser(url)
|
|
178
|
+
captured = listener(config.redirect_uri, callback_timeout)
|
|
179
|
+
if "error" in captured:
|
|
180
|
+
raise LoginFailed(f"CIAM refused the sign-in: {captured['error']}")
|
|
181
|
+
if captured.get("state") != state:
|
|
182
|
+
raise LoginFailed("the sign-in result does not match this request")
|
|
183
|
+
code = captured.get("code")
|
|
184
|
+
if not code:
|
|
185
|
+
raise LoginFailed("the sign-in result carries no authorization code")
|
|
186
|
+
try:
|
|
187
|
+
return exchange(client_id=config.client_id, code=code, verifier=verifier)
|
|
188
|
+
except CredentialError as exc:
|
|
189
|
+
# One exit code covers every sign-in failure. The SDK message says which.
|
|
190
|
+
raise LoginFailed(str(exc)) from None
|
|
@@ -1,11 +1,17 @@
|
|
|
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.auth import
|
|
9
|
+
from xoople.sdk.auth import (
|
|
10
|
+
CiamUserCredentials,
|
|
11
|
+
CiamUserSession,
|
|
12
|
+
EntraCredential,
|
|
13
|
+
StaticToken,
|
|
14
|
+
)
|
|
9
15
|
|
|
10
16
|
|
|
11
17
|
@dataclass(frozen=True)
|
|
@@ -15,16 +21,26 @@ class Credentials:
|
|
|
15
21
|
base_url: str | None = None
|
|
16
22
|
token: str | None = None
|
|
17
23
|
entra_scope: str | None = None
|
|
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
|
|
|
21
29
|
|
|
22
|
-
#
|
|
30
|
+
# The client factory is swapped for a fake in tests.
|
|
23
31
|
type ClientFactory = Callable[[Credentials], XoopleClient]
|
|
24
32
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
+
|
|
42
|
+
# The launcher injects ``Settings`` via ``parse=False``; commands stay plain
|
|
43
|
+
# functions and tests drive them over a fake transport.
|
|
28
44
|
type SettingsParam = Annotated[Settings, Parameter(parse=False)]
|
|
29
45
|
|
|
30
46
|
|
|
@@ -46,6 +62,7 @@ class Settings:
|
|
|
46
62
|
|
|
47
63
|
credentials: Credentials
|
|
48
64
|
factory: ClientFactory
|
|
65
|
+
login: LoginRunner = _ciam.login
|
|
49
66
|
as_json: bool = False
|
|
50
67
|
_client: XoopleClient | None = field(default=None, repr=False)
|
|
51
68
|
|
|
@@ -60,12 +77,24 @@ class Settings:
|
|
|
60
77
|
self._client = None
|
|
61
78
|
|
|
62
79
|
|
|
80
|
+
def _persist_session(session: CiamUserSession) -> None:
|
|
81
|
+
_ciam.write_session(session)
|
|
82
|
+
|
|
83
|
+
|
|
63
84
|
def _credential_provider(credentials: Credentials) -> CredentialProvider:
|
|
64
85
|
if credentials.entra_scope:
|
|
65
86
|
return EntraCredential(credentials.entra_scope)
|
|
66
|
-
if
|
|
67
|
-
|
|
68
|
-
|
|
87
|
+
if credentials.token:
|
|
88
|
+
return StaticToken(credentials.token)
|
|
89
|
+
session = _ciam.read_session()
|
|
90
|
+
if session is not None:
|
|
91
|
+
# The credential refreshes a user token itself. Store what it mints, so
|
|
92
|
+
# the next run does not spend another refresh token.
|
|
93
|
+
return CiamUserCredentials(session, on_refresh=_persist_session)
|
|
94
|
+
raise UsageError(
|
|
95
|
+
"no credentials: run `xoople auth login`, pass --token, set XOOPLE_TOKEN, "
|
|
96
|
+
"or pass --entra-scope"
|
|
97
|
+
)
|
|
69
98
|
|
|
70
99
|
|
|
71
100
|
def build_client(credentials: Credentials) -> XoopleClient:
|
|
@@ -14,10 +14,7 @@ EXIT_CODES: dict[type[errors.XoopleError], int] = {
|
|
|
14
14
|
errors.XoopleResponseError: 11,
|
|
15
15
|
}
|
|
16
16
|
_FALLBACK_EXIT = 1
|
|
17
|
-
#
|
|
18
|
-
# ``CliError`` today; every CLI-detected outcome has its own subclass below.
|
|
19
|
-
# Outcomes the CLI decides rather than the API. Kept here with the status map so
|
|
20
|
-
# the README's exit-code table has one source.
|
|
17
|
+
# Generic CLI errors fall back to 1; CLI-only outcomes use the codes below.
|
|
21
18
|
USAGE_EXIT = 2
|
|
22
19
|
FAILED_RUN_EXIT = 12
|
|
23
20
|
WAIT_TIMEOUT_EXIT = 13
|
|
@@ -35,6 +32,16 @@ class UsageError(CliError):
|
|
|
35
32
|
exit_code = USAGE_EXIT
|
|
36
33
|
|
|
37
34
|
|
|
35
|
+
class LoginFailed(CliError):
|
|
36
|
+
"""A CIAM sign-in did not produce a session.
|
|
37
|
+
|
|
38
|
+
Takes the API's unauthenticated code: the outcome a caller must handle is
|
|
39
|
+
the same, so a script does not need a second number for it.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
exit_code = EXIT_CODES[errors.AuthenticationError]
|
|
43
|
+
|
|
44
|
+
|
|
38
45
|
class RunFailed(CliError):
|
|
39
46
|
"""``--wait`` finished, but the run ended in a non-``COMPLETE`` state."""
|
|
40
47
|
|
|
@@ -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
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
1
4
|
from cyclopts import App
|
|
2
5
|
from pydantic import BaseModel
|
|
3
6
|
from xoople.cli._context import SettingsParam
|
|
@@ -24,11 +27,44 @@ def me(*, settings: SettingsParam) -> None:
|
|
|
24
27
|
|
|
25
28
|
|
|
26
29
|
@app.command(name="token")
|
|
27
|
-
def token(
|
|
28
|
-
|
|
30
|
+
def token(
|
|
31
|
+
*,
|
|
32
|
+
settings: SettingsParam,
|
|
33
|
+
format: Literal["json", "share"] = "json",
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Print your Delta Sharing bearer token and host.
|
|
36
|
+
|
|
37
|
+
With --format share, write the downloadable .share profile to stdout —
|
|
38
|
+
pipe to a file for any Delta Sharing client that reads its credentials
|
|
39
|
+
from it.
|
|
40
|
+
"""
|
|
41
|
+
if format == "share":
|
|
42
|
+
# Write the served bytes, not decoded text: a text stream applies the
|
|
43
|
+
# locale encoding and newline translation, so `> profile.share` would
|
|
44
|
+
# not be byte-for-byte the server's.
|
|
45
|
+
sys.stdout.buffer.write(settings.client().data_access.share_profile())
|
|
46
|
+
sys.stdout.buffer.flush()
|
|
47
|
+
return
|
|
29
48
|
emit_model(settings.client().data_access.token(), as_json=settings.as_json)
|
|
30
49
|
|
|
31
50
|
|
|
51
|
+
@app.command(name="rotate-token")
|
|
52
|
+
def rotate_token(
|
|
53
|
+
*,
|
|
54
|
+
settings: SettingsParam,
|
|
55
|
+
existing_token_expire_in_seconds: int = 3600,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Issue a fresh Delta Sharing token, keeping your existing share grants.
|
|
58
|
+
|
|
59
|
+
The superseded token keeps working for --existing-token-expire-in-seconds,
|
|
60
|
+
so running jobs can pick up the new one. Pass 0 to revoke it now.
|
|
61
|
+
"""
|
|
62
|
+
emit_model(
|
|
63
|
+
settings.client().data_access.rotate_token(existing_token_expire_in_seconds),
|
|
64
|
+
as_json=settings.as_json,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
32
68
|
@app.command(name="features")
|
|
33
69
|
def features(*, settings: SettingsParam) -> None:
|
|
34
70
|
"""Show which feature flags are on for you, and why."""
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
|
-
from typing import Annotated
|
|
2
|
+
from typing import Annotated, Literal
|
|
3
3
|
|
|
4
4
|
from cyclopts import App, Parameter
|
|
5
5
|
from xoople.cli._context import SettingsParam, at_least_one
|
|
@@ -39,6 +39,16 @@ FIELDS = [
|
|
|
39
39
|
("Updated", "update_time"),
|
|
40
40
|
]
|
|
41
41
|
|
|
42
|
+
STATUS_FIELDS = [
|
|
43
|
+
("Name", "name"),
|
|
44
|
+
("UID", "uid"),
|
|
45
|
+
("State", "state"),
|
|
46
|
+
("State message", "state_message"),
|
|
47
|
+
("Retries", "retry_count"),
|
|
48
|
+
("Created", "create_time"),
|
|
49
|
+
("Updated", "update_time"),
|
|
50
|
+
]
|
|
51
|
+
|
|
42
52
|
_SPEC_FILE = Parameter(name=["--spec-file", "-f"], allow_leading_hyphen=True)
|
|
43
53
|
|
|
44
54
|
|
|
@@ -74,6 +84,21 @@ def get(analysis: str, *, settings: SettingsParam) -> None:
|
|
|
74
84
|
emit_one(settings.client().analyses.get(analysis), FIELDS, as_json=settings.as_json)
|
|
75
85
|
|
|
76
86
|
|
|
87
|
+
@app.command(name="status")
|
|
88
|
+
def status(analysis: str, *, settings: SettingsParam) -> None:
|
|
89
|
+
"""Show how far one analysis has got, without its settings.
|
|
90
|
+
|
|
91
|
+
Cheaper than `get` on both ends: it reads none of the stored request, so it
|
|
92
|
+
is the one to call in a loop.
|
|
93
|
+
|
|
94
|
+
Parameters
|
|
95
|
+
----------
|
|
96
|
+
analysis:
|
|
97
|
+
The analysis to show.
|
|
98
|
+
"""
|
|
99
|
+
emit_one(settings.client().analyses.status(analysis), STATUS_FIELDS, as_json=settings.as_json)
|
|
100
|
+
|
|
101
|
+
|
|
77
102
|
@app.command(name="create")
|
|
78
103
|
def create(
|
|
79
104
|
*,
|
|
@@ -148,6 +173,101 @@ def cancel(analysis: str, *, settings: SettingsParam) -> None:
|
|
|
148
173
|
emit_one(settings.client().analyses.cancel(analysis), FIELDS, as_json=settings.as_json)
|
|
149
174
|
|
|
150
175
|
|
|
176
|
+
@app.command(name="share")
|
|
177
|
+
def share(
|
|
178
|
+
analysis: str,
|
|
179
|
+
*,
|
|
180
|
+
settings: SettingsParam,
|
|
181
|
+
scope: Literal["org"] = "org",
|
|
182
|
+
) -> None:
|
|
183
|
+
"""Let your organisation read one analysis.
|
|
184
|
+
|
|
185
|
+
Anyone in your organisation can then open it by name. It stays out of their
|
|
186
|
+
listings, and the share grants them no reuse action and no access to the
|
|
187
|
+
underlying data. Only the owner may share; repeating the call changes
|
|
188
|
+
nothing.
|
|
189
|
+
|
|
190
|
+
Parameters
|
|
191
|
+
----------
|
|
192
|
+
analysis:
|
|
193
|
+
The analysis to share.
|
|
194
|
+
scope:
|
|
195
|
+
Who to share with. Only your own organisation may be named.
|
|
196
|
+
"""
|
|
197
|
+
emit_one(settings.client().analyses.share(analysis, scope), FIELDS, as_json=settings.as_json)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@app.command(name="unshare")
|
|
201
|
+
def unshare(analysis: str, *, settings: SettingsParam, share: str = "org") -> None:
|
|
202
|
+
"""Revoke one grant on an analysis.
|
|
203
|
+
|
|
204
|
+
Only the owner may unshare; repeating the call changes nothing.
|
|
205
|
+
|
|
206
|
+
Parameters
|
|
207
|
+
----------
|
|
208
|
+
analysis:
|
|
209
|
+
The analysis to revoke a grant on.
|
|
210
|
+
share:
|
|
211
|
+
Which grant to revoke. `org` names your whole organisation, the only
|
|
212
|
+
audience an analysis can be shared with today.
|
|
213
|
+
"""
|
|
214
|
+
settings.client().analyses.unshare(analysis, share)
|
|
215
|
+
note(f"[dim]{analysis} is no longer shared with {share}[/dim]")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@app.command(name="run-again")
|
|
219
|
+
def run_again(
|
|
220
|
+
analysis: str,
|
|
221
|
+
*,
|
|
222
|
+
settings: SettingsParam,
|
|
223
|
+
idempotency_key: str | None = None,
|
|
224
|
+
wait: bool = False,
|
|
225
|
+
poll_interval: float = 5.0,
|
|
226
|
+
wait_timeout: float | None = None,
|
|
227
|
+
) -> None:
|
|
228
|
+
"""Run a settled analysis again as a new related analysis.
|
|
229
|
+
|
|
230
|
+
Settled means completed, failed or cancelled; a run still under way cannot
|
|
231
|
+
be repeated.
|
|
232
|
+
|
|
233
|
+
Parameters
|
|
234
|
+
----------
|
|
235
|
+
analysis:
|
|
236
|
+
The analysis whose settings to repeat.
|
|
237
|
+
idempotency_key:
|
|
238
|
+
Reuse a previous key to replay the action instead of repeating it.
|
|
239
|
+
wait:
|
|
240
|
+
Poll the new analysis's newest run until it reaches a terminal state.
|
|
241
|
+
poll_interval:
|
|
242
|
+
Seconds between polls while waiting.
|
|
243
|
+
wait_timeout:
|
|
244
|
+
Give up waiting after this many seconds.
|
|
245
|
+
"""
|
|
246
|
+
child = settings.client().analyses.run_again(analysis, idempotency_key=idempotency_key)
|
|
247
|
+
if not wait:
|
|
248
|
+
emit_one(child, FIELDS, as_json=settings.as_json)
|
|
249
|
+
return
|
|
250
|
+
note(f"[dim]submitted {child.uid}; waiting for the initial run[/dim]")
|
|
251
|
+
run = wait_for_run(
|
|
252
|
+
settings.client(), child.uid, poll_interval=poll_interval, timeout=wait_timeout
|
|
253
|
+
)
|
|
254
|
+
emit_one(run, RUN_FIELDS, as_json=settings.as_json)
|
|
255
|
+
if run.state is not models.RunState.COMPLETE:
|
|
256
|
+
raise RunFailed(f"run {run.uid} ended in {run.state}")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@app.command(name="estimate-run-again")
|
|
260
|
+
def estimate_run_again(analysis: str, *, settings: SettingsParam) -> None:
|
|
261
|
+
"""Estimate running an analysis again, without submitting it.
|
|
262
|
+
|
|
263
|
+
Parameters
|
|
264
|
+
----------
|
|
265
|
+
analysis:
|
|
266
|
+
The analysis whose settings to price.
|
|
267
|
+
"""
|
|
268
|
+
emit_model(settings.client().analyses.estimate_run_again(analysis), as_json=settings.as_json)
|
|
269
|
+
|
|
270
|
+
|
|
151
271
|
@app.command(name="estimate")
|
|
152
272
|
def estimate(*, settings: SettingsParam, spec_file: Annotated[Path, _SPEC_FILE]) -> None:
|
|
153
273
|
"""Estimate what a spec would cost, without submitting it.
|
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
|
|
8
|
+
app = App(
|
|
9
|
+
name="auth",
|
|
10
|
+
help="Sign in to Xoople, and show or discard the stored session.",
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
FIELDS = [("EMAIL", "email"), ("ORGANIZATION", "org_id"), ("EXPIRES", "expires_at")]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _StatusRow(BaseModel):
|
|
17
|
+
"""The part of a session that is safe to print. It carries no token."""
|
|
18
|
+
|
|
19
|
+
email: str | None
|
|
20
|
+
org_id: str | None
|
|
21
|
+
expires_at: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command(name="login")
|
|
25
|
+
def login(*, settings: SettingsParam) -> None:
|
|
26
|
+
"""Sign in through the browser and store the session for later commands."""
|
|
27
|
+
config = _ciam.CiamConfig.resolve(
|
|
28
|
+
settings.credentials.ciam_client_id, settings.credentials.ciam_redirect_uri
|
|
29
|
+
)
|
|
30
|
+
note(f"Serving the sign-in callback on {config.redirect_uri}")
|
|
31
|
+
session = settings.login(config, announce=lambda url: note(f"Opening {url}"))
|
|
32
|
+
path = _ciam.write_session(session)
|
|
33
|
+
note(f"Signed in as {session.email or session.client_id}; session stored at {path}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command(name="logout")
|
|
37
|
+
def logout(*, settings: SettingsParam) -> None:
|
|
38
|
+
"""Discard the stored session."""
|
|
39
|
+
del settings
|
|
40
|
+
if _ciam.clear_session():
|
|
41
|
+
note("Signed out.")
|
|
42
|
+
return
|
|
43
|
+
note("No stored session.")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.command(name="status")
|
|
47
|
+
def status(*, settings: SettingsParam) -> None:
|
|
48
|
+
"""Show who the stored session belongs to, and when it expires."""
|
|
49
|
+
session = _ciam.read_session()
|
|
50
|
+
if session is None:
|
|
51
|
+
raise LoginFailed("no stored session: run `xoople auth login`")
|
|
52
|
+
row = _StatusRow(email=session.email, org_id=session.org_id, expires_at=session.expires_at)
|
|
53
|
+
emit_one(row, FIELDS, as_json=settings.as_json)
|
|
@@ -75,6 +75,51 @@ def measures(
|
|
|
75
75
|
)
|
|
76
76
|
|
|
77
77
|
|
|
78
|
+
@app.command(name="projections")
|
|
79
|
+
def projections(*, settings: SettingsParam, bbox: str) -> None:
|
|
80
|
+
"""List the output projections an area may be gridded on, most local first.
|
|
81
|
+
|
|
82
|
+
These are the only values `target_crs` accepts for this area. Omitting
|
|
83
|
+
`target_crs` grids on the area's UTM zone, which is never listed.
|
|
84
|
+
|
|
85
|
+
Parameters
|
|
86
|
+
----------
|
|
87
|
+
bbox:
|
|
88
|
+
The area, as `min_lon,min_lat,max_lon,max_lat` in WGS84 degrees.
|
|
89
|
+
"""
|
|
90
|
+
result = settings.client().catalog.suggest_projections(bbox=bbox)
|
|
91
|
+
emit(
|
|
92
|
+
result.projections,
|
|
93
|
+
[
|
|
94
|
+
("CODE", "code"),
|
|
95
|
+
("NAME", "display_name"),
|
|
96
|
+
("TIER", "tier"),
|
|
97
|
+
("SAME GRID AS AUTOMATIC", "same_grid_as_automatic"),
|
|
98
|
+
("SCALE NOTE", "scale_note"),
|
|
99
|
+
],
|
|
100
|
+
as_json=settings.as_json,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.command(name="cloud-mask-presets")
|
|
105
|
+
def cloud_mask_presets(*, settings: SettingsParam) -> None:
|
|
106
|
+
"""List the scene-class masking presets, and the scene classes each one discards.
|
|
107
|
+
|
|
108
|
+
A preset name is what `cloud_mask_mode` accepts on an imagery request. The
|
|
109
|
+
classes are published here so a request never has to restate them.
|
|
110
|
+
"""
|
|
111
|
+
result = settings.client().catalog.cloud_mask_presets()
|
|
112
|
+
emit(
|
|
113
|
+
result.presets,
|
|
114
|
+
[
|
|
115
|
+
("MODE", "mode"),
|
|
116
|
+
("EXCLUDED CLASSES", "excluded_classes"),
|
|
117
|
+
("EXCLUDED CLASS NAMES", "excluded_class_names"),
|
|
118
|
+
],
|
|
119
|
+
as_json=settings.as_json,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
78
123
|
@app.command(name="designs")
|
|
79
124
|
def designs(*, settings: SettingsParam, product: str | None = None) -> None:
|
|
80
125
|
"""List the starting-point analysis designs. Their `spec_fragment` seeds a spec file.
|
|
@@ -1,19 +1,38 @@
|
|
|
1
1
|
from typing import Annotated
|
|
2
2
|
|
|
3
3
|
from cyclopts import App, CycloptsError, Parameter
|
|
4
|
-
from xoople.cli import
|
|
5
|
-
|
|
4
|
+
from xoople.cli import (
|
|
5
|
+
access,
|
|
6
|
+
analyses,
|
|
7
|
+
auth,
|
|
8
|
+
catalog,
|
|
9
|
+
events,
|
|
10
|
+
outputs,
|
|
11
|
+
projects,
|
|
12
|
+
runs,
|
|
13
|
+
schedules,
|
|
14
|
+
usage,
|
|
15
|
+
)
|
|
16
|
+
from xoople.cli._ciam import login as ciam_login
|
|
17
|
+
from xoople.cli._context import (
|
|
18
|
+
ClientFactory,
|
|
19
|
+
Credentials,
|
|
20
|
+
LoginRunner,
|
|
21
|
+
Settings,
|
|
22
|
+
build_client,
|
|
23
|
+
)
|
|
6
24
|
from xoople.cli._errors import USAGE_EXIT, CliError, report
|
|
7
25
|
from xoople.cli._output import note
|
|
8
26
|
from xoople.cli._version import version
|
|
9
27
|
from xoople.sdk import errors
|
|
10
28
|
|
|
11
29
|
|
|
12
|
-
def create_app(factory: ClientFactory = build_client) -> App:
|
|
13
|
-
"""The command tree, bound to a way of building clients.
|
|
30
|
+
def create_app(factory: ClientFactory = build_client, login: LoginRunner = ciam_login) -> App:
|
|
31
|
+
"""The command tree, bound to a way of building clients and signing in.
|
|
14
32
|
|
|
15
33
|
Tests build the tree over a fake transport rather than reaching into module
|
|
16
34
|
globals, so every command runs its real argument parsing and rendering.
|
|
35
|
+
`login` is the same seam for the sign-in, which opens a browser.
|
|
17
36
|
"""
|
|
18
37
|
app = App(
|
|
19
38
|
name="xoople",
|
|
@@ -21,6 +40,7 @@ def create_app(factory: ClientFactory = build_client) -> App:
|
|
|
21
40
|
help="Command-line interface for the Xoople Products API.",
|
|
22
41
|
)
|
|
23
42
|
app.command(analyses.app)
|
|
43
|
+
app.command(projects.app)
|
|
24
44
|
app.command(runs.app)
|
|
25
45
|
app.command(outputs.app)
|
|
26
46
|
app.command(schedules.app)
|
|
@@ -28,6 +48,7 @@ def create_app(factory: ClientFactory = build_client) -> App:
|
|
|
28
48
|
app.command(usage.app)
|
|
29
49
|
app.command(catalog.app)
|
|
30
50
|
app.command(access.app)
|
|
51
|
+
app.command(auth.app)
|
|
31
52
|
|
|
32
53
|
@app.meta.default
|
|
33
54
|
def launcher(
|
|
@@ -35,6 +56,10 @@ def create_app(factory: ClientFactory = build_client) -> App:
|
|
|
35
56
|
api_url: Annotated[str | None, Parameter(env_var="XOOPLE_API_URL")] = None,
|
|
36
57
|
token: Annotated[str | None, Parameter(env_var="XOOPLE_TOKEN")] = None,
|
|
37
58
|
entra_scope: Annotated[str | None, Parameter(env_var="XOOPLE_ENTRA_SCOPE")] = None,
|
|
59
|
+
ciam_client_id: Annotated[str | None, Parameter(env_var="XOOPLE_CIAM_CLIENT_ID")] = None,
|
|
60
|
+
ciam_redirect_uri: Annotated[
|
|
61
|
+
str | None, Parameter(env_var="XOOPLE_CIAM_REDIRECT_URI")
|
|
62
|
+
] = None,
|
|
38
63
|
timeout: Annotated[float, Parameter()] = 30.0,
|
|
39
64
|
max_retries: Annotated[int, Parameter()] = 3,
|
|
40
65
|
json_output: Annotated[bool, Parameter(name="--json")] = False,
|
|
@@ -50,6 +75,11 @@ def create_app(factory: ClientFactory = build_client) -> App:
|
|
|
50
75
|
entra_scope:
|
|
51
76
|
Authenticate with Entra ID for this scope instead of a token
|
|
52
77
|
(internal use; needs the entra extra).
|
|
78
|
+
ciam_client_id:
|
|
79
|
+
Xoople CIAM client ID, used by `auth login`.
|
|
80
|
+
ciam_redirect_uri:
|
|
81
|
+
Loopback URI the browser returns the sign-in to. It must match the
|
|
82
|
+
URI registered for the CIAM client.
|
|
53
83
|
timeout:
|
|
54
84
|
Per-request timeout, in seconds.
|
|
55
85
|
max_retries:
|
|
@@ -67,10 +97,13 @@ def create_app(factory: ClientFactory = build_client) -> App:
|
|
|
67
97
|
base_url=api_url,
|
|
68
98
|
token=token,
|
|
69
99
|
entra_scope=entra_scope,
|
|
100
|
+
ciam_client_id=ciam_client_id,
|
|
101
|
+
ciam_redirect_uri=ciam_redirect_uri,
|
|
70
102
|
timeout=timeout,
|
|
71
103
|
max_retries=max_retries,
|
|
72
104
|
),
|
|
73
105
|
factory=factory,
|
|
106
|
+
login=login,
|
|
74
107
|
as_json=json_output,
|
|
75
108
|
)
|
|
76
109
|
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,190 @@
|
|
|
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
|
+
@app.command(name="add-member")
|
|
102
|
+
def add_member(project: str, subject_id: str, *, settings: SettingsParam) -> None:
|
|
103
|
+
"""Add a subject to a project.
|
|
104
|
+
|
|
105
|
+
Parameters
|
|
106
|
+
----------
|
|
107
|
+
project:
|
|
108
|
+
The project to add to.
|
|
109
|
+
subject_id:
|
|
110
|
+
The subject to add.
|
|
111
|
+
"""
|
|
112
|
+
emit_model(settings.client().projects.add_member(project, subject_id), as_json=settings.as_json)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@app.command(name="remove-member")
|
|
116
|
+
def remove_member(project: str, subject: str, *, settings: SettingsParam) -> None:
|
|
117
|
+
"""Remove a subject from a project.
|
|
118
|
+
|
|
119
|
+
Parameters
|
|
120
|
+
----------
|
|
121
|
+
project:
|
|
122
|
+
The project to remove from.
|
|
123
|
+
subject:
|
|
124
|
+
The subject to remove.
|
|
125
|
+
"""
|
|
126
|
+
settings.client().projects.remove_member(project, subject)
|
|
127
|
+
note(f"[dim]{subject} is no longer a member of {project}[/dim]")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@app.command(name="move")
|
|
131
|
+
def move(project: str, destination: str, analyses: list[str], *, settings: SettingsParam) -> None:
|
|
132
|
+
"""Move named analyses into another project, pausing their schedules.
|
|
133
|
+
|
|
134
|
+
Parameters
|
|
135
|
+
----------
|
|
136
|
+
project:
|
|
137
|
+
The project the analyses are in now.
|
|
138
|
+
destination:
|
|
139
|
+
The project to move them into.
|
|
140
|
+
analyses:
|
|
141
|
+
The analysis identifiers to move, at most 500 per call.
|
|
142
|
+
"""
|
|
143
|
+
emit_model(
|
|
144
|
+
settings.client().projects.move_analyses_to_project(project, analyses, destination),
|
|
145
|
+
as_json=settings.as_json,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@app.command(name="archive")
|
|
150
|
+
def archive(project: str, *, settings: SettingsParam) -> None:
|
|
151
|
+
"""Archive a project and pause its schedules.
|
|
152
|
+
|
|
153
|
+
The default project cannot be archived.
|
|
154
|
+
|
|
155
|
+
Parameters
|
|
156
|
+
----------
|
|
157
|
+
project:
|
|
158
|
+
The project to archive.
|
|
159
|
+
"""
|
|
160
|
+
settings.client().projects.archive(project)
|
|
161
|
+
note(f"[dim]{project} is archived[/dim]")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@app.command(name="record-access")
|
|
165
|
+
def record_access(project: str, *, settings: SettingsParam) -> None:
|
|
166
|
+
"""Record that you opened a project, which orders `projects recent`.
|
|
167
|
+
|
|
168
|
+
Parameters
|
|
169
|
+
----------
|
|
170
|
+
project:
|
|
171
|
+
The project you opened.
|
|
172
|
+
"""
|
|
173
|
+
settings.client().projects.record_access(project)
|
|
174
|
+
note(f"[dim]{project} is your most recent project[/dim]")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@app.command(name="recent")
|
|
178
|
+
def recent(
|
|
179
|
+
*,
|
|
180
|
+
settings: SettingsParam,
|
|
181
|
+
limit: Annotated[int | None, Parameter(validator=at_least_one)] = None,
|
|
182
|
+
) -> None:
|
|
183
|
+
"""List the projects you opened most recently.
|
|
184
|
+
|
|
185
|
+
Parameters
|
|
186
|
+
----------
|
|
187
|
+
limit:
|
|
188
|
+
Return this many, instead of the default four.
|
|
189
|
+
"""
|
|
190
|
+
emit(settings.client().projects.recent(limit=limit), COLUMNS, as_json=settings.as_json)
|
|
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
|