xoople-cli 0.1.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.1.0/LICENSE +23 -0
- xoople_cli-0.1.0/PKG-INFO +65 -0
- xoople_cli-0.1.0/README.md +48 -0
- xoople_cli-0.1.0/pyproject.toml +43 -0
- xoople_cli-0.1.0/pyproject.toml.orig +53 -0
- xoople_cli-0.1.0/src/xoople/cli/__init__.py +5 -0
- xoople_cli-0.1.0/src/xoople/cli/__main__.py +3 -0
- xoople_cli-0.1.0/src/xoople/cli/_context.py +87 -0
- xoople_cli-0.1.0/src/xoople/cli/_errors.py +75 -0
- xoople_cli-0.1.0/src/xoople/cli/_output.py +137 -0
- xoople_cli-0.1.0/src/xoople/cli/_spec.py +45 -0
- xoople_cli-0.1.0/src/xoople/cli/_version.py +17 -0
- xoople_cli-0.1.0/src/xoople/cli/access.py +47 -0
- xoople_cli-0.1.0/src/xoople/cli/analyses.py +161 -0
- xoople_cli-0.1.0/src/xoople/cli/catalog.py +98 -0
- xoople_cli-0.1.0/src/xoople/cli/events.py +69 -0
- xoople_cli-0.1.0/src/xoople/cli/main.py +105 -0
- xoople_cli-0.1.0/src/xoople/cli/outputs.py +77 -0
- xoople_cli-0.1.0/src/xoople/cli/runs.py +100 -0
- xoople_cli-0.1.0/src/xoople/cli/schedules.py +214 -0
- xoople_cli-0.1.0/src/xoople/cli/usage.py +99 -0
xoople_cli-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Xoople SDK License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Xoople S.L. All rights reserved.
|
|
4
|
+
|
|
5
|
+
This software is the confidential and proprietary property of Xoople S.L.
|
|
6
|
+
|
|
7
|
+
Permission is granted, free of charge, to any person or organisation holding a
|
|
8
|
+
valid subscription to the Xoople Products API to use, copy, and distribute this
|
|
9
|
+
software, and to modify it for their own internal use, solely for the purpose of
|
|
10
|
+
accessing that API.
|
|
11
|
+
|
|
12
|
+
No other rights are granted. In particular, no right is granted to sell,
|
|
13
|
+
sublicense, or otherwise distribute this software or any derivative work as a
|
|
14
|
+
standalone product, or to use it to develop, operate, or market a service that
|
|
15
|
+
competes with the Xoople Products API. The Xoople name and marks are not
|
|
16
|
+
licensed hereunder.
|
|
17
|
+
|
|
18
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
19
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
20
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL XOOPLE S.L. BE
|
|
21
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
|
|
22
|
+
CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
23
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xoople-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Command-line interface for the Xoople Products API.
|
|
5
|
+
License-Expression: LicenseRef-Xoople-Proprietary
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
9
|
+
Requires-Dist: xoople-sdk>=0.2.0
|
|
10
|
+
Requires-Dist: cyclopts>=4.18,<5
|
|
11
|
+
Requires-Dist: rich>=13.7
|
|
12
|
+
Requires-Dist: xoople-sdk[entra]>=0.1.0 ; extra == 'entra'
|
|
13
|
+
Requires-Python: >=3.12
|
|
14
|
+
Project-URL: Homepage, https://xoople.com
|
|
15
|
+
Provides-Extra: entra
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# xoople-cli
|
|
19
|
+
|
|
20
|
+
Command-line interface for the Xoople Products API, over [`xoople-sdk`](../xoople-sdk).
|
|
21
|
+
|
|
22
|
+
```console
|
|
23
|
+
$ uv tool install xoople-cli
|
|
24
|
+
$ export XOOPLE_API_URL=https://api.xoople.com XOOPLE_TOKEN=...
|
|
25
|
+
$ xoople analyses list
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Every command reads `--api-url` and `--token` from `XOOPLE_API_URL` and
|
|
29
|
+
`XOOPLE_TOKEN`. `--json` prints the API response verbatim instead of a table, so
|
|
30
|
+
output pipes into `jq`.
|
|
31
|
+
|
|
32
|
+
## Commands
|
|
33
|
+
|
|
34
|
+
| Group | Commands |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| `analyses` | `list` `get` `create` `update` `cancel` `estimate` |
|
|
37
|
+
| `runs` | `list` `get` |
|
|
38
|
+
| `outputs` | `list` `get` `access-url` |
|
|
39
|
+
| `schedules` | `list` `get` `create` `pause` `resume` `cancel` `run` `estimate-run` |
|
|
40
|
+
| `events` | `list` `get` |
|
|
41
|
+
| `usage` | `history` `limits` |
|
|
42
|
+
| `catalog` | `products` `measures` `designs` |
|
|
43
|
+
| `access` | `me` `token` `features` |
|
|
44
|
+
|
|
45
|
+
## Submitting an analysis
|
|
46
|
+
|
|
47
|
+
The product spec is deeply nested and differs per product, so `create` and
|
|
48
|
+
`estimate` take it as JSON rather than as flags:
|
|
49
|
+
|
|
50
|
+
```console
|
|
51
|
+
$ xoople catalog designs --json | jq '.designs[0].spec_fragment' > spec.json
|
|
52
|
+
$ # fill in period and aoi, then:
|
|
53
|
+
$ xoople analyses create -f spec.json --display-name "South Wales NDVI" --wait
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The file holds either the bare spec or a whole create request (anything with a
|
|
57
|
+
`spec` key). `-f -` reads stdin. `--wait` polls the initial run and exits
|
|
58
|
+
non-zero if it does not reach `COMPLETE`.
|
|
59
|
+
|
|
60
|
+
## Exit codes
|
|
61
|
+
|
|
62
|
+
`0` success · `1` unexpected CLI error · `2` usage error · `3` unauthenticated · `4` forbidden ·
|
|
63
|
+
`5` not found · `6` bad request or validation · `7` conflict · `8` rate limited ·
|
|
64
|
+
`9` server error · `10` connection failure · `11` unreadable response ·
|
|
65
|
+
`12` run finished in a non-`COMPLETE` state · `13` `--wait` timed out.
|
|
@@ -0,0 +1,48 @@
|
|
|
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 '.designs[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.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "xoople-cli"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Command-line interface for the Xoople Products API."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = "LicenseRef-Xoople-Proprietary"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 3 - Alpha",
|
|
11
|
+
"Programming Language :: Python :: 3.12",
|
|
12
|
+
]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"xoople-sdk>=0.2.0",
|
|
15
|
+
"cyclopts>=4.18,<5",
|
|
16
|
+
"rich>=13.7",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
entra = ["xoople-sdk[entra]>=0.1.0"]
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
xoople = "xoople.cli.main:main"
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://xoople.com"
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["uv_build>=0.11.6,<0.12"]
|
|
30
|
+
build-backend = "uv_build"
|
|
31
|
+
|
|
32
|
+
[tool.uv.build-backend]
|
|
33
|
+
module-name = "xoople.cli"
|
|
34
|
+
|
|
35
|
+
[tool.uv.sources.xoople-sdk]
|
|
36
|
+
workspace = true
|
|
37
|
+
|
|
38
|
+
[dependency-groups]
|
|
39
|
+
dev = [
|
|
40
|
+
"ruff>=0.15.7",
|
|
41
|
+
"pytest>=8.0",
|
|
42
|
+
"pytest-cov>=6.0",
|
|
43
|
+
]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "xoople-cli"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Command-line interface for the Xoople Products API."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
# Proprietary: licensed for use against the Xoople Products API, not
|
|
8
|
+
# redistribution. `license-files` ships the text inside the wheel.
|
|
9
|
+
license = "LicenseRef-Xoople-Proprietary"
|
|
10
|
+
license-files = ["LICENSE"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Programming Language :: Python :: 3.12",
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"xoople-sdk>=0.2.0",
|
|
17
|
+
# Cyclopts parses docstrings for parameter help so signatures stay plain,
|
|
18
|
+
# supports union types and Pydantic models natively, and owns its parser.
|
|
19
|
+
# Global options + dependency injection go through the meta-app launcher
|
|
20
|
+
# in `main.py`.
|
|
21
|
+
"cyclopts>=4.18,<5",
|
|
22
|
+
"rich>=13.7",
|
|
23
|
+
]
|
|
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.1.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
xoople = "xoople.cli.main:main"
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://xoople.com"
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["uv_build>=0.11.6,<0.12"]
|
|
40
|
+
build-backend = "uv_build"
|
|
41
|
+
|
|
42
|
+
[tool.uv.build-backend]
|
|
43
|
+
module-name = "xoople.cli"
|
|
44
|
+
|
|
45
|
+
[tool.uv.sources]
|
|
46
|
+
xoople-sdk = { workspace = true }
|
|
47
|
+
|
|
48
|
+
[dependency-groups]
|
|
49
|
+
dev = [
|
|
50
|
+
"ruff>=0.15.7",
|
|
51
|
+
"pytest>=8.0",
|
|
52
|
+
"pytest-cov>=6.0",
|
|
53
|
+
]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
from cyclopts import Parameter
|
|
6
|
+
from xoople.cli._errors import UsageError
|
|
7
|
+
from xoople.sdk import Config, CredentialProvider, XoopleClient
|
|
8
|
+
from xoople.sdk.auth import EntraCredential, StaticToken
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Credentials:
|
|
13
|
+
"""The global options that decide which API and identity a command talks to."""
|
|
14
|
+
|
|
15
|
+
base_url: str | None = None
|
|
16
|
+
token: str | None = None
|
|
17
|
+
entra_scope: str | None = None
|
|
18
|
+
timeout: float = 30.0
|
|
19
|
+
max_retries: int = 3
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# How Credentials become a client — swapped for a fake in tests.
|
|
23
|
+
type ClientFactory = Callable[[Credentials], XoopleClient]
|
|
24
|
+
|
|
25
|
+
# Every command declares this keyword-only parameter; the meta-app launcher in
|
|
26
|
+
# ``main.py`` injects the resolved ``Settings`` via ``parse=False``, so commands
|
|
27
|
+
# stay plain functions and tests drive them over a fake transport.
|
|
28
|
+
type SettingsParam = Annotated[Settings, Parameter(parse=False)]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def at_least_one(_type: object, value: int | None) -> None:
|
|
32
|
+
"""Cyclopts validator for ``--limit``: a sub-1 read pays for nothing and the
|
|
33
|
+
SDK would reject it anyway — fail it at parse time with a usage error."""
|
|
34
|
+
if value is not None and value < 1:
|
|
35
|
+
raise ValueError("must be at least 1")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Settings:
|
|
40
|
+
"""Resolved global options, injected into every command by the meta launcher.
|
|
41
|
+
|
|
42
|
+
The client is built lazily so ``--help`` and argument errors never require
|
|
43
|
+
credentials, and cached so a command making several calls reuses one
|
|
44
|
+
connection pool.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
credentials: Credentials
|
|
48
|
+
factory: ClientFactory
|
|
49
|
+
as_json: bool = False
|
|
50
|
+
_client: XoopleClient | None = field(default=None, repr=False)
|
|
51
|
+
|
|
52
|
+
def client(self) -> XoopleClient:
|
|
53
|
+
if self._client is None:
|
|
54
|
+
self._client = self.factory(self.credentials)
|
|
55
|
+
return self._client
|
|
56
|
+
|
|
57
|
+
def close(self) -> None:
|
|
58
|
+
if self._client is not None:
|
|
59
|
+
self._client.close()
|
|
60
|
+
self._client = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _credential_provider(credentials: Credentials) -> CredentialProvider:
|
|
64
|
+
if credentials.entra_scope:
|
|
65
|
+
return EntraCredential(credentials.entra_scope)
|
|
66
|
+
if not credentials.token:
|
|
67
|
+
raise UsageError("no credentials: pass --token, set XOOPLE_TOKEN, or pass --entra-scope")
|
|
68
|
+
return StaticToken(credentials.token)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def build_client(credentials: Credentials) -> XoopleClient:
|
|
72
|
+
"""Resolve credentials into a client.
|
|
73
|
+
|
|
74
|
+
The environment is read by Cyclopts' ``env_var=`` on the launcher options,
|
|
75
|
+
not here — a second `os.environ` fallback would be unreachable and would
|
|
76
|
+
drift from the names `--help` advertises.
|
|
77
|
+
"""
|
|
78
|
+
if not credentials.base_url:
|
|
79
|
+
raise UsageError("no API URL: pass --api-url or set XOOPLE_API_URL")
|
|
80
|
+
return XoopleClient(
|
|
81
|
+
Config(
|
|
82
|
+
base_url=credentials.base_url,
|
|
83
|
+
credentials=_credential_provider(credentials),
|
|
84
|
+
timeout=credentials.timeout,
|
|
85
|
+
max_retries=credentials.max_retries,
|
|
86
|
+
)
|
|
87
|
+
)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from xoople.cli._output import note
|
|
2
|
+
from xoople.sdk import errors
|
|
3
|
+
|
|
4
|
+
EXIT_CODES: dict[type[errors.XoopleError], int] = {
|
|
5
|
+
errors.AuthenticationError: 3,
|
|
6
|
+
errors.PermissionDeniedError: 4,
|
|
7
|
+
errors.NotFoundError: 5,
|
|
8
|
+
errors.BadRequestError: 6,
|
|
9
|
+
errors.ValidationError: 6,
|
|
10
|
+
errors.ConflictError: 7,
|
|
11
|
+
errors.RateLimitError: 8,
|
|
12
|
+
errors.ServerError: 9,
|
|
13
|
+
errors.XoopleConnectionError: 10,
|
|
14
|
+
errors.XoopleResponseError: 11,
|
|
15
|
+
}
|
|
16
|
+
_FALLBACK_EXIT = 1
|
|
17
|
+
# ``CliError`` with no specific subclass falls back to 1. Nothing raises a bare
|
|
18
|
+
# ``CliError`` today; every CLI-detected outcome has its own subclass below.
|
|
19
|
+
# Outcomes the CLI decides rather than the API. Kept here with the status map so
|
|
20
|
+
# the README's exit-code table has one source.
|
|
21
|
+
USAGE_EXIT = 2
|
|
22
|
+
FAILED_RUN_EXIT = 12
|
|
23
|
+
WAIT_TIMEOUT_EXIT = 13
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CliError(Exception):
|
|
27
|
+
"""A failure the CLI itself detects, reported the same way as an API error."""
|
|
28
|
+
|
|
29
|
+
exit_code = _FALLBACK_EXIT
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class UsageError(CliError):
|
|
33
|
+
"""A bad flag or spec file: the user can fix it and re-run."""
|
|
34
|
+
|
|
35
|
+
exit_code = USAGE_EXIT
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RunFailed(CliError):
|
|
39
|
+
"""``--wait`` finished, but the run ended in a non-``COMPLETE`` state."""
|
|
40
|
+
|
|
41
|
+
exit_code = FAILED_RUN_EXIT
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class WaitTimeout(CliError):
|
|
45
|
+
"""``--wait`` gave up before the run reached a terminal state."""
|
|
46
|
+
|
|
47
|
+
exit_code = WAIT_TIMEOUT_EXIT
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def exit_code(exc: errors.XoopleError) -> int:
|
|
51
|
+
for cls, code in EXIT_CODES.items():
|
|
52
|
+
if isinstance(exc, cls):
|
|
53
|
+
return code
|
|
54
|
+
return _FALLBACK_EXIT
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _describe(exc: errors.XoopleError) -> str:
|
|
58
|
+
if isinstance(exc, errors.XoopleAPIError):
|
|
59
|
+
trace = f" [dim](trace {exc.trace_id})[/dim]" if exc.trace_id else ""
|
|
60
|
+
return f"[red]error[/red] {exc.status} {exc.code}: {exc.message}{trace}"
|
|
61
|
+
return f"[red]error[/red] {exc}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def report(exc: errors.XoopleError | CliError) -> int:
|
|
65
|
+
"""Print an error as one line and return the exit code it maps to.
|
|
66
|
+
|
|
67
|
+
The meta-app launcher in ``main.py`` calls this for every exception that
|
|
68
|
+
escapes a command, so a stack trace never reaches the user and the API
|
|
69
|
+
status returns as an exit code instead of a generic 1.
|
|
70
|
+
"""
|
|
71
|
+
if isinstance(exc, errors.XoopleError):
|
|
72
|
+
note(_describe(exc))
|
|
73
|
+
return exit_code(exc)
|
|
74
|
+
note(f"[red]error[/red] {exc}")
|
|
75
|
+
return exc.exit_code
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from collections.abc import Iterable, Sequence
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
type Column = tuple[str, str]
|
|
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.
|
|
16
|
+
_FRACTION_FIELDS = frozenset({"fraction", "estimate_confidence"})
|
|
17
|
+
|
|
18
|
+
# Piped output must not depend on the terminal that happened to launch it, so a
|
|
19
|
+
# non-tty stdout gets a width no table will wrap at. On a terminal, cells
|
|
20
|
+
# truncate instead of folding: half a UUID on each of two lines cannot be copied.
|
|
21
|
+
_PIPED_WIDTH = 10_000
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _console_for(*, stderr: bool) -> Console:
|
|
25
|
+
# `is_terminal` rather than `isatty()`: it also honours TERM=dumb, NO_COLOR
|
|
26
|
+
# and FORCE_COLOR, which a caller redirecting output may well be setting.
|
|
27
|
+
console = Console(stderr=stderr)
|
|
28
|
+
if not console.is_terminal:
|
|
29
|
+
console.width = _PIPED_WIDTH
|
|
30
|
+
return console
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_console = _console_for(stderr=False)
|
|
34
|
+
# Messages must stay greppable too: an error split across lines defeats
|
|
35
|
+
# `2>&1 | grep`, and a wrapped URL cannot be clicked.
|
|
36
|
+
_stderr = _console_for(stderr=True)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _scalar(value: object) -> str:
|
|
40
|
+
if value is None:
|
|
41
|
+
return "-"
|
|
42
|
+
if isinstance(value, bool):
|
|
43
|
+
return "yes" if value else "no"
|
|
44
|
+
# Credit and runtime figures carry full float precision; 15 decimal places of
|
|
45
|
+
# a unit count is noise in a table. `--json` is unaffected — it dumps the
|
|
46
|
+
# model, so anything doing arithmetic still gets the exact value.
|
|
47
|
+
if isinstance(value, float):
|
|
48
|
+
return f"{value:.2f}"
|
|
49
|
+
if isinstance(value, BaseModel):
|
|
50
|
+
return _scalar(value.model_dump(mode="json"))
|
|
51
|
+
if isinstance(value, dict):
|
|
52
|
+
return json.dumps(value, default=str)
|
|
53
|
+
if isinstance(value, list | tuple):
|
|
54
|
+
return ", ".join(_scalar(item) for item in value)
|
|
55
|
+
return str(value)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _percent(value: object) -> str:
|
|
59
|
+
return "-" if not isinstance(value, int | float) else f"{value:.0%}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _dotted(model: BaseModel, path: str) -> object:
|
|
63
|
+
"""Read ``a.b.c`` off nested models, treating a missing link as ``None``."""
|
|
64
|
+
current: object = model
|
|
65
|
+
for part in path.split("."):
|
|
66
|
+
if current is None:
|
|
67
|
+
return None
|
|
68
|
+
current = getattr(current, part, None)
|
|
69
|
+
return current
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _cell(model: BaseModel, column: Column) -> str:
|
|
73
|
+
_, path = column
|
|
74
|
+
value = _dotted(model, path)
|
|
75
|
+
leaf = path.rsplit(".", 1)[-1]
|
|
76
|
+
return _percent(value) if leaf in _FRACTION_FIELDS else _scalar(value)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def emit(models: Iterable[BaseModel], columns: Sequence[Column], *, as_json: bool) -> None:
|
|
80
|
+
"""Render a collection: raw JSON array, or a table of ``(header, path)`` columns."""
|
|
81
|
+
items = list(models)
|
|
82
|
+
if as_json:
|
|
83
|
+
_raw_json([item.model_dump(mode="json", by_alias=True) for item in items])
|
|
84
|
+
return
|
|
85
|
+
if not items:
|
|
86
|
+
_stderr.print("[dim]no matching items[/dim]")
|
|
87
|
+
return
|
|
88
|
+
table = Table(box=None, pad_edge=False, header_style="bold")
|
|
89
|
+
for column in columns:
|
|
90
|
+
table.add_column(column[0], overflow="ellipsis", no_wrap=True)
|
|
91
|
+
for item in items:
|
|
92
|
+
table.add_row(*(_cell(item, column) for column in columns))
|
|
93
|
+
_console.print(table)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def emit_one(model: BaseModel, fields: Sequence[Column], *, as_json: bool) -> None:
|
|
97
|
+
"""Render a single resource: raw JSON object, or the named fields as rows."""
|
|
98
|
+
if as_json:
|
|
99
|
+
_raw_json(model.model_dump(mode="json", by_alias=True))
|
|
100
|
+
return
|
|
101
|
+
table = Table(box=None, pad_edge=False, show_header=False)
|
|
102
|
+
table.add_column(style="bold")
|
|
103
|
+
table.add_column(overflow="fold")
|
|
104
|
+
for field in fields:
|
|
105
|
+
table.add_row(field[0], _cell(model, field))
|
|
106
|
+
_console.print(table)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def emit_model(model: BaseModel, *, as_json: bool) -> None:
|
|
110
|
+
"""Render every declared field of a single resource.
|
|
111
|
+
|
|
112
|
+
For responses whose shape varies by product — the estimate union, whose
|
|
113
|
+
variants share almost no fields — deriving rows from the model beats a
|
|
114
|
+
hardcoded list that would be wrong for one of them.
|
|
115
|
+
"""
|
|
116
|
+
fields = [(name.replace("_", " ").capitalize(), name) for name in type(model).model_fields]
|
|
117
|
+
emit_one(model, fields, as_json=as_json)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def heading(text: str) -> None:
|
|
121
|
+
"""A section label for a command that prints more than one table."""
|
|
122
|
+
_console.print(f"\n[bold]{text}[/bold]")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _raw_json(payload: object) -> None:
|
|
126
|
+
"""Print JSON on stdout, unstyled.
|
|
127
|
+
|
|
128
|
+
Deliberately not rich's ``print_json``: it soft-wraps to the console width,
|
|
129
|
+
which breaks a long value across lines and can leave the piped output
|
|
130
|
+
unparseable. ``--json`` exists to be piped, so it stays plain.
|
|
131
|
+
"""
|
|
132
|
+
print(json.dumps(payload, indent=2, default=str))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def note(message: str) -> None:
|
|
136
|
+
"""Progress and confirmation text — stderr, so ``--json`` stdout stays parseable."""
|
|
137
|
+
_stderr.print(message)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic import ValidationError
|
|
6
|
+
from xoople.cli._errors import UsageError
|
|
7
|
+
from xoople.sdk import create_request_from, models
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _read(path: Path) -> object:
|
|
11
|
+
raw = sys.stdin.read() if str(path) == "-" else path.read_text()
|
|
12
|
+
try:
|
|
13
|
+
return json.loads(raw)
|
|
14
|
+
except ValueError as exc:
|
|
15
|
+
source = "stdin" if str(path) == "-" else str(path)
|
|
16
|
+
raise UsageError(f"{source} is not valid JSON: {exc}") from exc
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _explain(exc: ValidationError) -> str:
|
|
20
|
+
lines = []
|
|
21
|
+
for error in exc.errors():
|
|
22
|
+
location = ".".join(str(part) for part in error["loc"]) or "(root)"
|
|
23
|
+
lines.append(f" {location}: {error['msg']}")
|
|
24
|
+
return "the spec does not match the API schema:\n" + "\n".join(lines)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_create_request(
|
|
28
|
+
path: Path, display_name: str | None = None
|
|
29
|
+
) -> models.CreateAnalysisRequest:
|
|
30
|
+
"""Read a create/estimate body from a JSON file (``-`` for stdin).
|
|
31
|
+
|
|
32
|
+
Which document shapes are accepted is the SDK's rule, not the CLI's; this
|
|
33
|
+
adds only what a CLI owes its user — reading the file and turning a
|
|
34
|
+
validation error into a message that points at the offending field.
|
|
35
|
+
"""
|
|
36
|
+
document = _read(path)
|
|
37
|
+
if not isinstance(document, dict):
|
|
38
|
+
raise UsageError("the spec file must contain a JSON object")
|
|
39
|
+
# A JSON object's keys are strings by construction; `json.loads` just does not
|
|
40
|
+
# say so in its return type.
|
|
41
|
+
body: dict[str, object] = {str(key): value for key, value in document.items()}
|
|
42
|
+
try:
|
|
43
|
+
return create_request_from(body, display_name)
|
|
44
|
+
except ValidationError as exc:
|
|
45
|
+
raise UsageError(_explain(exc)) from exc
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from functools import cache
|
|
2
|
+
from importlib.metadata import PackageNotFoundError
|
|
3
|
+
from importlib.metadata import version as _distribution_version
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@cache
|
|
7
|
+
def version() -> str:
|
|
8
|
+
"""The installed distribution version, reported by ``xoople --version``.
|
|
9
|
+
|
|
10
|
+
Read from installed metadata so ``pyproject.toml`` stays the only place the
|
|
11
|
+
version is written. Falls back to ``0.0.0`` when the package is imported
|
|
12
|
+
from a source tree that was never installed.
|
|
13
|
+
"""
|
|
14
|
+
try:
|
|
15
|
+
return _distribution_version("xoople-cli")
|
|
16
|
+
except PackageNotFoundError:
|
|
17
|
+
return "0.0.0"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from cyclopts import App
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
from xoople.cli._context import SettingsParam
|
|
4
|
+
from xoople.cli._output import emit, emit_model
|
|
5
|
+
|
|
6
|
+
app = App(
|
|
7
|
+
name="access",
|
|
8
|
+
help="Delta Sharing credentials and feature flags for your account.",
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _FlagRow(BaseModel):
|
|
13
|
+
"""A flat row for the flag map, which is a dict rather than a list on the wire."""
|
|
14
|
+
|
|
15
|
+
flag: str
|
|
16
|
+
value: bool
|
|
17
|
+
reason: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command(name="me")
|
|
21
|
+
def me(*, settings: SettingsParam) -> None:
|
|
22
|
+
"""Show your Delta Sharing recipient and the share it reads."""
|
|
23
|
+
emit_model(settings.client().data_access.me(), as_json=settings.as_json)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command(name="token")
|
|
27
|
+
def token(*, settings: SettingsParam) -> None:
|
|
28
|
+
"""Print your Delta Sharing bearer token and host."""
|
|
29
|
+
emit_model(settings.client().data_access.token(), as_json=settings.as_json)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.command(name="features")
|
|
33
|
+
def features(*, settings: SettingsParam) -> None:
|
|
34
|
+
"""Show which feature flags are on for you, and why."""
|
|
35
|
+
result = settings.client().features.get()
|
|
36
|
+
if settings.as_json:
|
|
37
|
+
emit_model(result, as_json=True)
|
|
38
|
+
return
|
|
39
|
+
flags = [
|
|
40
|
+
_FlagRow(flag=name, value=flag.value, reason=flag.reason)
|
|
41
|
+
for name, flag in result.flags.items()
|
|
42
|
+
]
|
|
43
|
+
emit(
|
|
44
|
+
flags,
|
|
45
|
+
[("FLAG", "flag"), ("ON", "value"), ("REASON", "reason")],
|
|
46
|
+
as_json=False,
|
|
47
|
+
)
|