xoople-cli 0.2.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.
Files changed (27) hide show
  1. {xoople_cli-0.2.0 → xoople_cli-0.4.0}/PKG-INFO +49 -6
  2. xoople_cli-0.4.0/README.md +93 -0
  3. {xoople_cli-0.2.0 → xoople_cli-0.4.0}/pyproject.toml +2 -5
  4. {xoople_cli-0.2.0 → xoople_cli-0.4.0}/pyproject.toml.orig +2 -9
  5. xoople_cli-0.4.0/src/xoople/cli/_ciam.py +71 -0
  6. {xoople_cli-0.2.0 → xoople_cli-0.4.0}/src/xoople/cli/_context.py +34 -8
  7. {xoople_cli-0.2.0 → xoople_cli-0.4.0}/src/xoople/cli/_errors.py +12 -0
  8. {xoople_cli-0.2.0 → xoople_cli-0.4.0}/src/xoople/cli/_output.py +3 -6
  9. {xoople_cli-0.2.0 → xoople_cli-0.4.0}/src/xoople/cli/_spec.py +2 -2
  10. {xoople_cli-0.2.0 → xoople_cli-0.4.0}/src/xoople/cli/analyses.py +6 -1
  11. xoople_cli-0.4.0/src/xoople/cli/auth.py +70 -0
  12. {xoople_cli-0.2.0 → xoople_cli-0.4.0}/src/xoople/cli/catalog.py +20 -2
  13. {xoople_cli-0.2.0 → xoople_cli-0.4.0}/src/xoople/cli/main.py +36 -8
  14. {xoople_cli-0.2.0 → xoople_cli-0.4.0}/src/xoople/cli/outputs.py +34 -0
  15. xoople_cli-0.4.0/src/xoople/cli/packages.py +92 -0
  16. xoople_cli-0.4.0/src/xoople/cli/projects.py +234 -0
  17. xoople_cli-0.2.0/README.md +0 -48
  18. xoople_cli-0.2.0/src/xoople/cli/projects.py +0 -118
  19. {xoople_cli-0.2.0 → xoople_cli-0.4.0}/LICENSE +0 -0
  20. {xoople_cli-0.2.0 → xoople_cli-0.4.0}/src/xoople/cli/__init__.py +0 -0
  21. {xoople_cli-0.2.0 → xoople_cli-0.4.0}/src/xoople/cli/__main__.py +0 -0
  22. {xoople_cli-0.2.0 → xoople_cli-0.4.0}/src/xoople/cli/_version.py +0 -0
  23. {xoople_cli-0.2.0 → xoople_cli-0.4.0}/src/xoople/cli/access.py +0 -0
  24. {xoople_cli-0.2.0 → xoople_cli-0.4.0}/src/xoople/cli/events.py +0 -0
  25. {xoople_cli-0.2.0 → xoople_cli-0.4.0}/src/xoople/cli/runs.py +0 -0
  26. {xoople_cli-0.2.0 → xoople_cli-0.4.0}/src/xoople/cli/schedules.py +0 -0
  27. {xoople_cli-0.2.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.2.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.3.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.3.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
@@ -41,6 +39,49 @@ output pipes into `jq`.
41
39
  | `usage` | `history` `limits` |
42
40
  | `catalog` | `products` `measures` `designs` |
43
41
  | `access` | `me` `token` `features` |
42
+ | `auth` | `login` `logout` `status` |
43
+
44
+ ## Signing in
45
+
46
+ `auth login` signs in through the browser with PKCE and stores the session at
47
+ `$XDG_CONFIG_HOME/xoople/session.json` (`~/.config/xoople/session.json` by
48
+ default), readable only by you. Every later command uses that session when
49
+ no `--token` is given, and refreshes it when the
50
+ access token is close to expiry.
51
+
52
+ ```console
53
+ $ export XOOPLE_API_URL=https://api.xoople.com
54
+ $ xoople auth login
55
+ $ xoople analyses list
56
+ ```
57
+
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
61
+ loopback URI the browser returns to; it must match a URI registered for the
62
+ CIAM client.
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.
44
85
 
45
86
  ## Submitting an analysis
46
87
 
@@ -59,7 +100,9 @@ non-zero if it does not reach `COMPLETE`.
59
100
 
60
101
  ## Exit codes
61
102
 
62
- `0` success · `1` unexpected CLI error · `2` usage error · `3` unauthenticated · `4` forbidden ·
103
+ `0` success · `1` unexpected CLI error · `2` usage error ·
104
+ `3` unauthenticated, including a failed or absent sign-in · `4` forbidden ·
63
105
  `5` not found · `6` bad request or validation · `7` conflict · `8` rate limited ·
64
106
  `9` server error · `10` connection failure · `11` unreadable response ·
65
- `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.
@@ -0,0 +1,93 @@
1
+ # xoople-cli
2
+
3
+ Command-line interface for the Xoople Products API, over [`xoople-sdk`](../xoople-sdk).
4
+
5
+ ```console
6
+ $ uv tool install xoople-cli
7
+ $ export XOOPLE_API_URL=https://api.xoople.com XOOPLE_TOKEN=...
8
+ $ xoople analyses list
9
+ ```
10
+
11
+ Every command reads `--api-url` and `--token` from `XOOPLE_API_URL` and
12
+ `XOOPLE_TOKEN`. `--json` prints the API response verbatim instead of a table, so
13
+ output pipes into `jq`.
14
+
15
+ ## Commands
16
+
17
+ | Group | Commands |
18
+ | --- | --- |
19
+ | `analyses` | `list` `get` `create` `update` `cancel` `estimate` |
20
+ | `runs` | `list` `get` |
21
+ | `outputs` | `list` `get` `access-url` |
22
+ | `schedules` | `list` `get` `create` `pause` `resume` `cancel` `run` `estimate-run` |
23
+ | `events` | `list` `get` |
24
+ | `usage` | `history` `limits` |
25
+ | `catalog` | `products` `measures` `designs` |
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
+ no `--token` 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
39
+ $ xoople auth login
40
+ $ xoople analyses list
41
+ ```
42
+
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
46
+ loopback URI the browser returns to; it must match a URI registered for the
47
+ CIAM client.
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
+
71
+ ## Submitting an analysis
72
+
73
+ The product spec is deeply nested and differs per product, so `create` and
74
+ `estimate` take it as JSON rather than as flags:
75
+
76
+ ```console
77
+ $ xoople catalog designs --json | jq '.[0].spec_fragment' > spec.json
78
+ $ # fill in period and aoi, then:
79
+ $ xoople analyses create -f spec.json --display-name "South Wales NDVI" --wait
80
+ ```
81
+
82
+ The file holds either the bare spec or a whole create request (anything with a
83
+ `spec` key). `-f -` reads stdin. `--wait` polls the initial run and exits
84
+ non-zero if it does not reach `COMPLETE`.
85
+
86
+ ## Exit codes
87
+
88
+ `0` success · `1` unexpected CLI error · `2` usage error ·
89
+ `3` unauthenticated, including a failed or absent sign-in · `4` forbidden ·
90
+ `5` not found · `6` bad request or validation · `7` conflict · `8` rate limited ·
91
+ `9` server error · `10` connection failure · `11` unreadable response ·
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.2.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.3.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.3.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.2.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.3.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.3.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
@@ -1,11 +1,18 @@
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 import session as session_file
10
+ from xoople.sdk.auth import (
11
+ CiamUserCredentials,
12
+ CiamUserSession,
13
+ StaticToken,
14
+ ciam_discover_client_id,
15
+ )
9
16
 
10
17
 
11
18
  @dataclass(frozen=True)
@@ -14,7 +21,8 @@ class Credentials:
14
21
 
15
22
  base_url: str | None = None
16
23
  token: str | None = None
17
- 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,8 @@ class Settings:
45
62
 
46
63
  credentials: Credentials
47
64
  factory: ClientFactory
65
+ login: LoginRunner = _ciam.login
66
+ discover: _ciam.ClientIdLookup = ciam_discover_client_id
48
67
  as_json: bool = False
49
68
  _client: XoopleClient | None = field(default=None, repr=False)
50
69
 
@@ -59,12 +78,19 @@ class Settings:
59
78
  self._client = None
60
79
 
61
80
 
81
+ def _persist_session(session: CiamUserSession) -> None:
82
+ session_file.write_session(session)
83
+
84
+
62
85
  def _credential_provider(credentials: Credentials) -> CredentialProvider:
63
- if credentials.entra_scope:
64
- 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)
86
+ if credentials.token:
87
+ return StaticToken(credentials.token)
88
+ session = session_file.read_session()
89
+ if session is not None:
90
+ # The credential refreshes a user token itself. Store what it mints, so
91
+ # the next run does not spend another refresh token.
92
+ return CiamUserCredentials(session, on_refresh=_persist_session)
93
+ raise UsageError("no credentials: run `xoople auth login`, pass --token, or set XOOPLE_TOKEN")
68
94
 
