xoople-cli 0.3.0__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {xoople_cli-0.3.0 → xoople_cli-0.4.0}/PKG-INFO +31 -9
- {xoople_cli-0.3.0 → xoople_cli-0.4.0}/README.md +29 -5
- {xoople_cli-0.3.0 → xoople_cli-0.4.0}/pyproject.toml +2 -5
- {xoople_cli-0.3.0 → xoople_cli-0.4.0}/pyproject.toml.orig +2 -9
- xoople_cli-0.4.0/src/xoople/cli/_ciam.py +71 -0
- {xoople_cli-0.3.0 → xoople_cli-0.4.0}/src/xoople/cli/_context.py +6 -10
- {xoople_cli-0.3.0 → xoople_cli-0.4.0}/src/xoople/cli/_errors.py +2 -0
- {xoople_cli-0.3.0 → xoople_cli-0.4.0}/src/xoople/cli/_spec.py +2 -2
- {xoople_cli-0.3.0 → xoople_cli-0.4.0}/src/xoople/cli/analyses.py +6 -1
- {xoople_cli-0.3.0 → xoople_cli-0.4.0}/src/xoople/cli/auth.py +21 -4
- {xoople_cli-0.3.0 → xoople_cli-0.4.0}/src/xoople/cli/catalog.py +1 -2
- {xoople_cli-0.3.0 → xoople_cli-0.4.0}/src/xoople/cli/main.py +14 -8
- xoople_cli-0.4.0/src/xoople/cli/packages.py +92 -0
- {xoople_cli-0.3.0 → xoople_cli-0.4.0}/src/xoople/cli/projects.py +44 -0
- xoople_cli-0.3.0/src/xoople/cli/_ciam.py +0 -190
- {xoople_cli-0.3.0 → xoople_cli-0.4.0}/LICENSE +0 -0
- {xoople_cli-0.3.0 → xoople_cli-0.4.0}/src/xoople/cli/__init__.py +0 -0
- {xoople_cli-0.3.0 → xoople_cli-0.4.0}/src/xoople/cli/__main__.py +0 -0
- {xoople_cli-0.3.0 → xoople_cli-0.4.0}/src/xoople/cli/_output.py +0 -0
- {xoople_cli-0.3.0 → xoople_cli-0.4.0}/src/xoople/cli/_version.py +0 -0
- {xoople_cli-0.3.0 → xoople_cli-0.4.0}/src/xoople/cli/access.py +0 -0
- {xoople_cli-0.3.0 → xoople_cli-0.4.0}/src/xoople/cli/events.py +0 -0
- {xoople_cli-0.3.0 → xoople_cli-0.4.0}/src/xoople/cli/outputs.py +0 -0
- {xoople_cli-0.3.0 → xoople_cli-0.4.0}/src/xoople/cli/runs.py +0 -0
- {xoople_cli-0.3.0 → xoople_cli-0.4.0}/src/xoople/cli/schedules.py +0 -0
- {xoople_cli-0.3.0 → xoople_cli-0.4.0}/src/xoople/cli/usage.py +0 -0
|
@@ -1,18 +1,16 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: xoople-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Command-line interface for the Xoople Products API.
|
|
5
5
|
License-Expression: LicenseRef-Xoople-Proprietary
|
|
6
6
|
License-File: LICENSE
|
|
7
7
|
Classifier: Development Status :: 3 - Alpha
|
|
8
8
|
Classifier: Programming Language :: Python :: 3.12
|
|
9
|
-
Requires-Dist: xoople-sdk>=0.
|
|
9
|
+
Requires-Dist: xoople-sdk>=0.6.0
|
|
10
10
|
Requires-Dist: cyclopts>=4.18,<5
|
|
11
11
|
Requires-Dist: rich>=13.7
|
|
12
|
-
Requires-Dist: xoople-sdk[entra]>=0.5.0 ; extra == 'entra'
|
|
13
12
|
Requires-Python: >=3.12
|
|
14
13
|
Project-URL: Homepage, https://xoople.com
|
|
15
|
-
Provides-Extra: entra
|
|
16
14
|
Description-Content-Type: text/markdown
|
|
17
15
|
|
|
18
16
|
# xoople-cli
|
|
@@ -48,20 +46,43 @@ output pipes into `jq`.
|
|
|
48
46
|
`auth login` signs in through the browser with PKCE and stores the session at
|
|
49
47
|
`$XDG_CONFIG_HOME/xoople/session.json` (`~/.config/xoople/session.json` by
|
|
50
48
|
default), readable only by you. Every later command uses that session when
|
|
51
|
-
|
|
49
|
+
no `--token` is given, and refreshes it when the
|
|
52
50
|
access token is close to expiry.
|
|
53
51
|
|
|
54
52
|
```console
|
|
55
|
-
$ export XOOPLE_API_URL=https://api.xoople.com
|
|
53
|
+
$ export XOOPLE_API_URL=https://api.xoople.com
|
|
56
54
|
$ xoople auth login
|
|
57
55
|
$ xoople analyses list
|
|
58
56
|
```
|
|
59
57
|
|
|
60
|
-
`auth
|
|
61
|
-
|
|
58
|
+
`auth login` reads the CIAM client ID from the API. `--ciam-client-id`
|
|
59
|
+
(`XOOPLE_CIAM_CLIENT_ID`) overrides it. `auth status` exits `3` when there is no
|
|
60
|
+
stored session, so a script can test for a sign-in. `--ciam-redirect-uri` (`XOOPLE_CIAM_REDIRECT_URI`) overrides the
|
|
62
61
|
loopback URI the browser returns to; it must match a URI registered for the
|
|
63
62
|
CIAM client.
|
|
64
63
|
|
|
64
|
+
## API keys
|
|
65
|
+
|
|
66
|
+
An API key lets a script or a CI job run the CLI without a browser sign-in.
|
|
67
|
+
Create one on the **API keys** section of your Xoople profile page (`/profile`).
|
|
68
|
+
The key starts with `sk_` and is shown once, so store it in a secret manager.
|
|
69
|
+
|
|
70
|
+
A key belongs to one organization membership. It acts as you, in the
|
|
71
|
+
organization that was active when you created it, with that membership's roles.
|
|
72
|
+
To work in a different organization, create a key there.
|
|
73
|
+
|
|
74
|
+
Pass the key as the token. It takes precedence over a stored session:
|
|
75
|
+
|
|
76
|
+
```console
|
|
77
|
+
$ export XOOPLE_API_URL=https://api.xoople.com XOOPLE_TOKEN=sk_...
|
|
78
|
+
$ xoople access me
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The API accepts at most 300 API key requests a minute from one IP address. The
|
|
82
|
+
CLI retries rate-limited GETs and replay-safe writes with backoff, then exits
|
|
83
|
+
`8`. Any other write exits `8` on the first 429, with no retry. Revoke a key on
|
|
84
|
+
the same profile page when you no longer need it.
|
|
85
|
+
|
|
65
86
|
## Submitting an analysis
|
|
66
87
|
|
|
67
88
|
The product spec is deeply nested and differs per product, so `create` and
|
|
@@ -83,4 +104,5 @@ non-zero if it does not reach `COMPLETE`.
|
|
|
83
104
|
`3` unauthenticated, including a failed or absent sign-in · `4` forbidden ·
|
|
84
105
|
`5` not found · `6` bad request or validation · `7` conflict · `8` rate limited ·
|
|
85
106
|
`9` server error · `10` connection failure · `11` unreadable response ·
|
|
86
|
-
`12` run finished in a non-`COMPLETE` state · `13` `--wait` timed out
|
|
107
|
+
`12` run finished in a non-`COMPLETE` state · `13` `--wait` timed out ·
|
|
108
|
+
`14` the analysis was deleted.
|
|
@@ -31,20 +31,43 @@ output pipes into `jq`.
|
|
|
31
31
|
`auth login` signs in through the browser with PKCE and stores the session at
|
|
32
32
|
`$XDG_CONFIG_HOME/xoople/session.json` (`~/.config/xoople/session.json` by
|
|
33
33
|
default), readable only by you. Every later command uses that session when
|
|
34
|
-
|
|
34
|
+
no `--token` is given, and refreshes it when the
|
|
35
35
|
access token is close to expiry.
|
|
36
36
|
|
|
37
37
|
```console
|
|
38
|
-
$ export XOOPLE_API_URL=https://api.xoople.com
|
|
38
|
+
$ export XOOPLE_API_URL=https://api.xoople.com
|
|
39
39
|
$ xoople auth login
|
|
40
40
|
$ xoople analyses list
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
`auth
|
|
44
|
-
|
|
43
|
+
`auth login` reads the CIAM client ID from the API. `--ciam-client-id`
|
|
44
|
+
(`XOOPLE_CIAM_CLIENT_ID`) overrides it. `auth status` exits `3` when there is no
|
|
45
|
+
stored session, so a script can test for a sign-in. `--ciam-redirect-uri` (`XOOPLE_CIAM_REDIRECT_URI`) overrides the
|
|
45
46
|
loopback URI the browser returns to; it must match a URI registered for the
|
|
46
47
|
CIAM client.
|
|
47
48
|
|
|
49
|
+
## API keys
|
|
50
|
+
|
|
51
|
+
An API key lets a script or a CI job run the CLI without a browser sign-in.
|
|
52
|
+
Create one on the **API keys** section of your Xoople profile page (`/profile`).
|
|
53
|
+
The key starts with `sk_` and is shown once, so store it in a secret manager.
|
|
54
|
+
|
|
55
|
+
A key belongs to one organization membership. It acts as you, in the
|
|
56
|
+
organization that was active when you created it, with that membership's roles.
|
|
57
|
+
To work in a different organization, create a key there.
|
|
58
|
+
|
|
59
|
+
Pass the key as the token. It takes precedence over a stored session:
|
|
60
|
+
|
|
61
|
+
```console
|
|
62
|
+
$ export XOOPLE_API_URL=https://api.xoople.com XOOPLE_TOKEN=sk_...
|
|
63
|
+
$ xoople access me
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The API accepts at most 300 API key requests a minute from one IP address. The
|
|
67
|
+
CLI retries rate-limited GETs and replay-safe writes with backoff, then exits
|
|
68
|
+
`8`. Any other write exits `8` on the first 429, with no retry. Revoke a key on
|
|
69
|
+
the same profile page when you no longer need it.
|
|
70
|
+
|
|
48
71
|
## Submitting an analysis
|
|
49
72
|
|
|
50
73
|
The product spec is deeply nested and differs per product, so `create` and
|
|
@@ -66,4 +89,5 @@ non-zero if it does not reach `COMPLETE`.
|
|
|
66
89
|
`3` unauthenticated, including a failed or absent sign-in · `4` forbidden ·
|
|
67
90
|
`5` not found · `6` bad request or validation · `7` conflict · `8` rate limited ·
|
|
68
91
|
`9` server error · `10` connection failure · `11` unreadable response ·
|
|
69
|
-
`12` run finished in a non-`COMPLETE` state · `13` `--wait` timed out
|
|
92
|
+
`12` run finished in a non-`COMPLETE` state · `13` `--wait` timed out ·
|
|
93
|
+
`14` the analysis was deleted.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "xoople-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "Command-line interface for the Xoople Products API."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -11,14 +11,11 @@ classifiers = [
|
|
|
11
11
|
"Programming Language :: Python :: 3.12",
|
|
12
12
|
]
|
|
13
13
|
dependencies = [
|
|
14
|
-
"xoople-sdk>=0.
|
|
14
|
+
"xoople-sdk>=0.6.0",
|
|
15
15
|
"cyclopts>=4.18,<5",
|
|
16
16
|
"rich>=13.7",
|
|
17
17
|
]
|
|
18
18
|
|
|
19
|
-
[project.optional-dependencies]
|
|
20
|
-
entra = ["xoople-sdk[entra]>=0.5.0"]
|
|
21
|
-
|
|
22
19
|
[project.scripts]
|
|
23
20
|
xoople = "xoople.cli.main:main"
|
|
24
21
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "xoople-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "Command-line interface for the Xoople Products API."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -13,7 +13,7 @@ classifiers = [
|
|
|
13
13
|
"Programming Language :: Python :: 3.12",
|
|
14
14
|
]
|
|
15
15
|
dependencies = [
|
|
16
|
-
"xoople-sdk>=0.
|
|
16
|
+
"xoople-sdk>=0.6.0",
|
|
17
17
|
# Cyclopts parses docstrings for parameter help so signatures stay plain,
|
|
18
18
|
# supports union types and Pydantic models natively, and owns its parser.
|
|
19
19
|
# Global options + dependency injection go through the meta-app launcher
|
|
@@ -22,13 +22,6 @@ dependencies = [
|
|
|
22
22
|
"rich>=13.7",
|
|
23
23
|
]
|
|
24
24
|
|
|
25
|
-
[project.optional-dependencies]
|
|
26
|
-
# Mirrors xoople-sdk[entra]: internal callers authenticating with Entra ID via
|
|
27
|
-
# `--entra-scope`. Not needed for the token path.
|
|
28
|
-
entra = [
|
|
29
|
-
"xoople-sdk[entra]>=0.5.0",
|
|
30
|
-
]
|
|
31
|
-
|
|
32
25
|
[project.scripts]
|
|
33
26
|
xoople = "xoople.cli.main:main"
|
|
34
27
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Which CIAM client the CLI signs in as, and how a failed sign-in is reported.
|
|
2
|
+
|
|
3
|
+
The SDK owns the sign-in, browser leg included, and the session file, because
|
|
4
|
+
a `xoople-cli` connection profile reads that file too.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Protocol, Self
|
|
10
|
+
|
|
11
|
+
from xoople.cli._errors import LoginFailed
|
|
12
|
+
from xoople.sdk.auth import CiamUserSession, ciam_sign_in
|
|
13
|
+
from xoople.sdk.errors import CredentialError
|
|
14
|
+
|
|
15
|
+
DEFAULT_REDIRECT_URI = "http://localhost:8976/callback"
|
|
16
|
+
"""Fixed, because CIAM matches a redirect URI exactly and must hold this one."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _SignIn(Protocol):
|
|
20
|
+
def __call__(
|
|
21
|
+
self, *, client_id: str, redirect_uri: str, announce: Callable[[str], None]
|
|
22
|
+
) -> CiamUserSession: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Tests supply a lookup that makes no HTTP call.
|
|
26
|
+
type ClientIdLookup = Callable[[str], str]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class CiamConfig:
|
|
31
|
+
"""What a login needs: the CIAM client and the URI the browser comes back to."""
|
|
32
|
+
|
|
33
|
+
client_id: str
|
|
34
|
+
redirect_uri: str = DEFAULT_REDIRECT_URI
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def resolve(
|
|
38
|
+
cls,
|
|
39
|
+
client_id: str | None,
|
|
40
|
+
redirect_uri: str | None,
|
|
41
|
+
*,
|
|
42
|
+
base_url: str | None,
|
|
43
|
+
discover: ClientIdLookup,
|
|
44
|
+
) -> Self:
|
|
45
|
+
"""An explicit client wins. Otherwise the API names its own client."""
|
|
46
|
+
if not client_id:
|
|
47
|
+
if not base_url:
|
|
48
|
+
raise LoginFailed(
|
|
49
|
+
"no CIAM client: pass --api-url, --ciam-client-id or set XOOPLE_CIAM_CLIENT_ID"
|
|
50
|
+
)
|
|
51
|
+
try:
|
|
52
|
+
client_id = discover(base_url)
|
|
53
|
+
except CredentialError as exc:
|
|
54
|
+
raise LoginFailed(f"{exc}: check --api-url or pass --ciam-client-id") from None
|
|
55
|
+
return cls(client_id=client_id, redirect_uri=redirect_uri or DEFAULT_REDIRECT_URI)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def login(
|
|
59
|
+
config: CiamConfig,
|
|
60
|
+
*,
|
|
61
|
+
announce: Callable[[str], None],
|
|
62
|
+
sign_in: _SignIn = ciam_sign_in,
|
|
63
|
+
) -> CiamUserSession:
|
|
64
|
+
"""Run one browser sign-in and return the session it produced."""
|
|
65
|
+
try:
|
|
66
|
+
return sign_in(
|
|
67
|
+
client_id=config.client_id, redirect_uri=config.redirect_uri, announce=announce
|
|
68
|
+
)
|
|
69
|
+
except CredentialError as exc:
|
|
70
|
+
# One exit code covers every sign-in failure. The SDK message says which.
|
|
71
|
+
raise LoginFailed(str(exc)) from None
|
|
@@ -6,11 +6,12 @@ from cyclopts import Parameter
|
|
|
6
6
|
from xoople.cli import _ciam
|
|
7
7
|
from xoople.cli._errors import UsageError
|
|
8
8
|
from xoople.sdk import Config, CredentialProvider, XoopleClient
|
|
9
|
+
from xoople.sdk import session as session_file
|
|
9
10
|
from xoople.sdk.auth import (
|
|
10
11
|
CiamUserCredentials,
|
|
11
12
|
CiamUserSession,
|
|
12
|
-
EntraCredential,
|
|
13
13
|
StaticToken,
|
|
14
|
+
ciam_discover_client_id,
|
|
14
15
|
)
|
|
15
16
|
|
|
16
17
|
|
|
@@ -20,7 +21,6 @@ class Credentials:
|
|
|
20
21
|
|
|
21
22
|
base_url: str | None = None
|
|
22
23
|
token: str | None = None
|
|
23
|
-
entra_scope: str | None = None
|
|
24
24
|
ciam_client_id: str | None = None
|
|
25
25
|
ciam_redirect_uri: str | None = None
|
|
26
26
|
timeout: float = 30.0
|
|
@@ -63,6 +63,7 @@ class Settings:
|
|
|
63
63
|
credentials: Credentials
|
|
64
64
|
factory: ClientFactory
|
|
65
65
|
login: LoginRunner = _ciam.login
|
|
66
|
+
discover: _ciam.ClientIdLookup = ciam_discover_client_id
|
|
66
67
|
as_json: bool = False
|
|
67
68
|
_client: XoopleClient | None = field(default=None, repr=False)
|
|
68
69
|
|
|
@@ -78,23 +79,18 @@ class Settings:
|
|
|
78
79
|
|
|
79
80
|
|
|
80
81
|
def _persist_session(session: CiamUserSession) -> None:
|
|
81
|
-
|
|
82
|
+
session_file.write_session(session)
|
|
82
83
|
|
|
83
84
|
|
|
84
85
|
def _credential_provider(credentials: Credentials) -> CredentialProvider:
|
|
85
|
-
if credentials.entra_scope:
|
|
86
|
-
return EntraCredential(credentials.entra_scope)
|
|
87
86
|
if credentials.token:
|
|
88
87
|
return StaticToken(credentials.token)
|
|
89
|
-
session =
|
|
88
|
+
session = session_file.read_session()
|
|
90
89
|
if session is not None:
|
|
91
90
|
# The credential refreshes a user token itself. Store what it mints, so
|
|
92
91
|
# the next run does not spend another refresh token.
|
|
93
92
|
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
|
-
)
|
|
93
|
+
raise UsageError("no credentials: run `xoople auth login`, pass --token, or set XOOPLE_TOKEN")
|
|
98
94
|
|
|
99
95
|
|
|
100
96
|
def build_client(credentials: Credentials) -> XoopleClient:
|
|
@@ -4,6 +4,8 @@ from xoople.sdk import errors
|
|
|
4
4
|
EXIT_CODES: dict[type[errors.XoopleError], int] = {
|
|
5
5
|
errors.AuthenticationError: 3,
|
|
6
6
|
errors.PermissionDeniedError: 4,
|
|
7
|
+
# Before its parent: `exit_code` takes the first class the error is an instance of.
|
|
8
|
+
errors.AnalysisDeletedError: 14,
|
|
7
9
|
errors.NotFoundError: 5,
|
|
8
10
|
errors.BadRequestError: 6,
|
|
9
11
|
errors.ValidationError: 6,
|
|
@@ -25,7 +25,7 @@ def _explain(exc: ValidationError) -> str:
|
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
def load_create_request(
|
|
28
|
-
path: Path, display_name: str | None = None
|
|
28
|
+
path: Path, display_name: str | None = None, project: str | None = None
|
|
29
29
|
) -> models.CreateAnalysisRequest:
|
|
30
30
|
"""Read a create/estimate body from a JSON file (``-`` for stdin).
|
|
31
31
|
|
|
@@ -40,6 +40,6 @@ def load_create_request(
|
|
|
40
40
|
# say so in its return type.
|
|
41
41
|
body: dict[str, object] = {str(key): value for key, value in document.items()}
|
|
42
42
|
try:
|
|
43
|
-
return create_request_from(body, display_name)
|
|
43
|
+
return create_request_from(body, display_name, project)
|
|
44
44
|
except ValidationError as exc:
|
|
45
45
|
raise UsageError(_explain(exc)) from exc
|
|
@@ -105,6 +105,7 @@ def create(
|
|
|
105
105
|
settings: SettingsParam,
|
|
106
106
|
spec_file: Annotated[Path, _SPEC_FILE],
|
|
107
107
|
display_name: str | None = None,
|
|
108
|
+
project: str | None = None,
|
|
108
109
|
idempotency_key: str | None = None,
|
|
109
110
|
wait: bool = False,
|
|
110
111
|
poll_interval: float = 5.0,
|
|
@@ -118,6 +119,10 @@ def create(
|
|
|
118
119
|
JSON spec, or a whole create request. Use - for stdin.
|
|
119
120
|
display_name:
|
|
120
121
|
Label for the analysis; overrides the spec file's.
|
|
122
|
+
project:
|
|
123
|
+
Project that holds the analysis, as a uid or projects/{uid}; overrides the spec
|
|
124
|
+
file's. Omit it to use the organisation's default project. An M2M token always
|
|
125
|
+
gets 403 for a named project other than the default project.
|
|
121
126
|
idempotency_key:
|
|
122
127
|
Reuse a previous key to replay a submission instead of duplicating it.
|
|
123
128
|
wait:
|
|
@@ -127,7 +132,7 @@ def create(
|
|
|
127
132
|
wait_timeout:
|
|
128
133
|
Give up waiting after this many seconds.
|
|
129
134
|
"""
|
|
130
|
-
body = load_create_request(spec_file, display_name)
|
|
135
|
+
body = load_create_request(spec_file, display_name, project)
|
|
131
136
|
analysis = settings.client().analyses.create(body, idempotency_key=idempotency_key)
|
|
132
137
|
if not wait:
|
|
133
138
|
emit_one(analysis, FIELDS, as_json=settings.as_json)
|
|
@@ -4,6 +4,8 @@ from xoople.cli import _ciam
|
|
|
4
4
|
from xoople.cli._context import SettingsParam
|
|
5
5
|
from xoople.cli._errors import LoginFailed
|
|
6
6
|
from xoople.cli._output import emit_one, note
|
|
7
|
+
from xoople.sdk import session as session_file
|
|
8
|
+
from xoople.sdk.auth import normalize_api_url
|
|
7
9
|
|
|
8
10
|
app = App(
|
|
9
11
|
name="auth",
|
|
@@ -21,15 +23,30 @@ class _StatusRow(BaseModel):
|
|
|
21
23
|
expires_at: int
|
|
22
24
|
|
|
23
25
|
|
|
26
|
+
def _bound_api_url(base_url: str | None) -> str | None:
|
|
27
|
+
"""The API a profile may send this session's token to. None binds it to no profile."""
|
|
28
|
+
if not base_url:
|
|
29
|
+
return None
|
|
30
|
+
try:
|
|
31
|
+
return normalize_api_url(base_url)
|
|
32
|
+
except ValueError:
|
|
33
|
+
raise LoginFailed(f"{base_url!r} is not a URL: check --api-url") from None
|
|
34
|
+
|
|
35
|
+
|
|
24
36
|
@app.command(name="login")
|
|
25
37
|
def login(*, settings: SettingsParam) -> None:
|
|
26
38
|
"""Sign in through the browser and store the session for later commands."""
|
|
27
39
|
config = _ciam.CiamConfig.resolve(
|
|
28
|
-
settings.credentials.ciam_client_id,
|
|
40
|
+
settings.credentials.ciam_client_id,
|
|
41
|
+
settings.credentials.ciam_redirect_uri,
|
|
42
|
+
base_url=settings.credentials.base_url,
|
|
43
|
+
discover=settings.discover,
|
|
29
44
|
)
|
|
45
|
+
api_url = _bound_api_url(settings.credentials.base_url)
|
|
30
46
|
note(f"Serving the sign-in callback on {config.redirect_uri}")
|
|
31
47
|
session = settings.login(config, announce=lambda url: note(f"Opening {url}"))
|
|
32
|
-
|
|
48
|
+
session = session.model_copy(update={"api_url": api_url})
|
|
49
|
+
path = session_file.write_session(session)
|
|
33
50
|
note(f"Signed in as {session.email or session.client_id}; session stored at {path}")
|
|
34
51
|
|
|
35
52
|
|
|
@@ -37,7 +54,7 @@ def login(*, settings: SettingsParam) -> None:
|
|
|
37
54
|
def logout(*, settings: SettingsParam) -> None:
|
|
38
55
|
"""Discard the stored session."""
|
|
39
56
|
del settings
|
|
40
|
-
if
|
|
57
|
+
if session_file.clear_session():
|
|
41
58
|
note("Signed out.")
|
|
42
59
|
return
|
|
43
60
|
note("No stored session.")
|
|
@@ -46,7 +63,7 @@ def logout(*, settings: SettingsParam) -> None:
|
|
|
46
63
|
@app.command(name="status")
|
|
47
64
|
def status(*, settings: SettingsParam) -> None:
|
|
48
65
|
"""Show who the stored session belongs to, and when it expires."""
|
|
49
|
-
session =
|
|
66
|
+
session = session_file.read_session()
|
|
50
67
|
if session is None:
|
|
51
68
|
raise LoginFailed("no stored session: run `xoople auth login`")
|
|
52
69
|
row = _StatusRow(email=session.email, org_id=session.org_id, expires_at=session.expires_at)
|
|
@@ -32,7 +32,7 @@ def measures(
|
|
|
32
32
|
product: str | None = None,
|
|
33
33
|
domain: list[str] | None = None,
|
|
34
34
|
) -> None:
|
|
35
|
-
"""List the bands, indices and
|
|
35
|
+
"""List the bands, indices and indicators you can request.
|
|
36
36
|
|
|
37
37
|
Parameters
|
|
38
38
|
----------
|
|
@@ -58,7 +58,6 @@ def measures(
|
|
|
58
58
|
)
|
|
59
59
|
for label, items in (
|
|
60
60
|
("indices", result.indices),
|
|
61
|
-
("contrasts", result.contrasts),
|
|
62
61
|
("indicators", result.indicators),
|
|
63
62
|
):
|
|
64
63
|
heading(label)
|
|
@@ -8,11 +8,13 @@ from xoople.cli import (
|
|
|
8
8
|
catalog,
|
|
9
9
|
events,
|
|
10
10
|
outputs,
|
|
11
|
+
packages,
|
|
11
12
|
projects,
|
|
12
13
|
runs,
|
|
13
14
|
schedules,
|
|
14
15
|
usage,
|
|
15
16
|
)
|
|
17
|
+
from xoople.cli._ciam import ClientIdLookup
|
|
16
18
|
from xoople.cli._ciam import login as ciam_login
|
|
17
19
|
from xoople.cli._context import (
|
|
18
20
|
ClientFactory,
|
|
@@ -25,14 +27,20 @@ from xoople.cli._errors import USAGE_EXIT, CliError, report
|
|
|
25
27
|
from xoople.cli._output import note
|
|
26
28
|
from xoople.cli._version import version
|
|
27
29
|
from xoople.sdk import errors
|
|
30
|
+
from xoople.sdk.auth import ciam_discover_client_id
|
|
28
31
|
|
|
29
32
|
|
|
30
|
-
def create_app(
|
|
33
|
+
def create_app(
|
|
34
|
+
factory: ClientFactory = build_client,
|
|
35
|
+
login: LoginRunner = ciam_login,
|
|
36
|
+
discover: ClientIdLookup = ciam_discover_client_id,
|
|
37
|
+
) -> App:
|
|
31
38
|
"""The command tree, bound to a way of building clients and signing in.
|
|
32
39
|
|
|
33
40
|
Tests build the tree over a fake transport rather than reaching into module
|
|
34
41
|
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
|
|
42
|
+
`login` is the same seam for the sign-in, which opens a browser, and
|
|
43
|
+
`discover` for the client ID lookup, which calls the API.
|
|
36
44
|
"""
|
|
37
45
|
app = App(
|
|
38
46
|
name="xoople",
|
|
@@ -43,6 +51,7 @@ def create_app(factory: ClientFactory = build_client, login: LoginRunner = ciam_
|
|
|
43
51
|
app.command(projects.app)
|
|
44
52
|
app.command(runs.app)
|
|
45
53
|
app.command(outputs.app)
|
|
54
|
+
app.command(packages.app)
|
|
46
55
|
app.command(schedules.app)
|
|
47
56
|
app.command(events.app)
|
|
48
57
|
app.command(usage.app)
|
|
@@ -55,7 +64,6 @@ def create_app(factory: ClientFactory = build_client, login: LoginRunner = ciam_
|
|
|
55
64
|
*tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)],
|
|
56
65
|
api_url: Annotated[str | None, Parameter(env_var="XOOPLE_API_URL")] = None,
|
|
57
66
|
token: Annotated[str | None, Parameter(env_var="XOOPLE_TOKEN")] = None,
|
|
58
|
-
entra_scope: Annotated[str | None, Parameter(env_var="XOOPLE_ENTRA_SCOPE")] = None,
|
|
59
67
|
ciam_client_id: Annotated[str | None, Parameter(env_var="XOOPLE_CIAM_CLIENT_ID")] = None,
|
|
60
68
|
ciam_redirect_uri: Annotated[
|
|
61
69
|
str | None, Parameter(env_var="XOOPLE_CIAM_REDIRECT_URI")
|
|
@@ -72,11 +80,9 @@ def create_app(factory: ClientFactory = build_client, login: LoginRunner = ciam_
|
|
|
72
80
|
Base URL of the Xoople API.
|
|
73
81
|
token:
|
|
74
82
|
Bearer token.
|
|
75
|
-
entra_scope:
|
|
76
|
-
Authenticate with Entra ID for this scope instead of a token
|
|
77
|
-
(internal use; needs the entra extra).
|
|
78
83
|
ciam_client_id:
|
|
79
|
-
Xoople CIAM client ID, used by `auth login`.
|
|
84
|
+
Xoople CIAM client ID, used by `auth login`. Without it, the CLI
|
|
85
|
+
reads the client ID from the API.
|
|
80
86
|
ciam_redirect_uri:
|
|
81
87
|
Loopback URI the browser returns the sign-in to. It must match the
|
|
82
88
|
URI registered for the CIAM client.
|
|
@@ -96,7 +102,6 @@ def create_app(factory: ClientFactory = build_client, login: LoginRunner = ciam_
|
|
|
96
102
|
credentials=Credentials(
|
|
97
103
|
base_url=api_url,
|
|
98
104
|
token=token,
|
|
99
|
-
entra_scope=entra_scope,
|
|
100
105
|
ciam_client_id=ciam_client_id,
|
|
101
106
|
ciam_redirect_uri=ciam_redirect_uri,
|
|
102
107
|
timeout=timeout,
|
|
@@ -104,6 +109,7 @@ def create_app(factory: ClientFactory = build_client, login: LoginRunner = ciam_
|
|
|
104
109
|
),
|
|
105
110
|
factory=factory,
|
|
106
111
|
login=login,
|
|
112
|
+
discover=discover,
|
|
107
113
|
as_json=json_output,
|
|
108
114
|
)
|
|
109
115
|
try:
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from cyclopts import App, Parameter
|
|
4
|
+
from xoople.cli._context import SettingsParam, at_least_one
|
|
5
|
+
from xoople.cli._output import emit, emit_model
|
|
6
|
+
|
|
7
|
+
app = App(
|
|
8
|
+
name="packages",
|
|
9
|
+
help="Export an analysis as an immutable Parquet package and mint access links.",
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
COLUMNS = [
|
|
13
|
+
("PACKAGE", "package_id"),
|
|
14
|
+
("STATE", "state"),
|
|
15
|
+
("VERSION", "delta_version"),
|
|
16
|
+
("ROWS", "row_count"),
|
|
17
|
+
("CREATED", "create_time"),
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.command(name="create")
|
|
22
|
+
def create(analysis: str, *, settings: SettingsParam) -> None:
|
|
23
|
+
"""Package an analysis's current output, or return the package that already holds it.
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
analysis:
|
|
28
|
+
The ready analysis to package. Needs its owner and the parquet_packages feature.
|
|
29
|
+
"""
|
|
30
|
+
emit_model(settings.client().analyses.packages.create(analysis), as_json=settings.as_json)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.command(name="list")
|
|
34
|
+
def list_packages(
|
|
35
|
+
analysis: str,
|
|
36
|
+
*,
|
|
37
|
+
settings: SettingsParam,
|
|
38
|
+
limit: Annotated[int | None, Parameter(validator=at_least_one)] = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""List an analysis's packages, newest first.
|
|
41
|
+
|
|
42
|
+
Parameters
|
|
43
|
+
----------
|
|
44
|
+
analysis:
|
|
45
|
+
The analysis whose packages to list.
|
|
46
|
+
limit:
|
|
47
|
+
Stop after this many, instead of every page.
|
|
48
|
+
"""
|
|
49
|
+
items = settings.client().analyses.packages.list(analysis, limit=limit)
|
|
50
|
+
emit(items, COLUMNS, as_json=settings.as_json)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.command(name="get")
|
|
54
|
+
def get(analysis: str, package: str, *, settings: SettingsParam) -> None:
|
|
55
|
+
"""Show one package and whether it has settled.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
analysis:
|
|
60
|
+
The analysis the package belongs to.
|
|
61
|
+
package:
|
|
62
|
+
The package to show.
|
|
63
|
+
"""
|
|
64
|
+
emit_model(settings.client().analyses.packages.get(analysis, package), as_json=settings.as_json)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command(name="access-url")
|
|
68
|
+
def access_url(
|
|
69
|
+
analysis: str,
|
|
70
|
+
package: str,
|
|
71
|
+
*,
|
|
72
|
+
settings: SettingsParam,
|
|
73
|
+
access_ttl_hours: int | None = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Mint links to a ready package: one zip of it, and its directory.
|
|
76
|
+
|
|
77
|
+
`archive_url` downloads the whole package as one zip file. `url` opens the
|
|
78
|
+
directory; copy it with `azcopy copy "<url>" ./package --recursive`.
|
|
79
|
+
|
|
80
|
+
Parameters
|
|
81
|
+
----------
|
|
82
|
+
analysis:
|
|
83
|
+
The analysis the package belongs to.
|
|
84
|
+
package:
|
|
85
|
+
The ready package to mint a link for.
|
|
86
|
+
access_ttl_hours:
|
|
87
|
+
How many hours the link stays valid, 1 to 168; the API picks 24.
|
|
88
|
+
"""
|
|
89
|
+
result = settings.client().analyses.packages.generate_access_url(
|
|
90
|
+
analysis, package, access_ttl_hours=access_ttl_hours
|
|
91
|
+
)
|
|
92
|
+
emit_model(result, as_json=settings.as_json)
|
|
@@ -98,6 +98,34 @@ def update(
|
|
|
98
98
|
)
|
|
99
99
|
|
|
100
100
|
|
|
101
|
+
MEMBER_COLUMNS = [
|
|
102
|
+
("SUBJECT", "subject_id"),
|
|
103
|
+
("NAME", "display_name"),
|
|
104
|
+
("EMAIL", "email"),
|
|
105
|
+
("ROLE", "role"),
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@app.command(name="members")
|
|
110
|
+
def members(project: str, *, settings: SettingsParam) -> None:
|
|
111
|
+
"""List a project's members and their project role.
|
|
112
|
+
|
|
113
|
+
The default project admits every organisation member, so it lists none.
|
|
114
|
+
|
|
115
|
+
Parameters
|
|
116
|
+
----------
|
|
117
|
+
project:
|
|
118
|
+
The project to list.
|
|
119
|
+
"""
|
|
120
|
+
listed = settings.client().projects.list_members(project)
|
|
121
|
+
if settings.as_json:
|
|
122
|
+
emit_model(listed, as_json=True)
|
|
123
|
+
elif listed.admits_whole_organization:
|
|
124
|
+
note(f"[dim]Every organisation member belongs to {project}[/dim]")
|
|
125
|
+
else:
|
|
126
|
+
emit(listed.members, MEMBER_COLUMNS, as_json=False)
|
|
127
|
+
|
|
128
|
+
|
|
101
129
|
@app.command(name="add-member")
|
|
102
130
|
def add_member(project: str, subject_id: str, *, settings: SettingsParam) -> None:
|
|
103
131
|
"""Add a subject to a project.
|
|
@@ -161,6 +189,22 @@ def archive(project: str, *, settings: SettingsParam) -> None:
|
|
|
161
189
|
note(f"[dim]{project} is archived[/dim]")
|
|
162
190
|
|
|
163
191
|
|
|
192
|
+
@app.command(name="delete")
|
|
193
|
+
def delete(project: str, *, settings: SettingsParam) -> None:
|
|
194
|
+
"""Delete an archived project and remove its stored data.
|
|
195
|
+
|
|
196
|
+
The project must hold no analyses. It counts against the project limit
|
|
197
|
+
until storage removal finishes.
|
|
198
|
+
|
|
199
|
+
Parameters
|
|
200
|
+
----------
|
|
201
|
+
project:
|
|
202
|
+
The archived project to delete.
|
|
203
|
+
"""
|
|
204
|
+
settings.client().projects.delete(project)
|
|
205
|
+
note(f"[dim]{project} is deleted; its storage is being removed[/dim]")
|
|
206
|
+
|
|
207
|
+
|
|
164
208
|
@app.command(name="record-access")
|
|
165
209
|
def record_access(project: str, *, settings: SettingsParam) -> None:
|
|
166
210
|
"""Record that you opened a project, which orders `projects recent`.
|
|
@@ -1,190 +0,0 @@
|
|
|
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
|
|
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
|