xoople-cli 0.3.0__tar.gz → 0.5.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 (26) hide show
  1. {xoople_cli-0.3.0 → xoople_cli-0.5.0}/PKG-INFO +31 -9
  2. {xoople_cli-0.3.0 → xoople_cli-0.5.0}/README.md +29 -5
  3. {xoople_cli-0.3.0 → xoople_cli-0.5.0}/pyproject.toml +3 -6
  4. {xoople_cli-0.3.0 → xoople_cli-0.5.0}/pyproject.toml.orig +3 -10
  5. xoople_cli-0.5.0/src/xoople/cli/_ciam.py +71 -0
  6. {xoople_cli-0.3.0 → xoople_cli-0.5.0}/src/xoople/cli/_context.py +6 -10
  7. {xoople_cli-0.3.0 → xoople_cli-0.5.0}/src/xoople/cli/_errors.py +2 -0
  8. {xoople_cli-0.3.0 → xoople_cli-0.5.0}/src/xoople/cli/_spec.py +2 -2
  9. {xoople_cli-0.3.0 → xoople_cli-0.5.0}/src/xoople/cli/access.py +1 -1
  10. {xoople_cli-0.3.0 → xoople_cli-0.5.0}/src/xoople/cli/analyses.py +8 -44
  11. {xoople_cli-0.3.0 → xoople_cli-0.5.0}/src/xoople/cli/auth.py +21 -4
  12. {xoople_cli-0.3.0 → xoople_cli-0.5.0}/src/xoople/cli/catalog.py +1 -2
  13. {xoople_cli-0.3.0 → xoople_cli-0.5.0}/src/xoople/cli/main.py +14 -8
  14. xoople_cli-0.5.0/src/xoople/cli/packages.py +114 -0
  15. {xoople_cli-0.3.0 → xoople_cli-0.5.0}/src/xoople/cli/projects.py +44 -0
  16. xoople_cli-0.3.0/src/xoople/cli/_ciam.py +0 -190
  17. {xoople_cli-0.3.0 → xoople_cli-0.5.0}/LICENSE +0 -0
  18. {xoople_cli-0.3.0 → xoople_cli-0.5.0}/src/xoople/cli/__init__.py +0 -0
  19. {xoople_cli-0.3.0 → xoople_cli-0.5.0}/src/xoople/cli/__main__.py +0 -0
  20. {xoople_cli-0.3.0 → xoople_cli-0.5.0}/src/xoople/cli/_output.py +0 -0
  21. {xoople_cli-0.3.0 → xoople_cli-0.5.0}/src/xoople/cli/_version.py +0 -0
  22. {xoople_cli-0.3.0 → xoople_cli-0.5.0}/src/xoople/cli/events.py +0 -0
  23. {xoople_cli-0.3.0 → xoople_cli-0.5.0}/src/xoople/cli/outputs.py +0 -0
  24. {xoople_cli-0.3.0 → xoople_cli-0.5.0}/src/xoople/cli/runs.py +0 -0
  25. {xoople_cli-0.3.0 → xoople_cli-0.5.0}/src/xoople/cli/schedules.py +0 -0
  26. {xoople_cli-0.3.0 → xoople_cli-0.5.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.0
3
+ Version: 0.5.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.5.0
9
+ Requires-Dist: xoople-sdk>=0.7.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
- neither `--token` nor `--entra-scope` is given, and refreshes it when the
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 XOOPLE_CIAM_CLIENT_ID=client_...
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 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
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
- neither `--token` nor `--entra-scope` is given, and refreshes it when the
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 XOOPLE_CIAM_CLIENT_ID=client_...
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 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
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.0"
3
+ version = "0.5.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.5.0",
14
+ "xoople-sdk>=0.7.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
 
@@ -26,7 +23,7 @@ xoople = "xoople.cli.main:main"
26
23
  Homepage = "https://xoople.com"
27
24
 
28
25
  [build-system]
29
- requires = ["uv_build>=0.11.6,<0.12"]
26
+ requires = ["uv_build>=0.13.0,<0.14"]
30
27
  build-backend = "uv_build"
31
28
 
32
29
  [tool.uv.build-backend]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xoople-cli"
3
- version = "0.3.0"
3
+ version = "0.5.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.5.0",
16
+ "xoople-sdk>=0.7.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
 
@@ -36,7 +29,7 @@ xoople = "xoople.cli.main:main"
36
29
  Homepage = "https://xoople.com"
37
30
 
38
31
  [build-system]
39
- requires = ["uv_build>=0.11.6,<0.12"]
32
+ requires = ["uv_build>=0.13.0,<0.14"]
40
33
  build-backend = "uv_build"
41
34
 
42
35
  [tool.uv.build-backend]
@@ -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
- _ciam.write_session(session)
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 = _ciam.read_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
@@ -22,7 +22,7 @@ class _FlagRow(BaseModel):
22
22
 
23
23
  @app.command(name="me")
24
24
  def me(*, settings: SettingsParam) -> None:
25
- """Show your Delta Sharing recipient and the share it reads."""
25
+ """Show your Delta Sharing recipient and the schema of your org's shared tables."""
26
26
  emit_model(settings.client().data_access.me(), as_json=settings.as_json)
27
27
 
28
28
 
@@ -1,5 +1,5 @@
1
1
  from pathlib import Path
2
- from typing import Annotated, Literal
2
+ from typing import Annotated
3
3
 
4
4
  from cyclopts import App, Parameter
5
5
  from xoople.cli._context import SettingsParam, at_least_one
@@ -30,6 +30,7 @@ FIELDS = [
30
30
  ("Display name", "display_name"),
31
31
  ("Product", "product"),
32
32
  ("Product version", "product_version"),
33
+ ("Output schema outdated", "output_schema_outdated"),
33
34
  ("State", "state"),
34
35
  ("State message", "state_message"),
35
36
  ("Owner", "owner"),
@@ -105,6 +106,7 @@ def create(
105
106
  settings: SettingsParam,
106
107
  spec_file: Annotated[Path, _SPEC_FILE],
107
108
  display_name: str | None = None,
109
+ project: str | None = None,
108
110
  idempotency_key: str | None = None,
109
111
  wait: bool = False,
110
112
  poll_interval: float = 5.0,
@@ -118,6 +120,10 @@ def create(
118
120
  JSON spec, or a whole create request. Use - for stdin.
119
121
  display_name:
120
122
  Label for the analysis; overrides the spec file's.
123
+ project:
124
+ Project that holds the analysis, as a uid or projects/{uid}; overrides the spec
125
+ file's. Omit it to use the organisation's default project. An M2M token always
126
+ gets 403 for a named project other than the default project.
121
127
  idempotency_key:
122
128
  Reuse a previous key to replay a submission instead of duplicating it.
123
129
  wait:
@@ -127,7 +133,7 @@ def create(
127
133
  wait_timeout:
128
134
  Give up waiting after this many seconds.
129
135
  """
130
- body = load_create_request(spec_file, display_name)
136
+ body = load_create_request(spec_file, display_name, project)
131
137
  analysis = settings.client().analyses.create(body, idempotency_key=idempotency_key)
132
138
  if not wait:
133
139
  emit_one(analysis, FIELDS, as_json=settings.as_json)
@@ -173,48 +179,6 @@ def cancel(analysis: str, *, settings: SettingsParam) -> None:
173
179
  emit_one(settings.client().analyses.cancel(analysis), FIELDS, as_json=settings.as_json)
174
180
 
175
181
 
176
- @app.command(name="share")
177
- def share(
178
- analysis: str,
179
- *,
180
- settings: SettingsParam,
181
- scope: Literal["org"] = "org",
182
- ) -> None:
183
- """Let your organisation read one analysis.
184
-
185
- Anyone in your organisation can then open it by name. It stays out of their
186
- listings, and the share grants them no reuse action and no access to the
187
- underlying data. Only the owner may share; repeating the call changes
188
- nothing.
189
-
190
- Parameters
191
- ----------
192
- analysis:
193
- The analysis to share.
194
- scope:
195
- Who to share with. Only your own organisation may be named.
196
- """
197
- emit_one(settings.client().analyses.share(analysis, scope), FIELDS, as_json=settings.as_json)
198
-
199
-
200
- @app.command(name="unshare")
201
- def unshare(analysis: str, *, settings: SettingsParam, share: str = "org") -> None:
202
- """Revoke one grant on an analysis.
203
-
204
- Only the owner may unshare; repeating the call changes nothing.
205
-
206
- Parameters
207
- ----------
208
- analysis:
209
- The analysis to revoke a grant on.
210
- share:
211
- Which grant to revoke. `org` names your whole organisation, the only
212
- audience an analysis can be shared with today.
213
- """
214
- settings.client().analyses.unshare(analysis, share)
215
- note(f"[dim]{analysis} is no longer shared with {share}[/dim]")
216
-
217
-
218
182
  @app.command(name="run-again")
219
183
  def run_again(
220
184
  analysis: str,
@@ -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, settings.credentials.ciam_redirect_uri
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
- path = _ciam.write_session(session)
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 _ciam.clear_session():
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 = _ciam.read_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 contrasts you can request.
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(factory: ClientFactory = build_client, login: LoginRunner = ciam_login) -> 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,114 @@
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, note
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)
93
+
94
+
95
+ @app.command(name="delete")
96
+ def delete(analysis: str, package: str, *, settings: SettingsParam) -> None:
97
+ """Delete a package and its files; the source table and other packages stay.
98
+
99
+ Cleanup runs in the background, so the package shows as DELETING until
100
+ `packages get` reports DELETED. Run this again on a DELETE_FAILED package
101
+ to retry it.
102
+
103
+ Parameters
104
+ ----------
105
+ analysis:
106
+ The analysis the package belongs to.
107
+ package:
108
+ The package to delete. Needs its owner; a package still being built cannot be deleted.
109
+ """
110
+ result = settings.client().analyses.packages.delete(analysis, package)
111
+ if result is None:
112
+ note(f"[dim]{package} is already deleted[/dim]")
113
+ return
114
+ 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