69
95
 
70
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,
@@ -32,6 +34,16 @@ class UsageError(CliError):
32
34
  exit_code = USAGE_EXIT
33
35
 
34
36
 
37
+ class LoginFailed(CliError):
38
+ """A CIAM sign-in did not produce a session.
39
+
40
+ Takes the API's unauthenticated code: the outcome a caller must handle is
41
+ the same, so a script does not need a second number for it.
42
+ """
43
+
44
+ exit_code = EXIT_CODES[errors.AuthenticationError]
45
+
46
+
35
47
  class RunFailed(CliError):
36
48
  """``--wait`` finished, but the run ended in a non-``COMPLETE`` state."""
37
49
 
@@ -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
@@ -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)
@@ -0,0 +1,70 @@
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
+ from xoople.sdk import session as session_file
8
+ from xoople.sdk.auth import normalize_api_url
9
+
10
+ app = App(
11
+ name="auth",
12
+ help="Sign in to Xoople, and show or discard the stored session.",
13
+ )
14
+
15
+ FIELDS = [("EMAIL", "email"), ("ORGANIZATION", "org_id"), ("EXPIRES", "expires_at")]
16
+
17
+
18
+ class _StatusRow(BaseModel):
19
+ """The part of a session that is safe to print. It carries no token."""
20
+
21
+ email: str | None
22
+ org_id: str | None
23
+ expires_at: int
24
+
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
+
36
+ @app.command(name="login")
37
+ def login(*, settings: SettingsParam) -> None:
38
+ """Sign in through the browser and store the session for later commands."""
39
+ config = _ciam.CiamConfig.resolve(
40
+ settings.credentials.ciam_client_id,
41
+ settings.credentials.ciam_redirect_uri,
42
+ base_url=settings.credentials.base_url,
43
+ discover=settings.discover,
44
+ )
45
+ api_url = _bound_api_url(settings.credentials.base_url)
46
+ note(f"Serving the sign-in callback on {config.redirect_uri}")
47
+ session = settings.login(config, announce=lambda url: note(f"Opening {url}"))
48
+ session = session.model_copy(update={"api_url": api_url})
49
+ path = session_file.write_session(session)
50
+ note(f"Signed in as {session.email or session.client_id}; session stored at {path}")
51
+
52
+
53
+ @app.command(name="logout")
54
+ def logout(*, settings: SettingsParam) -> None:
55
+ """Discard the stored session."""
56
+ del settings
57
+ if session_file.clear_session():
58
+ note("Signed out.")
59
+ return
60
+ note("No stored session.")
61
+
62
+
63
+ @app.command(name="status")
64
+ def status(*, settings: SettingsParam) -> None:
65
+ """Show who the stored session belongs to, and when it expires."""
66
+ session = session_file.read_session()
67
+ if session is None:
68
+ raise LoginFailed("no stored session: run `xoople auth login`")
69
+ row = _StatusRow(email=session.email, org_id=session.org_id, expires_at=session.expires_at)
70
+ emit_one(row, FIELDS, as_json=settings.as_json)
@@ -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)
@@ -101,6 +100,25 @@ def projections(*, settings: SettingsParam, bbox: str) -> None:
101
100
  )
