xoople-cli 0.2.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.2.0 → xoople_cli-0.3.0}/PKG-INFO +25 -4
- {xoople_cli-0.2.0 → xoople_cli-0.3.0}/README.md +22 -1
- {xoople_cli-0.2.0 → xoople_cli-0.3.0}/pyproject.toml +3 -3
- {xoople_cli-0.2.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.2.0 → xoople_cli-0.3.0}/src/xoople/cli/_context.py +35 -5
- {xoople_cli-0.2.0 → xoople_cli-0.3.0}/src/xoople/cli/_errors.py +10 -0
- {xoople_cli-0.2.0 → xoople_cli-0.3.0}/src/xoople/cli/_output.py +3 -6
- xoople_cli-0.3.0/src/xoople/cli/auth.py +53 -0
- {xoople_cli-0.2.0 → xoople_cli-0.3.0}/src/xoople/cli/catalog.py +19 -0
- {xoople_cli-0.2.0 → xoople_cli-0.3.0}/src/xoople/cli/main.py +25 -3
- {xoople_cli-0.2.0 → xoople_cli-0.3.0}/src/xoople/cli/outputs.py +34 -0
- {xoople_cli-0.2.0 → xoople_cli-0.3.0}/src/xoople/cli/projects.py +74 -2
- {xoople_cli-0.2.0 → xoople_cli-0.3.0}/LICENSE +0 -0
- {xoople_cli-0.2.0 → xoople_cli-0.3.0}/src/xoople/cli/__init__.py +0 -0
- {xoople_cli-0.2.0 → xoople_cli-0.3.0}/src/xoople/cli/__main__.py +0 -0
- {xoople_cli-0.2.0 → xoople_cli-0.3.0}/src/xoople/cli/_spec.py +0 -0
- {xoople_cli-0.2.0 → xoople_cli-0.3.0}/src/xoople/cli/_version.py +0 -0
- {xoople_cli-0.2.0 → xoople_cli-0.3.0}/src/xoople/cli/access.py +0 -0
- {xoople_cli-0.2.0 → xoople_cli-0.3.0}/src/xoople/cli/analyses.py +0 -0
- {xoople_cli-0.2.0 → xoople_cli-0.3.0}/src/xoople/cli/events.py +0 -0
- {xoople_cli-0.2.0 → xoople_cli-0.3.0}/src/xoople/cli/runs.py +0 -0
- {xoople_cli-0.2.0 → xoople_cli-0.3.0}/src/xoople/cli/schedules.py +0 -0
- {xoople_cli-0.2.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
|
|
|
@@ -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
|
|
|
@@ -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,6 +21,8 @@ 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
|
|
|
@@ -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,7 @@ class Settings:
|
|
|
45
62
|
|
|
46
63
|
credentials: Credentials
|
|
47
64
|
factory: ClientFactory
|
|
65
|
+
login: LoginRunner = _ciam.login
|
|
48
66
|
as_json: bool = False
|
|
49
67
|
_client: XoopleClient | None = field(default=None, repr=False)
|
|
50
68
|
|
|
@@ -59,12 +77,24 @@ class Settings:
|
|
|
59
77
|
self._client = None
|
|
60
78
|
|
|
61
79
|
|
|
80
|
+
def _persist_session(session: CiamUserSession) -> None:
|
|
81
|
+
_ciam.write_session(session)
|
|
82
|
+
|
|
83
|
+
|
|
62
84
|
def _credential_provider(credentials: Credentials) -> CredentialProvider:
|
|
63
85
|
if credentials.entra_scope:
|
|
64
86
|
return EntraCredential(credentials.entra_scope)
|
|
65
|
-
if
|
|
66
|
-
|
|
67
|
-
|
|
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
|
+
)
|
|
68
98
|
|
|
69
99
|
|
|
70
100
|
def build_client(credentials: Credentials) -> XoopleClient:
|
|
@@ -32,6 +32,16 @@ class UsageError(CliError):
|
|
|
32
32
|
exit_code = USAGE_EXIT
|
|
33
33
|
|
|
34
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
|
+
|
|
35
45
|
class RunFailed(CliError):
|
|
36
46
|
"""``--wait`` finished, but the run ended in a non-``COMPLETE`` state."""
|
|
37
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
|
|
@@ -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)
|
|
@@ -101,6 +101,25 @@ def projections(*, settings: SettingsParam, bbox: str) -> None:
|
|
|
101
101
|
)
|
|
102
102
|
|
|
103
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
|
+
|
|
104
123
|
@app.command(name="designs")
|
|
105
124
|
def designs(*, settings: SettingsParam, product: str | None = None) -> None:
|
|
106
125
|
"""List the starting-point analysis designs. Their `spec_fragment` seeds a spec file.
|
|
@@ -4,6 +4,7 @@ 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,
|
|
@@ -12,18 +13,26 @@ from xoople.cli import (
|
|
|
12
13
|
schedules,
|
|
13
14
|
usage,
|
|
14
15
|
)
|
|
15
|
-
from xoople.cli.
|
|
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
|
+
)
|
|
16
24
|
from xoople.cli._errors import USAGE_EXIT, CliError, report
|
|
17
25
|
from xoople.cli._output import note
|
|
18
26
|
from xoople.cli._version import version
|
|
19
27
|
from xoople.sdk import errors
|
|
20
28
|
|
|
21
29
|
|
|
22
|
-
def create_app(factory: ClientFactory = build_client) -> App:
|
|
23
|
-
"""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.
|
|
24
32
|
|
|
25
33
|
Tests build the tree over a fake transport rather than reaching into module
|
|
26
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.
|
|
27
36
|
"""
|
|
28
37
|
app = App(
|
|
29
38
|
name="xoople",
|
|
@@ -39,6 +48,7 @@ def create_app(factory: ClientFactory = build_client) -> App:
|
|
|
39
48
|
app.command(usage.app)
|
|
40
49
|
app.command(catalog.app)
|
|
41
50
|
app.command(access.app)
|
|
51
|
+
app.command(auth.app)
|
|
42
52
|
|
|
43
53
|
@app.meta.default
|
|
44
54
|
def launcher(
|
|
@@ -46,6 +56,10 @@ def create_app(factory: ClientFactory = build_client) -> App:
|
|
|
46
56
|
api_url: Annotated[str | None, Parameter(env_var="XOOPLE_API_URL")] = None,
|
|
47
57
|
token: Annotated[str | None, Parameter(env_var="XOOPLE_TOKEN")] = None,
|
|
48
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,
|
|
49
63
|
timeout: Annotated[float, Parameter()] = 30.0,
|
|
50
64
|
max_retries: Annotated[int, Parameter()] = 3,
|
|
51
65
|
json_output: Annotated[bool, Parameter(name="--json")] = False,
|
|
@@ -61,6 +75,11 @@ def create_app(factory: ClientFactory = build_client) -> App:
|
|
|
61
75
|
entra_scope:
|
|
62
76
|
Authenticate with Entra ID for this scope instead of a token
|
|
63
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.
|
|
64
83
|
timeout:
|
|
65
84
|
Per-request timeout, in seconds.
|
|
66
85
|
max_retries:
|
|
@@ -78,10 +97,13 @@ def create_app(factory: ClientFactory = build_client) -> App:
|
|
|
78
97
|
base_url=api_url,
|
|
79
98
|
token=token,
|
|
80
99
|
entra_scope=entra_scope,
|
|
100
|
+
ciam_client_id=ciam_client_id,
|
|
101
|
+
ciam_redirect_uri=ciam_redirect_uri,
|
|
81
102
|
timeout=timeout,
|
|
82
103
|
max_retries=max_retries,
|
|
83
104
|
),
|
|
84
105
|
factory=factory,
|
|
106
|
+
login=login,
|
|
85
107
|
as_json=json_output,
|
|
86
108
|
)
|
|
87
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
|
+
)
|
|
@@ -20,6 +20,7 @@ FIELDS = [
|
|
|
20
20
|
("Name", "name"),
|
|
21
21
|
("UID", "uid"),
|
|
22
22
|
("Display name", "display_name"),
|
|
23
|
+
("Description", "description"),
|
|
23
24
|
("Default", "is_default"),
|
|
24
25
|
("Archive time", "archive_time"),
|
|
25
26
|
("Create time", "create_time"),
|
|
@@ -44,15 +45,57 @@ def list_projects(
|
|
|
44
45
|
|
|
45
46
|
|
|
46
47
|
@app.command(name="create")
|
|
47
|
-
def create(name: str, *, settings: SettingsParam) -> None:
|
|
48
|
+
def create(name: str, *, description: str | None = None, settings: SettingsParam) -> None:
|
|
48
49
|
"""Create a project.
|
|
49
50
|
|
|
50
51
|
Parameters
|
|
51
52
|
----------
|
|
52
53
|
name:
|
|
53
54
|
The project's display name.
|
|
55
|
+
description:
|
|
56
|
+
The project's description, at most 500 characters.
|
|
54
57
|
"""
|
|
55
|
-
emit_one(settings.client().projects.create(name), FIELDS, as_json=settings.as_json)
|
|
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
|
+
)
|
|
56
99
|
|
|
57
100
|
|
|
58
101
|
@app.command(name="add-member")
|
|
@@ -116,3 +159,32 @@ def archive(project: str, *, settings: SettingsParam) -> None:
|
|
|
116
159
|
"""
|
|
117
160
|
settings.client().projects.archive(project)
|
|
118
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
|
|
File without changes
|
|
File without changes
|