xmemcli 0.0.1__py3-none-any.whl
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.
- xmemcli/__init__.py +3 -0
- xmemcli/_envelope.py +24 -0
- xmemcli/_help.py +720 -0
- xmemcli/_instance_schema.py +769 -0
- xmemcli/_login.py +593 -0
- xmemcli/_org_cluster.py +70 -0
- xmemcli/_output.py +274 -0
- xmemcli/_pretty.py +373 -0
- xmemcli/_rc.py +192 -0
- xmemcli/_version.py +4 -0
- xmemcli/_xmem_bridge.py +33 -0
- xmemcli/cli.py +1826 -0
- xmemcli-0.0.1.dist-info/METADATA +22 -0
- xmemcli-0.0.1.dist-info/RECORD +16 -0
- xmemcli-0.0.1.dist-info/WHEEL +4 -0
- xmemcli-0.0.1.dist-info/entry_points.txt +2 -0
xmemcli/_output.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Tagged-union output payload + dispatch shared by every ``xmem`` subcommand.
|
|
2
|
+
|
|
3
|
+
Why this lives in its own module
|
|
4
|
+
--------------------------------
|
|
5
|
+
|
|
6
|
+
The CLI has two output personalities that the user-facing surface needs to
|
|
7
|
+
keep strictly separated:
|
|
8
|
+
|
|
9
|
+
1. **Machine consumption** — any time stdout is *not* attached to a terminal
|
|
10
|
+
(a pipe, a shell redirect, CI capture, the Playwright runner reading
|
|
11
|
+
``result.stdout``), or any time the human explicitly passes ``--json``.
|
|
12
|
+
In that mode every command MUST emit **exactly one** JSON document on
|
|
13
|
+
stdout, so a downstream consumer can rely on
|
|
14
|
+
``xmem read … | jq .reader_result`` working unconditionally.
|
|
15
|
+
2. **Human consumption** — the operator typed the command into an
|
|
16
|
+
interactive shell and did **not** pass ``--json``. In that mode the CLI
|
|
17
|
+
is allowed to format the payload into a short, opinionated, one-line (or
|
|
18
|
+
nearly-one-line) summary keyed off the *kind* of response.
|
|
19
|
+
|
|
20
|
+
To keep the dispatch logic in one place and force every new subcommand to
|
|
21
|
+
declare which family of output it produces, the commands no longer print
|
|
22
|
+
directly. They build a :class:`CliOutput` (a discriminated payload tagged
|
|
23
|
+
with an :class:`OutputKind`), and the entry point in ``cli/xmem.py`` decides
|
|
24
|
+
how to render it via :func:`emit`. The pretty-print branch lives in
|
|
25
|
+
``cli/_pretty.py`` so the formatting code can grow without cluttering the
|
|
26
|
+
HTTP and argparse plumbing.
|
|
27
|
+
|
|
28
|
+
Invariants
|
|
29
|
+
----------
|
|
30
|
+
|
|
31
|
+
- The default for any non-TTY stdout is JSON. There is no other default.
|
|
32
|
+
- ``--json`` forces JSON even on a TTY — always, unconditionally.
|
|
33
|
+
- Pretty-print is only ever reached when ``stdout.isatty()`` is true AND
|
|
34
|
+
``--json`` was not passed. Anything else gets JSON.
|
|
35
|
+
- ``$XMEM_FORCE_TTY_OUTPUT_NOT_JSON`` is a **deliberate, documented escape hatch**
|
|
36
|
+
for manual E2E runbooks only (see :data:`ENV_FORCE_TTY_OUTPUT_NOT_JSON`). It does
|
|
37
|
+
not change server behavior, credentials, or API payloads — only how ``emit`` chooses
|
|
38
|
+
between JSON and pretty text. Agents, CI, and piped scripts must never set it;
|
|
39
|
+
they use ``--json`` or a real TTY. If unset (the normal case), the non-TTY⇒JSON
|
|
40
|
+
rule is unchanged.
|
|
41
|
+
- A failed command still emits exactly one JSON document on stdout when JSON
|
|
42
|
+
mode is in effect — :class:`OutputKind.ERROR` + a non-zero exit code. We
|
|
43
|
+
do not silently dump human prose to stderr in machine mode.
|
|
44
|
+
- The pretty-print renderers may fall back to JSON if they can't extract a
|
|
45
|
+
useful summary from the payload; that is intentional and keeps human
|
|
46
|
+
output truthful rather than guessing.
|
|
47
|
+
|
|
48
|
+
Wire format
|
|
49
|
+
-----------
|
|
50
|
+
|
|
51
|
+
The JSON document emitted by :func:`emit` is the CURATED payload built by
|
|
52
|
+
the subcommand (see the ``_curate_*`` helpers in ``cli/xmem.py``) — the
|
|
53
|
+
fields the user asked for, with server internals (SQL, diff plans, raw
|
|
54
|
+
extraction trees) stripped in both output modes. We do NOT wrap it in a
|
|
55
|
+
second envelope keyed by :class:`OutputKind` — adding a layer the consumer
|
|
56
|
+
didn't ask for would make scripts more fragile, not less. The kind is only
|
|
57
|
+
used internally for pretty-print dispatch.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
from __future__ import annotations
|
|
61
|
+
|
|
62
|
+
import json
|
|
63
|
+
import os
|
|
64
|
+
import sys
|
|
65
|
+
from enum import StrEnum
|
|
66
|
+
from typing import IO, Any, NamedTuple
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class OutputKind(StrEnum):
|
|
70
|
+
"""Discriminator for what kind of payload a command produced.
|
|
71
|
+
|
|
72
|
+
Used purely for pretty-print dispatch. Has no effect on the JSON output —
|
|
73
|
+
the same enum value can produce wildly different JSON depending on the
|
|
74
|
+
server payload, and that's fine.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
#: Synchronous write that completed (``POST …/write``). Curated payload:
|
|
78
|
+
#: ``write_id`` + ``extracted`` (compact ``Type(name)`` tags).
|
|
79
|
+
WRITE_SYNC = "write_sync"
|
|
80
|
+
|
|
81
|
+
#: Asynchronous write that was queued (``POST …/write_async``). Curated
|
|
82
|
+
#: payload: ``write_id``; the caller must poll.
|
|
83
|
+
WRITE_ASYNC = "write_async"
|
|
84
|
+
|
|
85
|
+
#: Terminal result of an ``async_write_status`` poll. Curated payload:
|
|
86
|
+
#: ``write_id`` + ``write_status`` in {``completed``, ``failed``,
|
|
87
|
+
#: ``not_found``} (+ ``error_detail`` / ``timed_out`` when applicable).
|
|
88
|
+
WRITE_STATUS = "write_status"
|
|
89
|
+
|
|
90
|
+
#: Read in ``mode=single-answer``. Curated payload: ``answer`` (falls
|
|
91
|
+
#: back to the reader_result subtree when no answer string exists).
|
|
92
|
+
READ_SINGLE = "read_single"
|
|
93
|
+
|
|
94
|
+
#: Read in ``mode=raw-tables``. Curated payload: the reader_result
|
|
95
|
+
#: subtree (columns + rows). Pretty-print renders as JSON because there
|
|
96
|
+
#: is no useful one-line summary.
|
|
97
|
+
READ_RAW = "read_raw"
|
|
98
|
+
|
|
99
|
+
#: Read in ``mode=xresponse``. Curated payload: the reader_result
|
|
100
|
+
#: subtree shaped as an XResponse (objects + relations). Pretty-print
|
|
101
|
+
#: renders as JSON for the same reason as :attr:`READ_RAW`.
|
|
102
|
+
READ_XRESPONSE = "read_xresponse"
|
|
103
|
+
|
|
104
|
+
#: Output of ``xmemcli org list keys``. Curated payload: a list of
|
|
105
|
+
#: ``{name, key_prefix, is_active}`` entries (full DTOs with
|
|
106
|
+
#: ``--verbose``).
|
|
107
|
+
KEY_LIST = "key_list"
|
|
108
|
+
|
|
109
|
+
#: Output of ``xmemcli org list clusters``.
|
|
110
|
+
CLUSTER_LIST = "cluster_list"
|
|
111
|
+
|
|
112
|
+
#: Output of ``xmem auth status``. Curated payload:
|
|
113
|
+
#: ``{authenticated: bool, key_prefix, email?}`` — whose key the CLI is
|
|
114
|
+
#: holding, resolved via ``/whoami``.
|
|
115
|
+
AUTH_STATUS = "auth_status"
|
|
116
|
+
|
|
117
|
+
#: Output of ``xmem auth login`` / ``xmem auth logout``.
|
|
118
|
+
LOGIN = "login"
|
|
119
|
+
|
|
120
|
+
#: Output of ``xmem version``.
|
|
121
|
+
VERSION = "version"
|
|
122
|
+
|
|
123
|
+
#: Local-only release health (``xmem status``) — no network.
|
|
124
|
+
STATUS = "status"
|
|
125
|
+
|
|
126
|
+
#: Output of ``xmemcli quota``. Curated payload: usage windows.
|
|
127
|
+
QUOTA = "quota"
|
|
128
|
+
|
|
129
|
+
#: Output of ``xmemcli org list instances`` (and ``instance list`` alias).
|
|
130
|
+
INSTANCE_LIST = "instance_list"
|
|
131
|
+
|
|
132
|
+
#: Output of ``xmem instance get``.
|
|
133
|
+
INSTANCE_GET = "instance_get"
|
|
134
|
+
|
|
135
|
+
#: Output of ``xmem instance create``.
|
|
136
|
+
INSTANCE_CREATE = "instance_create"
|
|
137
|
+
|
|
138
|
+
#: Output of ``xmem instance delete``.
|
|
139
|
+
INSTANCE_DELETE = "instance_delete"
|
|
140
|
+
|
|
141
|
+
#: Output of ``xmem schema update``.
|
|
142
|
+
SCHEMA_UPDATE = "schema_update"
|
|
143
|
+
|
|
144
|
+
#: Output of ``xmem schema dry-run``.
|
|
145
|
+
SCHEMA_DRY_RUN = "schema_dry_run"
|
|
146
|
+
|
|
147
|
+
#: Output of ``xmem schema migrations list`` / ``get``.
|
|
148
|
+
SCHEMA_MIGRATIONS = "schema_migrations"
|
|
149
|
+
|
|
150
|
+
#: Output of ``xmem xmd generate`` / ``enhance`` / ``validate`` / ``schema get``.
|
|
151
|
+
XMD_SCHEMA = "xmd_schema"
|
|
152
|
+
|
|
153
|
+
#: Output of ``xmem xmd enhance`` when a migration plan is included.
|
|
154
|
+
XMD_ENHANCE = "xmd_enhance"
|
|
155
|
+
|
|
156
|
+
#: Output of ``xmem xmd convert``.
|
|
157
|
+
XMD_CONVERT = "xmd_convert"
|
|
158
|
+
|
|
159
|
+
#: Structured help document from ``build_help_document`` (overview, options,
|
|
160
|
+
#: command topic, or unknown topic). Emitted when ``--json`` is set or stdout
|
|
161
|
+
#: is not a TTY — same JSON-by-default rule as data commands.
|
|
162
|
+
HELP = "help"
|
|
163
|
+
|
|
164
|
+
#: A failure surfaced as structured output. ``payload`` carries
|
|
165
|
+
#: ``{"error": str, "status": int|null, "stage": str}`` — see
|
|
166
|
+
#: :func:`make_error`.
|
|
167
|
+
ERROR = "error"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class CliOutput(NamedTuple):
|
|
171
|
+
"""A command's structured result, ready for either renderer.
|
|
172
|
+
|
|
173
|
+
A ``typing.NamedTuple`` (immutable, typed, attribute-accessed) rather than
|
|
174
|
+
Pydantic or ``@dataclass``: this CLI is stdlib-only and standalone — it runs
|
|
175
|
+
without a ``pip install`` of the ``xmemory`` library (see ``cli/xmem.py``).
|
|
176
|
+
|
|
177
|
+
Construction is cheap; the only invariant is that ``payload`` must be
|
|
178
|
+
JSON-serialisable (dict / list / str / int / float / bool / None).
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
kind: OutputKind
|
|
182
|
+
payload: Any
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def make_error(message: str, *, status: int | None = None, stage: str = "request") -> CliOutput:
|
|
186
|
+
"""Construct an :class:`OutputKind.ERROR` payload.
|
|
187
|
+
|
|
188
|
+
``status`` is the HTTP status code when the failure originated from a
|
|
189
|
+
server response; ``stage`` is a free-form label naming the step that
|
|
190
|
+
failed (``"request"``, ``"parse"``, ``"validate"``…). The pretty-print
|
|
191
|
+
renderer uses ``stage`` to decide how loudly to scream.
|
|
192
|
+
"""
|
|
193
|
+
return CliOutput(
|
|
194
|
+
OutputKind.ERROR,
|
|
195
|
+
{"error": message, "status": status, "stage": stage},
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# Deliberate escape hatch for manual harness runs with captured stdout — not for CI/scripts.
|
|
200
|
+
# See README.md ("Output personality"). ``--json`` still wins when passed.
|
|
201
|
+
ENV_FORCE_TTY_OUTPUT_NOT_JSON = "XMEM_FORCE_TTY_OUTPUT_NOT_JSON"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _env_tty_override() -> bool | None:
|
|
205
|
+
raw = os.environ.get(ENV_FORCE_TTY_OUTPUT_NOT_JSON, "").strip().lower()
|
|
206
|
+
if not raw:
|
|
207
|
+
return None
|
|
208
|
+
if raw in ("1", "true", "yes", "on"):
|
|
209
|
+
return True
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def stdout_is_tty() -> bool:
|
|
214
|
+
"""``True`` iff stdout is connected to an interactive terminal.
|
|
215
|
+
|
|
216
|
+
Wrapped in a function (not captured at module import time) so unit tests
|
|
217
|
+
can monkey-patch ``sys.stdout`` between cases without surprising us.
|
|
218
|
+
Returns ``False`` defensively if ``isatty`` raises — some pseudo-streams
|
|
219
|
+
don't implement it.
|
|
220
|
+
|
|
221
|
+
``$XMEM_FORCE_TTY_OUTPUT_NOT_JSON`` — when set to a truthy value, pretend stdout is a
|
|
222
|
+
TTY so :func:`emit` uses pretty one-liners on piped/captured stdout. Manual Case 4
|
|
223
|
+
only; see :data:`ENV_FORCE_TTY_OUTPUT_NOT_JSON`. Does not apply when ``force_json``
|
|
224
|
+
is true (``--json``).
|
|
225
|
+
"""
|
|
226
|
+
override = _env_tty_override()
|
|
227
|
+
if override is not None:
|
|
228
|
+
return override
|
|
229
|
+
try:
|
|
230
|
+
return sys.stdout.isatty()
|
|
231
|
+
except Exception:
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def emit(
|
|
236
|
+
out: CliOutput,
|
|
237
|
+
*,
|
|
238
|
+
force_json: bool,
|
|
239
|
+
is_tty: bool | None = None,
|
|
240
|
+
stream: IO[str] | None = None,
|
|
241
|
+
) -> None:
|
|
242
|
+
"""Render ``out`` to stdout (or ``stream``) using the JSON-by-default rule.
|
|
243
|
+
|
|
244
|
+
Parameters
|
|
245
|
+
----------
|
|
246
|
+
out
|
|
247
|
+
The structured payload produced by a subcommand.
|
|
248
|
+
force_json
|
|
249
|
+
``True`` when ``--json`` was passed. Pretty-print is suppressed
|
|
250
|
+
unconditionally and the renderer emits exactly one JSON document.
|
|
251
|
+
is_tty
|
|
252
|
+
Override for :func:`stdout_is_tty`. Tests can pass an explicit
|
|
253
|
+
boolean to exercise both branches without monkey-patching.
|
|
254
|
+
stream
|
|
255
|
+
Defaults to ``sys.stdout``. Tests pass ``io.StringIO()`` here to
|
|
256
|
+
capture output deterministically.
|
|
257
|
+
|
|
258
|
+
The JSON branch always terminates with a newline so log scrapers and
|
|
259
|
+
JSON-lines consumers behave identically. The pretty-print branch
|
|
260
|
+
delegates entirely to ``cli/_pretty.py`` and is only reached when the
|
|
261
|
+
user is sitting at a terminal AND did not opt into JSON.
|
|
262
|
+
"""
|
|
263
|
+
out_stream = stream if stream is not None else sys.stdout
|
|
264
|
+
tty = stdout_is_tty() if is_tty is None else is_tty
|
|
265
|
+
if force_json or not tty:
|
|
266
|
+
json.dump(out.payload, out_stream, indent=2, default=str)
|
|
267
|
+
out_stream.write("\n")
|
|
268
|
+
return
|
|
269
|
+
# Lazy import so the JSON path stays import-cheap (the CLI is invoked
|
|
270
|
+
# hundreds of times per Playwright recording). Absolute import works
|
|
271
|
+
# because xmem.py runs as a script — its directory is on sys.path[0].
|
|
272
|
+
from . import _pretty # noqa: PLC0415
|
|
273
|
+
|
|
274
|
+
_pretty.render(out.kind, out.payload, out_stream)
|
xmemcli/_pretty.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""Human-friendly renderers for :mod:`_output` payloads.
|
|
2
|
+
|
|
3
|
+
This module is **only** reached when the operator is sitting at a real
|
|
4
|
+
terminal (``sys.stdout.isatty()``) AND did not pass ``--json``. Every other
|
|
5
|
+
invocation gets strict JSON via :func:`_output.emit` — see that module's
|
|
6
|
+
docstring for the rationale.
|
|
7
|
+
|
|
8
|
+
The renderers are deliberately small, opinionated, and one-shot: they print
|
|
9
|
+
a single short summary (often a single line) keyed off the
|
|
10
|
+
:class:`~cli._output.OutputKind`. The principle is "give the operator the
|
|
11
|
+
headline they would have wanted to read aloud", not "render the entire
|
|
12
|
+
payload". If the renderer can't extract a useful headline, it falls through
|
|
13
|
+
to a JSON dump of the relevant sub-tree so the user still sees something
|
|
14
|
+
truthful rather than a guess.
|
|
15
|
+
|
|
16
|
+
Adding a new :class:`~cli._output.OutputKind` requires adding a renderer
|
|
17
|
+
here keyed by the enum value in :data:`_RENDERERS`. The default handler
|
|
18
|
+
emits the JSON payload, which is a safe (if uninspired) fallback.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
from enum import Enum
|
|
25
|
+
from typing import IO, Any, Callable
|
|
26
|
+
|
|
27
|
+
from ._version import CLI_NAME
|
|
28
|
+
|
|
29
|
+
# Type alias so the dispatch table is self-documenting.
|
|
30
|
+
Renderer = Callable[[Any, IO[str]], None]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def render(kind: str | Enum, payload: Any, stream: IO[str]) -> None:
|
|
34
|
+
"""Dispatch to a renderer by :class:`OutputKind`.
|
|
35
|
+
|
|
36
|
+
Unknown kinds (or any future kind without a registered renderer) fall
|
|
37
|
+
through to :func:`_default_handler`, which dumps the payload as JSON.
|
|
38
|
+
"""
|
|
39
|
+
dispatch_key = kind.value if isinstance(kind, Enum) else kind
|
|
40
|
+
handler = _RENDERERS.get(dispatch_key, _default_handler)
|
|
41
|
+
handler(payload, stream)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ── Per-kind renderers ────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _render_write_sync(payload: Any, stream: IO[str]) -> None:
|
|
48
|
+
"""One-liner for a completed synchronous write.
|
|
49
|
+
|
|
50
|
+
The headline is "write <id> ok · extracted: <obj-list>" — the curated
|
|
51
|
+
payload already reduced the extraction tree to compact ``Type(name)``
|
|
52
|
+
tags (see ``_summarize_extracted`` in ``cli/xmem.py``).
|
|
53
|
+
"""
|
|
54
|
+
if not isinstance(payload, dict):
|
|
55
|
+
_default_handler(payload, stream)
|
|
56
|
+
return
|
|
57
|
+
wid = payload.get("write_id", "?")
|
|
58
|
+
parts = [p for p in (payload.get("extracted") or []) if isinstance(p, str)]
|
|
59
|
+
extracted = ", ".join(parts) or "(no objects extracted)"
|
|
60
|
+
stream.write(f"write {wid} ok · extracted: {extracted}\n")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _render_write_async(payload: Any, stream: IO[str]) -> None:
|
|
64
|
+
"""Async write was queued; surface the write_id for follow-up polling."""
|
|
65
|
+
if not isinstance(payload, dict):
|
|
66
|
+
_default_handler(payload, stream)
|
|
67
|
+
return
|
|
68
|
+
stream.write(f"write queued: {payload.get('write_id', '?')}\n")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _render_write_status(payload: Any, stream: IO[str]) -> None:
|
|
72
|
+
"""Terminal poll result — short status line, no body."""
|
|
73
|
+
if not isinstance(payload, dict):
|
|
74
|
+
_default_handler(payload, stream)
|
|
75
|
+
return
|
|
76
|
+
wid = payload.get("write_id", "?")
|
|
77
|
+
status = payload.get("write_status", "unknown")
|
|
78
|
+
stream.write(f"{wid}: {status}\n")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _render_read_single(payload: Any, stream: IO[str]) -> None:
|
|
82
|
+
"""Single-answer read — the answer text *is* the headline.
|
|
83
|
+
|
|
84
|
+
The curated payload carries ``answer`` when the reader produced one;
|
|
85
|
+
otherwise it is the reader_result subtree, which we dump as JSON.
|
|
86
|
+
Better to show structure than to invent.
|
|
87
|
+
"""
|
|
88
|
+
if not isinstance(payload, dict):
|
|
89
|
+
_default_handler(payload, stream)
|
|
90
|
+
return
|
|
91
|
+
answer = payload.get("answer")
|
|
92
|
+
if isinstance(answer, str) and answer.strip():
|
|
93
|
+
stream.write(answer.strip() + "\n")
|
|
94
|
+
return
|
|
95
|
+
json.dump(payload, stream, indent=2, default=str)
|
|
96
|
+
stream.write("\n")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _render_read_structured(payload: Any, stream: IO[str]) -> None:
|
|
100
|
+
"""Raw-tables and xresponse reads — there is no useful one-line summary.
|
|
101
|
+
|
|
102
|
+
The curated payload IS the rows / xresponse the operator explicitly
|
|
103
|
+
asked for; dump it as JSON.
|
|
104
|
+
"""
|
|
105
|
+
json.dump(payload, stream, indent=2, default=str)
|
|
106
|
+
stream.write("\n")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _render_key_list(payload: Any, stream: IO[str]) -> None:
|
|
110
|
+
"""``xmemcli org list keys`` — table of prefix / name / active.
|
|
111
|
+
|
|
112
|
+
Payload is the raw API items list (the envelope ``items`` field).
|
|
113
|
+
"""
|
|
114
|
+
if not isinstance(payload, list):
|
|
115
|
+
_default_handler(payload, stream)
|
|
116
|
+
return
|
|
117
|
+
if not payload:
|
|
118
|
+
stream.write("(no keys)\n")
|
|
119
|
+
return
|
|
120
|
+
for k in payload:
|
|
121
|
+
if not isinstance(k, dict):
|
|
122
|
+
continue
|
|
123
|
+
prefix = k.get("key_prefix", "????????")
|
|
124
|
+
name = k.get("name", "(unnamed)")
|
|
125
|
+
active = k.get("is_active", "?")
|
|
126
|
+
stream.write(f"{prefix}… {name} active={active}\n")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _render_auth_status(payload: Any, stream: IO[str]) -> None:
|
|
130
|
+
"""``xmem auth status`` — one line on whose key the CLI is holding.
|
|
131
|
+
|
|
132
|
+
Payload is ``{authenticated, key_prefix, email?}``.
|
|
133
|
+
"""
|
|
134
|
+
if not isinstance(payload, dict):
|
|
135
|
+
_default_handler(payload, stream)
|
|
136
|
+
return
|
|
137
|
+
prefix = payload.get("key_prefix") or "????????"
|
|
138
|
+
if payload.get("authenticated"):
|
|
139
|
+
email = payload.get("email") or "(unknown)"
|
|
140
|
+
stream.write(f"authenticated as {email} (key {prefix}…)\n")
|
|
141
|
+
elif payload.get("logged_in") is False:
|
|
142
|
+
stream.write("not logged in\n")
|
|
143
|
+
else:
|
|
144
|
+
stream.write(f"not authenticated — key {prefix}… is not recognized (unknown, inactive, or expired)\n")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _render_login(payload: Any, stream: IO[str]) -> None:
|
|
148
|
+
"""``xmem auth login`` / ``logout`` — one line for authorized or other status."""
|
|
149
|
+
if not isinstance(payload, dict):
|
|
150
|
+
_default_handler(payload, stream)
|
|
151
|
+
return
|
|
152
|
+
status = payload.get("status", "?")
|
|
153
|
+
if status == "authorized":
|
|
154
|
+
email = payload.get("email") or "(unknown account)"
|
|
155
|
+
prefix = payload.get("key_prefix") or "????????"
|
|
156
|
+
rc_file = payload.get("rc_file") or ".xmemrc.json"
|
|
157
|
+
stream.write("API key installed successfully.\n")
|
|
158
|
+
stream.write(f" Account: {email}\n")
|
|
159
|
+
stream.write(f" Key prefix: {prefix}…\n")
|
|
160
|
+
stream.write(f" Saved to: {rc_file}\n")
|
|
161
|
+
elif status == "aborted":
|
|
162
|
+
stream.write("Login aborted.\n")
|
|
163
|
+
else:
|
|
164
|
+
stream.write(f"auth {status}\n")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _render_version(payload: Any, stream: IO[str]) -> None:
|
|
168
|
+
if not isinstance(payload, dict):
|
|
169
|
+
_default_handler(payload, stream)
|
|
170
|
+
return
|
|
171
|
+
stream.write(f"{CLI_NAME} {payload.get('version', '?')}\n")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _render_status(payload: Any, stream: IO[str]) -> None:
|
|
175
|
+
if not isinstance(payload, dict):
|
|
176
|
+
_default_handler(payload, stream)
|
|
177
|
+
return
|
|
178
|
+
version = payload.get("version", "?")
|
|
179
|
+
status = payload.get("status", "?")
|
|
180
|
+
stream.write(f"{CLI_NAME} {version} · {status}\n")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _render_instance_list(payload: Any, stream: IO[str]) -> None:
|
|
184
|
+
if not isinstance(payload, list):
|
|
185
|
+
_default_handler(payload, stream)
|
|
186
|
+
return
|
|
187
|
+
if not payload:
|
|
188
|
+
stream.write("(no instances)\n")
|
|
189
|
+
return
|
|
190
|
+
for row in payload:
|
|
191
|
+
if not isinstance(row, dict):
|
|
192
|
+
continue
|
|
193
|
+
iid = row.get("id", "?")
|
|
194
|
+
name = row.get("name", "(unnamed)")
|
|
195
|
+
stream.write(f"{iid} {name}\n")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _render_instance_get(payload: Any, stream: IO[str]) -> None:
|
|
199
|
+
if not isinstance(payload, dict):
|
|
200
|
+
_default_handler(payload, stream)
|
|
201
|
+
return
|
|
202
|
+
iid = payload.get("id", "?")
|
|
203
|
+
name = payload.get("name", "(unnamed)")
|
|
204
|
+
stream.write(f"{iid} {name}\n")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _render_instance_delete(payload: Any, stream: IO[str]) -> None:
|
|
208
|
+
if not isinstance(payload, dict):
|
|
209
|
+
_default_handler(payload, stream)
|
|
210
|
+
return
|
|
211
|
+
iid = payload.get("instance_id", "?")
|
|
212
|
+
name = payload.get("name", "")
|
|
213
|
+
if name:
|
|
214
|
+
stream.write(f"deleted {iid} {name}\n")
|
|
215
|
+
else:
|
|
216
|
+
stream.write(f"deleted {iid}\n")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _render_instance_create(payload: Any, stream: IO[str]) -> None:
|
|
220
|
+
if not isinstance(payload, dict):
|
|
221
|
+
_default_handler(payload, stream)
|
|
222
|
+
return
|
|
223
|
+
iid = payload.get("instance_id", "?")
|
|
224
|
+
name = payload.get("name", "(unnamed)")
|
|
225
|
+
stream.write(f"created {iid} {name}\n")
|
|
226
|
+
url = payload.get("console_url")
|
|
227
|
+
if isinstance(url, str) and url.strip():
|
|
228
|
+
stream.write(f" console: {url.strip()}\n")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _render_xmd_schema(payload: Any, stream: IO[str]) -> None:
|
|
232
|
+
if not isinstance(payload, dict):
|
|
233
|
+
_default_handler(payload, stream)
|
|
234
|
+
return
|
|
235
|
+
if payload.get("valid") is True:
|
|
236
|
+
stream.write("schema valid\n")
|
|
237
|
+
return
|
|
238
|
+
if payload.get("data_schema") is not None:
|
|
239
|
+
stream.write("schema generated\n")
|
|
240
|
+
return
|
|
241
|
+
_default_handler(payload, stream)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _render_schema_dry_run(payload: Any, stream: IO[str]) -> None:
|
|
245
|
+
if isinstance(payload, dict) and payload.get("status") == "ok":
|
|
246
|
+
n = len(payload.get("statements") or [])
|
|
247
|
+
stream.write(f"migration dry-run ok ({n} statement{'s' if n != 1 else ''})\n")
|
|
248
|
+
return
|
|
249
|
+
_default_handler(payload, stream)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _render_schema_migrations(payload: Any, stream: IO[str]) -> None:
|
|
253
|
+
if not isinstance(payload, dict):
|
|
254
|
+
_default_handler(payload, stream)
|
|
255
|
+
return
|
|
256
|
+
if isinstance(payload.get("record"), dict):
|
|
257
|
+
stream.write(f"migration {payload['record'].get('id', '?')}\n")
|
|
258
|
+
return
|
|
259
|
+
items = payload.get("items")
|
|
260
|
+
if isinstance(items, list):
|
|
261
|
+
stream.write(f"{len(items)} migration(s)\n")
|
|
262
|
+
return
|
|
263
|
+
_default_handler(payload, stream)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _render_xmd_convert(payload: Any, stream: IO[str]) -> None:
|
|
267
|
+
if isinstance(payload, dict) and isinstance(payload.get("message"), str):
|
|
268
|
+
stream.write(payload["message"])
|
|
269
|
+
if not payload["message"].endswith("\n"):
|
|
270
|
+
stream.write("\n")
|
|
271
|
+
return
|
|
272
|
+
_default_handler(payload, stream)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _render_quota(payload: Any, stream: IO[str]) -> None:
|
|
276
|
+
"""``xmem quota get`` — one line per window: used/limit and server window start.
|
|
277
|
+
|
|
278
|
+
Reset instant is intentionally omitted — the API returns ``window_start`` only
|
|
279
|
+
(anchored billing windows are not inferable client-side).
|
|
280
|
+
"""
|
|
281
|
+
if not isinstance(payload, list):
|
|
282
|
+
_default_handler(payload, stream)
|
|
283
|
+
return
|
|
284
|
+
if not payload:
|
|
285
|
+
stream.write("(no quotas)\n")
|
|
286
|
+
return
|
|
287
|
+
for row in payload:
|
|
288
|
+
if not isinstance(row, dict):
|
|
289
|
+
continue
|
|
290
|
+
window = row.get("quota_window") or "?"
|
|
291
|
+
used = row.get("used_tokens", "?")
|
|
292
|
+
limit = row.get("token_limit", "?")
|
|
293
|
+
started = row.get("window_start") or "?"
|
|
294
|
+
stream.write(f"{window}: {used}/{limit} used · window started {started}\n")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _render_error(payload: Any, stream: IO[str]) -> None:
|
|
298
|
+
"""Structured error — short red-ish line on stdout for the operator.
|
|
299
|
+
|
|
300
|
+
We deliberately write the human error to **stdout**, not stderr, in
|
|
301
|
+
pretty-print mode. The exit code already tells the shell something went
|
|
302
|
+
wrong; piping ``xmem read foo 2>&1`` is awkward, and operators are
|
|
303
|
+
already used to seeing errors inline. Machines never get here — they
|
|
304
|
+
see the JSON-mode error payload on stdout.
|
|
305
|
+
"""
|
|
306
|
+
if not isinstance(payload, dict):
|
|
307
|
+
_default_handler(payload, stream)
|
|
308
|
+
return
|
|
309
|
+
msg = payload.get("error") or "(unspecified error)"
|
|
310
|
+
status = payload.get("status")
|
|
311
|
+
stage = payload.get("stage") or "request"
|
|
312
|
+
if stage == "usage":
|
|
313
|
+
stream.write(f"{msg}\n")
|
|
314
|
+
return
|
|
315
|
+
suffix = f" (HTTP {status})" if status else ""
|
|
316
|
+
stream.write(f"error [{stage}]: {msg}{suffix}\n")
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _render_cluster_list(payload: Any, stream: IO[str]) -> None:
|
|
320
|
+
if not isinstance(payload, list):
|
|
321
|
+
_default_handler(payload, stream)
|
|
322
|
+
return
|
|
323
|
+
if not payload:
|
|
324
|
+
stream.write("(no clusters)\n")
|
|
325
|
+
return
|
|
326
|
+
for row in payload:
|
|
327
|
+
if not isinstance(row, dict):
|
|
328
|
+
continue
|
|
329
|
+
cid = row.get("id", "?")
|
|
330
|
+
name = row.get("name", "(unnamed)")
|
|
331
|
+
stream.write(f"{cid} {name}\n")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _default_handler(payload: Any, stream: IO[str]) -> None:
|
|
335
|
+
"""Fallback when an OutputKind has no specific renderer.
|
|
336
|
+
|
|
337
|
+
Used for new kinds that haven't been wired up yet, and for malformed
|
|
338
|
+
payloads where the typed renderer would have crashed. Always emits the
|
|
339
|
+
raw JSON so the operator still sees the truth.
|
|
340
|
+
"""
|
|
341
|
+
json.dump(payload, stream, indent=2, default=str)
|
|
342
|
+
stream.write("\n")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# ── Dispatch table ────────────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
_RENDERERS: dict[str, Renderer] = {
|
|
349
|
+
"write_sync": _render_write_sync,
|
|
350
|
+
"write_async": _render_write_async,
|
|
351
|
+
"write_status": _render_write_status,
|
|
352
|
+
"read_single": _render_read_single,
|
|
353
|
+
"read_raw": _render_read_structured,
|
|
354
|
+
"read_xresponse": _render_read_structured,
|
|
355
|
+
"key_list": _render_key_list,
|
|
356
|
+
"auth_status": _render_auth_status,
|
|
357
|
+
"login": _render_login,
|
|
358
|
+
"version": _render_version,
|
|
359
|
+
"status": _render_status,
|
|
360
|
+
"quota": _render_quota,
|
|
361
|
+
"cluster_list": _render_cluster_list,
|
|
362
|
+
"instance_list": _render_instance_list,
|
|
363
|
+
"instance_get": _render_instance_get,
|
|
364
|
+
"instance_create": _render_instance_create,
|
|
365
|
+
"instance_delete": _render_instance_delete,
|
|
366
|
+
"schema_update": _render_xmd_schema,
|
|
367
|
+
"schema_dry_run": _render_schema_dry_run,
|
|
368
|
+
"schema_migrations": _render_schema_migrations,
|
|
369
|
+
"xmd_schema": _render_xmd_schema,
|
|
370
|
+
"xmd_enhance": _render_xmd_schema,
|
|
371
|
+
"xmd_convert": _render_xmd_convert,
|
|
372
|
+
"error": _render_error,
|
|
373
|
+
}
|