102
101
 
103
102
 
103
+ @app.command(name="cloud-mask-presets")
104
+ def cloud_mask_presets(*, settings: SettingsParam) -> None:
105
+ """List the scene-class masking presets, and the scene classes each one discards.
106
+
107
+ A preset name is what `cloud_mask_mode` accepts on an imagery request. The
108
+ classes are published here so a request never has to restate them.
109
+ """
110
+ result = settings.client().catalog.cloud_mask_presets()
111
+ emit(
112
+ result.presets,
113
+ [
114
+ ("MODE", "mode"),
115
+ ("EXCLUDED CLASSES", "excluded_classes"),
116
+ ("EXCLUDED CLASS NAMES", "excluded_class_names"),
117
+ ],
118
+ as_json=settings.as_json,
119
+ )
120
+
121
+
104
122
  @app.command(name="designs")
105
123
  def designs(*, settings: SettingsParam, product: str | None = None) -> None:
106
124
  """List the starting-point analysis designs. Their `spec_fragment` seeds a spec file.
@@ -4,26 +4,43 @@ 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,
11
+ packages,
10
12
  projects,
11
13
  runs,
12
14
  schedules,
13
15
  usage,
14
16
  )
15
- from xoople.cli._context import ClientFactory, Credentials, Settings, build_client
17
+ from xoople.cli._ciam import ClientIdLookup
18
+ from xoople.cli._ciam import login as ciam_login
19
+ from xoople.cli._context import (
20
+ ClientFactory,
21
+ Credentials,
22
+ LoginRunner,
23
+ Settings,
24
+ build_client,
25
+ )
16
26
  from xoople.cli._errors import USAGE_EXIT, CliError, report
17
27
  from xoople.cli._output import note
18
28
  from xoople.cli._version import version
19
29
  from xoople.sdk import errors
30
+ from xoople.sdk.auth import ciam_discover_client_id
20
31
 
21
32
 
22
- def create_app(factory: ClientFactory = build_client) -> App:
23
- """The command tree, bound to a way of building clients.
33
+ def create_app(
34
+ factory: ClientFactory = build_client,
35
+ login: LoginRunner = ciam_login,
36
+ discover: ClientIdLookup = ciam_discover_client_id,
37
+ ) -> App:
38
+ """The command tree, bound to a way of building clients and signing in.
24
39
 
25
40
  Tests build the tree over a fake transport rather than reaching into module
26
41
  globals, so every command runs its real argument parsing and rendering.
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.
27
44
  """
