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.
@@ -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,5 @@
1
+ from xoople.cli._version import version
2
+
3
+ __version__ = version()
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,3 @@
1
+ from xoople.cli.main import main
2
+
3
+ main()
@@ -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
+ )