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.
Files changed (24) hide show
  1. {xoople_cli-0.2.0 → xoople_cli-0.3.0}/PKG-INFO +25 -4
  2. {xoople_cli-0.2.0 → xoople_cli-0.3.0}/README.md +22 -1
  3. {xoople_cli-0.2.0 → xoople_cli-0.3.0}/pyproject.toml +3 -3
  4. {xoople_cli-0.2.0 → xoople_cli-0.3.0}/pyproject.toml.orig +3 -3
  5. xoople_cli-0.3.0/src/xoople/cli/_ciam.py +190 -0
  6. {xoople_cli-0.2.0 → xoople_cli-0.3.0}/src/xoople/cli/_context.py +35 -5
  7. {xoople_cli-0.2.0 → xoople_cli-0.3.0}/src/xoople/cli/_errors.py +10 -0
  8. {xoople_cli-0.2.0 → xoople_cli-0.3.0}/src/xoople/cli/_output.py +3 -6
  9. xoople_cli-0.3.0/src/xoople/cli/auth.py +53 -0
  10. {xoople_cli-0.2.0 → xoople_cli-0.3.0}/src/xoople/cli/catalog.py +19 -0
  11. {xoople_cli-0.2.0 → xoople_cli-0.3.0}/src/xoople/cli/main.py +25 -3
  12. {xoople_cli-0.2.0 → xoople_cli-0.3.0}/src/xoople/cli/outputs.py +34 -0
  13. {xoople_cli-0.2.0 → xoople_cli-0.3.0}/src/xoople/cli/projects.py +74 -2
  14. {xoople_cli-0.2.0 → xoople_cli-0.3.0}/LICENSE +0 -0
  15. {xoople_cli-0.2.0 → xoople_cli-0.3.0}/src/xoople/cli/__init__.py +0 -0
  16. {xoople_cli-0.2.0 → xoople_cli-0.3.0}/src/xoople/cli/__main__.py +0 -0
  17. {xoople_cli-0.2.0 → xoople_cli-0.3.0}/src/xoople/cli/_spec.py +0 -0
  18. {xoople_cli-0.2.0 → xoople_cli-0.3.0}/src/xoople/cli/_version.py +0 -0
  19. {xoople_cli-0.2.0 → xoople_cli-0.3.0}/src/xoople/cli/access.py +0 -0
  20. {xoople_cli-0.2.0 → xoople_cli-0.3.0}/src/xoople/cli/analyses.py +0 -0
  21. {xoople_cli-0.2.0 → xoople_cli-0.3.0}/src/xoople/cli/events.py +0 -0
  22. {xoople_cli-0.2.0 → xoople_cli-0.3.0}/src/xoople/cli/runs.py +0 -0
  23. {xoople_cli-0.2.0 → xoople_cli-0.3.0}/src/xoople/cli/schedules.py +0 -0
  24. {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.2.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.3.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.3.0 ; extra == 'entra'
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 · `3` unauthenticated · `4` forbidden ·
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 · `3` unauthenticated · `4` forbidden ·
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.2.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.3.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.3.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.2.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.3.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.3.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 EntraCredential, StaticToken
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 not credentials.token:
66
- raise UsageError("no credentials: pass --token, set XOOPLE_TOKEN, or pass --entra-scope")
67
- return StaticToken(credentials.token)
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. A column headed PROGRESS showing `0.42` reads
11
- # as a bug, and `estimate_confidence` is the same unit, so both render as whole
12
- # percent. Keyed on the field name rather than declared per column: the unit
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._context import ClientFactory, Credentials, Settings, build_client
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