28
45
  app = App(
29
46
  name="xoople",
@@ -34,18 +51,23 @@ def create_app(factory: ClientFactory = build_client) -> App:
34
51
  app.command(projects.app)
35
52
  app.command(runs.app)
36
53
  app.command(outputs.app)
54
+ app.command(packages.app)
37
55
  app.command(schedules.app)
38
56
  app.command(events.app)
39
57
  app.command(usage.app)
40
58
  app.command(catalog.app)
41
59
  app.command(access.app)
60
+ app.command(auth.app)
42
61
 
43
62
  @app.meta.default
44
63
  def launcher(
45
64
  *tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)],
46
65
  api_url: Annotated[str | None, Parameter(env_var="XOOPLE_API_URL")] = None,
47
66
  token: Annotated[str | None, Parameter(env_var="XOOPLE_TOKEN")] = None,
48
- entra_scope: Annotated[str | None, Parameter(env_var="XOOPLE_ENTRA_SCOPE")] = None,
67
+ ciam_client_id: Annotated[str | None, Parameter(env_var="XOOPLE_CIAM_CLIENT_ID")] = None,
68
+ ciam_redirect_uri: Annotated[
69
+ str | None, Parameter(env_var="XOOPLE_CIAM_REDIRECT_URI")
70
+ ] = None,
49
71
  timeout: Annotated[float, Parameter()] = 30.0,
50
72
  max_retries: Annotated[int, Parameter()] = 3,
51
73
  json_output: Annotated[bool, Parameter(name="--json")] = False,
@@ -58,9 +80,12 @@ def create_app(factory: ClientFactory = build_client) -> App:
58
80
  Base URL of the Xoople API.
59
81
  token:
60
82
  Bearer token.
61
- entra_scope:
62
- Authenticate with Entra ID for this scope instead of a token
63
- (internal use; needs the entra extra).
83
+ ciam_client_id:
84
+ Xoople CIAM client ID, used by `auth login`. Without it, the CLI
85
+ reads the client ID from the API.
86
+ ciam_redirect_uri:
87
+ Loopback URI the browser returns the sign-in to. It must match the
88
+ URI registered for the CIAM client.
64
89
  timeout:
65
90
  Per-request timeout, in seconds.
66
91
  max_retries:
@@ -77,11 +102,14 @@ def create_app(factory: ClientFactory = build_client) -> App:
77
102
  credentials=Credentials(
78
103
  base_url=api_url,
79
104
  token=token,
80
- entra_scope=entra_scope,
105
+ ciam_client_id=ciam_client_id,
106
+ ciam_redirect_uri=ciam_redirect_uri,
81
107
  timeout=timeout,
82
108
  max_retries=max_retries,
83
109
  ),
84
110
  factory=factory,
111
+ login=login,
112
+ discover=discover,
85
113
  as_json=json_output,
86
114
  )
87
115
  try:
@@ -75,3 +75,37 @@ def access_url(
75
75
  analysis, output, ttl_seconds=ttl_seconds
76
76
  )
77
77
  emit_model(result, as_json=settings.as_json)
78
+
79
+
80
+ @app.command(name="summary")
81
+ def summary(analysis: str, output: str, *, settings: SettingsParam) -> None:
82
+ """Show an output's summary: aggregate coverage, grain and pixel statistics.
83
+
84
+ Parameters
85
+ ----------
86
+ analysis:
87
+ The analysis the output belongs to.
88
+ output:
89
+ The output whose summary to show.
90
+ """
91
+ emit_model(
92
+ settings.client().analyses.outputs.get_summary(analysis, output),
93
+ as_json=settings.as_json,
94
+ )
95
+
96
+
97
+ @app.command(name="refresh-summary")
98
+ def refresh_summary(analysis: str, output: str, *, settings: SettingsParam) -> None:
99
+ """Start (or join) an asynchronous recomputation of an output's summary.
100
+
101
+ Parameters
102
+ ----------
103
+ analysis:
104
+ The analysis the output belongs to.
105
+ output:
106
+ The output whose summary to refresh.
107
+ """
108
+ emit_model(
109
+ settings.client().analyses.outputs.refresh_summary(analysis, output),
110
+ as_json=settings.as_json,
111
+ )
@@ -0,0 +1,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)
@@ -0,0 +1,234 @@
1
+ from typing import Annotated
2
+
3
+ from cyclopts import App, Parameter
4
+ from xoople.cli._context import SettingsParam, at_least_one
5
+ from xoople.cli._output import emit, emit_model, emit_one, note
6
+
7
+ app = App(
8
+ name="projects",
9
+ help="Organise analyses into projects and manage who belongs to them.",
10
+ )
11
+
12
+ COLUMNS = [
13
+ ("UID", "uid"),
14
+ ("NAME", "display_name"),
15
+ ("DEFAULT", "is_default"),
16
+ ("ARCHIVED", "archive_time"),
17
+ ]
18
+
19
+ FIELDS = [
20
+ ("Name", "name"),
21
+ ("UID", "uid"),
22
+ ("Display name", "display_name"),
23
+ ("Description", "description"),
24
+ ("Default", "is_default"),
25
+ ("Archive time", "archive_time"),
26
+ ("Create time", "create_time"),
27
+ ("Update time", "update_time"),
28
+ ]
29
+
30
+
31
+ @app.command(name="list")
32
+ def list_projects(
33
+ *,
34
+ settings: SettingsParam,
35
+ limit: Annotated[int | None, Parameter(validator=at_least_one)] = None,
36
+ ) -> None:
37
+ """List your organisation's projects.
38
+
39
+ Parameters
40
+ ----------
41
+ limit:
42
+ Stop after this many, instead of every page.
43
+ """
44
+ emit(settings.client().projects.list(limit=limit), COLUMNS, as_json=settings.as_json)
45
+
46
+
47
+ @app.command(name="create")
48
+ def create(name: str, *, description: str | None = None, settings: SettingsParam) -> None:
49
+ """Create a project.
50
+
51
+ Parameters
52
+ ----------
53
+ name:
54
+ The project's display name.
55
+ description:
56
+ The project's description, at most 500 characters.
57
+ """
58
+ emit_one(settings.client().projects.create(name, description), FIELDS, as_json=settings.as_json)
59
+
60
+
61
+ @app.command(name="get")
62
+ def get(project: str, *, settings: SettingsParam) -> None:
63
+ """Show one project, archived or not.
64
+
65
+ Parameters
66
+ ----------
67
+ project:
68
+ The project to show.
69
+ """
70
+ emit_one(settings.client().projects.get(project), FIELDS, as_json=settings.as_json)
71
+
72
+
73
+ @app.command(name="update")
74
+ def update(
75
+ project: str,
76
+ *,
77
+ display_name: str | None = None,
78
+ description: str | None = None,
79
+ settings: SettingsParam,
80
+ ) -> None:
81
+ """Change a project's display name, its description, or both.
82
+
83
+ Parameters
84
+ ----------
85
+ project:
86
+ The project to change.
87
+ display_name:
88
+ The new display name.
89
+ description:
90
+ The new description. Pass an empty string to clear it.
91
+ """
92
+ emit_one(
93
+ settings.client().projects.update(
94
+ project, display_name=display_name, description=description
95
+ ),
96
+ FIELDS,
97
+ as_json=settings.as_json,
98
+ )
99
+
100
+
101
+ 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
+
129
+ @app.command(name="add-member")
130
+ def add_member(project: str, subject_id: str, *, settings: SettingsParam) -> None:
131
+ """Add a subject to a project.
132
+
133
+ Parameters
134
+ ----------
135
+ project:
136
+ The project to add to.
137
+ subject_id:
138
+ The subject to add.
139
+ """
140
+ emit_model(settings.client().projects.add_member(project, subject_id), as_json=settings.as_json)
141
+
142
+
143
+ @app.command(name="remove-member")
144
+ def remove_member(project: str, subject: str, *, settings: SettingsParam) -> None:
145
+ """Remove a subject from a project.
146
+
147
+ Parameters
148
+ ----------
149
+ project:
150
+ The project to remove from.
151
+ subject:
152
+ The subject to remove.
153
+ """
154
+ settings.client().projects.remove_member(project, subject)
155
+ note(f"[dim]{subject} is no longer a member of {project}[/dim]")
156
+
157
+
158
+ @app.command(name="move")
159
+ def move(project: str, destination: str, analyses: list[str], *, settings: SettingsParam) -> None:
160
+ """Move named analyses into another project, pausing their schedules.
161
+
162
+ Parameters
163
+ ----------
164
+ project:
165
+ The project the analyses are in now.
166
+ destination:
167
+ The project to move them into.
168
+ analyses:
169
+ The analysis identifiers to move, at most 500 per call.
170
+ """
171
+ emit_model(
172
+ settings.client().projects.move_analyses_to_project(project, analyses, destination),
173
+ as_json=settings.as_json,
174
+ )
175
+
176
+
177
+ @app.command(name="archive")
178
+ def archive(project: str, *, settings: SettingsParam) -> None:
179
+ """Archive a project and pause its schedules.
180
+
181
+ The default project cannot be archived.
182
+
183
+ Parameters
184
+ ----------
185
+ project:
186
+ The project to archive.
187
+ """
188
+ settings.client().projects.archive(project)
189
+ note(f"[dim]{project} is archived[/dim]")
190
+
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
+
208
+ @app.command(name="record-access")
209
+ def record_access(project: str, *, settings: SettingsParam) -> None:
210
+ """Record that you opened a project, which orders `projects recent`.
211
+
212
+ Parameters
213
+ ----------
214
+ project:
215
+ The project you opened.
216
+ """
217
+ settings.client().projects.record_access(project)
218
+ note(f"[dim]{project} is your most recent project[/dim]")
219
+
220
+
221
+ @app.command(name="recent")
222
+ def recent(
223
+ *,
224
+ settings: SettingsParam,
225
+ limit: Annotated[int | None, Parameter(validator=at_least_one)] = None,
226
+ ) -> None:
227
+ """List the projects you opened most recently.
228
+
229
+ Parameters
230
+ ----------
231
+ limit:
232
+ Return this many, instead of the default four.
233
+ """
234
+ emit(settings.client().projects.recent(limit=limit), COLUMNS, as_json=settings.as_json)
@@ -1,48 +0,0 @@
1
- # xoople-cli
2
-
3
- Command-line interface for the Xoople Products API, over [`xoople-sdk`](../xoople-sdk).
4
-
5
- ```console
6
- $ uv tool install xoople-cli
7
- $ export XOOPLE_API_URL=https://api.xoople.com XOOPLE_TOKEN=...
8
- $ xoople analyses list
9
- ```
10
-
11
- Every command reads `--api-url` and `--token` from `XOOPLE_API_URL` and
12
- `XOOPLE_TOKEN`. `--json` prints the API response verbatim instead of a table, so
13
- output pipes into `jq`.
14
-
15
- ## Commands
16
-
17
- | Group | Commands |
18
- | --- | --- |
19
- | `analyses` | `list` `get` `create` `update` `cancel` `estimate` |
20
- | `runs` | `list` `get` |
21
- | `outputs` | `list` `get` `access-url` |
22
- | `schedules` | `list` `get` `create` `pause` `resume` `cancel` `run` `estimate-run` |
23
- | `events` | `list` `get` |
24
- | `usage` | `history` `limits` |
25
- | `catalog` | `products` `measures` `designs` |
26
- | `access` | `me` `token` `features` |
27
-
28
- ## Submitting an analysis
29
-
30
- The product spec is deeply nested and differs per product, so `create` and
31
- `estimate` take it as JSON rather than as flags:
32
-
33
- ```console
34
- $ xoople catalog designs --json | jq '.[0].spec_fragment' > spec.json
35
- $ # fill in period and aoi, then:
36
- $ xoople analyses create -f spec.json --display-name "South Wales NDVI" --wait
37
- ```
38
-
39
- The file holds either the bare spec or a whole create request (anything with a
40
- `spec` key). `-f -` reads stdin. `--wait` polls the initial run and exits
41
- non-zero if it does not reach `COMPLETE`.
42
-
43
- ## Exit codes
44
-
45
- `0` success · `1` unexpected CLI error · `2` usage error · `3` unauthenticated · `4` forbidden ·
46
- `5` not found · `6` bad request or validation · `7` conflict · `8` rate limited ·
47
- `9` server error · `10` connection failure · `11` unreadable response ·
48
- `12` run finished in a non-`COMPLETE` state · `13` `--wait` timed out.
@@ -1,118 +0,0 @@
1
- from typing import Annotated
2
-
3
- from cyclopts import App, Parameter
4
- from xoople.cli._context import SettingsParam, at_least_one
5
- from xoople.cli._output import emit, emit_model, emit_one, note
6
-
7
- app = App(
8
- name="projects",
9
- help="Organise analyses into projects and manage who belongs to them.",
10
- )
11
-
12
- COLUMNS = [
13
- ("UID", "uid"),
14
- ("NAME", "display_name"),
15
- ("DEFAULT", "is_default"),
16
- ("ARCHIVED", "archive_time"),
17
- ]
18
-
19
- FIELDS = [
20
- ("Name", "name"),
21
- ("UID", "uid"),
22
- ("Display name", "display_name"),
23
- ("Default", "is_default"),
24
- ("Archive time", "archive_time"),
25
- ("Create time", "create_time"),
26
- ("Update time", "update_time"),
27
- ]
28
-
29
-
30
- @app.command(name="list")
31
- def list_projects(
32
- *,
33
- settings: SettingsParam,
34
- limit: Annotated[int | None, Parameter(validator=at_least_one)] = None,
35
- ) -> None:
36
- """List your organisation's projects.
37
-
38
- Parameters
39
- ----------
40
- limit:
41
- Stop after this many, instead of every page.
42
- """
43
- emit(settings.client().projects.list(limit=limit), COLUMNS, as_json=settings.as_json)
44
-
45
-
46
- @app.command(name="create")
47
- def create(name: str, *, settings: SettingsParam) -> None:
48
- """Create a project.
49
-
50
- Parameters
51
- ----------
52
- name:
53
- The project's display name.
54
- """
55
- emit_one(settings.client().projects.create(name), FIELDS, as_json=settings.as_json)
56
-
57
-
58
- @app.command(name="add-member")
59
- def add_member(project: str, subject_id: str, *, settings: SettingsParam) -> None:
60
- """Add a subject to a project.
61
-
62
- Parameters
63
- ----------
64
- project:
65
- The project to add to.
66
- subject_id:
67
- The subject to add.
68
- """
69
- emit_model(settings.client().projects.add_member(project, subject_id), as_json=settings.as_json)
70
-
71
-
72
- @app.command(name="remove-member")
73
- def remove_member(project: str, subject: str, *, settings: SettingsParam) -> None:
74
- """Remove a subject from a project.
75
-
76
- Parameters
77
- ----------
78
- project:
79
- The project to remove from.
80
- subject:
81
- The subject to remove.
82
- """
83
- settings.client().projects.remove_member(project, subject)
84
- note(f"[dim]{subject} is no longer a member of {project}[/dim]")
85
-
86
-
87
- @app.command(name="move")
88
- def move(project: str, destination: str, analyses: list[str], *, settings: SettingsParam) -> None:
89
- """Move named analyses into another project, pausing their schedules.
90
-
91
- Parameters
92
- ----------
93
- project:
94
- The project the analyses are in now.
95
- destination:
96
- The project to move them into.
97
- analyses:
98
- The analysis identifiers to move, at most 500 per call.
99
- """
100
- emit_model(
101
- settings.client().projects.move_analyses_to_project(project, analyses, destination),
102
- as_json=settings.as_json,
103
- )
104
-
105
-
106
- @app.command(name="archive")
107
- def archive(project: str, *, settings: SettingsParam) -> None:
108
- """Archive a project and pause its schedules.
109
-
110
- The default project cannot be archived.
111
-
112
- Parameters
113
- ----------
114
- project:
115
- The project to archive.
116
- """
117
- settings.client().projects.archive(project)
118
- note(f"[dim]{project} is archived[/dim]")
File without changes