tlgr-cli 2.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.
- tlgr/__init__.py +3 -0
- tlgr/__main__.py +6 -0
- tlgr/actions/__init__.py +45 -0
- tlgr/actions/forward.py +74 -0
- tlgr/actions/reply.py +32 -0
- tlgr/cli/__init__.py +259 -0
- tlgr/cli/confirm.py +55 -0
- tlgr/cli/errors.py +84 -0
- tlgr/cli/gen.py +690 -0
- tlgr/cli/globals.py +273 -0
- tlgr/cli/introspect.py +170 -0
- tlgr/cli/params.py +189 -0
- tlgr/cli/render.py +418 -0
- tlgr/core/__init__.py +0 -0
- tlgr/core/accounts.py +384 -0
- tlgr/core/config.py +358 -0
- tlgr/core/custom_tl.py +170 -0
- tlgr/core/errors.py +687 -0
- tlgr/core/eventtypes.py +1170 -0
- tlgr/core/identity.py +127 -0
- tlgr/core/launchd.py +122 -0
- tlgr/core/logging.py +194 -0
- tlgr/core/media.py +134 -0
- tlgr/core/output.py +251 -0
- tlgr/core/pagination.py +227 -0
- tlgr/core/paths.py +360 -0
- tlgr/core/peers.py +427 -0
- tlgr/core/process.py +138 -0
- tlgr/core/signing.py +38 -0
- tlgr/core/systemd.py +96 -0
- tlgr/core/telethon_compat.py +295 -0
- tlgr/core/text.py +211 -0
- tlgr/core/timefmt.py +199 -0
- tlgr/core/tl.py +98 -0
- tlgr/daemon/__init__.py +0 -0
- tlgr/daemon/app.py +869 -0
- tlgr/daemon/dispatch.py +446 -0
- tlgr/daemon/events.py +723 -0
- tlgr/daemon/files.py +431 -0
- tlgr/daemon/idle.py +119 -0
- tlgr/daemon/jobs.py +68 -0
- tlgr/daemon/main.py +161 -0
- tlgr/daemon/peercred.py +75 -0
- tlgr/daemon/policy.py +113 -0
- tlgr/daemon/preauth.py +366 -0
- tlgr/daemon/ratelimit.py +391 -0
- tlgr/daemon/server.py +24 -0
- tlgr/daemon/session.py +648 -0
- tlgr/daemon/sessions.py +274 -0
- tlgr/daemon/singleton.py +114 -0
- tlgr/daemon/stream.py +193 -0
- tlgr/daemon/transfers.py +219 -0
- tlgr/daemon/webhook.py +390 -0
- tlgr/data/catalog_index.json +1 -0
- tlgr/data/parity_waivers.toml +90 -0
- tlgr/filters/__init__.py +42 -0
- tlgr/filters/compose.py +121 -0
- tlgr/filters/content.py +85 -0
- tlgr/filters/context.py +114 -0
- tlgr/filters/message.py +161 -0
- tlgr/filters/temporal.py +87 -0
- tlgr/filters/user.py +36 -0
- tlgr/gateway/__init__.py +1 -0
- tlgr/gateway/config.py +161 -0
- tlgr/gateway/engine.py +215 -0
- tlgr/gateway/event.py +22 -0
- tlgr/jobs/__init__.py +0 -0
- tlgr/jobs/base.py +81 -0
- tlgr/jobs/client.py +37 -0
- tlgr/models/__init__.py +1220 -0
- tlgr/models/admin.py +744 -0
- tlgr/models/auth.py +510 -0
- tlgr/models/base.py +81 -0
- tlgr/models/bot.py +576 -0
- tlgr/models/business.py +265 -0
- tlgr/models/call.py +586 -0
- tlgr/models/config.py +101 -0
- tlgr/models/contact.py +481 -0
- tlgr/models/daemon.py +336 -0
- tlgr/models/dialog.py +626 -0
- tlgr/models/envelope.py +68 -0
- tlgr/models/error.py +30 -0
- tlgr/models/event.py +79 -0
- tlgr/models/export.py +66 -0
- tlgr/models/gift.py +275 -0
- tlgr/models/inline.py +84 -0
- tlgr/models/location.py +115 -0
- tlgr/models/media.py +507 -0
- tlgr/models/message.py +584 -0
- tlgr/models/net.py +232 -0
- tlgr/models/notify.py +105 -0
- tlgr/models/page.py +32 -0
- tlgr/models/payment.py +172 -0
- tlgr/models/peer.py +400 -0
- tlgr/models/poll.py +119 -0
- tlgr/models/premium.py +161 -0
- tlgr/models/privacy.py +93 -0
- tlgr/models/profile.py +217 -0
- tlgr/models/reaction.py +160 -0
- tlgr/models/resolve.py +175 -0
- tlgr/models/settings.py +103 -0
- tlgr/models/stars.py +101 -0
- tlgr/models/sticker.py +243 -0
- tlgr/models/story.py +467 -0
- tlgr/models/sync.py +105 -0
- tlgr/models/todo.py +36 -0
- tlgr/models/webapp.py +89 -0
- tlgr/ops/__init__.py +63 -0
- tlgr/ops/_admin.py +313 -0
- tlgr/ops/_auth.py +599 -0
- tlgr/ops/_bots.py +586 -0
- tlgr/ops/_calls.py +535 -0
- tlgr/ops/_common.py +160 -0
- tlgr/ops/_layer.py +46 -0
- tlgr/ops/_media.py +592 -0
- tlgr/ops/_params.py +212 -0
- tlgr/ops/_rights.py +402 -0
- tlgr/ops/_send.py +593 -0
- tlgr/ops/_serialize.py +667 -0
- tlgr/ops/_settings.py +306 -0
- tlgr/ops/_spec.py +167 -0
- tlgr/ops/_story.py +743 -0
- tlgr/ops/account.py +2604 -0
- tlgr/ops/agent.py +937 -0
- tlgr/ops/auth.py +1282 -0
- tlgr/ops/bot.py +4880 -0
- tlgr/ops/business.py +1520 -0
- tlgr/ops/call.py +1610 -0
- tlgr/ops/chat.py +4025 -0
- tlgr/ops/chat_admin.py +929 -0
- tlgr/ops/chat_extra.py +1061 -0
- tlgr/ops/chat_invite.py +716 -0
- tlgr/ops/chat_manage.py +1691 -0
- tlgr/ops/chat_member.py +1357 -0
- tlgr/ops/chat_stats.py +902 -0
- tlgr/ops/chat_topic.py +905 -0
- tlgr/ops/conference.py +791 -0
- tlgr/ops/config.py +1698 -0
- tlgr/ops/contact.py +2330 -0
- tlgr/ops/daemon.py +1397 -0
- tlgr/ops/draft.py +299 -0
- tlgr/ops/emoji.py +343 -0
- tlgr/ops/events.py +1327 -0
- tlgr/ops/export.py +596 -0
- tlgr/ops/folder.py +1322 -0
- tlgr/ops/gif.py +522 -0
- tlgr/ops/gift.py +1546 -0
- tlgr/ops/giveaway.py +541 -0
- tlgr/ops/inline.py +773 -0
- tlgr/ops/job.py +799 -0
- tlgr/ops/location.py +917 -0
- tlgr/ops/media.py +4495 -0
- tlgr/ops/message.py +3769 -0
- tlgr/ops/net.py +536 -0
- tlgr/ops/notify.py +840 -0
- tlgr/ops/passport.py +464 -0
- tlgr/ops/payment.py +907 -0
- tlgr/ops/poll.py +1078 -0
- tlgr/ops/premium.py +488 -0
- tlgr/ops/privacy.py +794 -0
- tlgr/ops/profile.py +1481 -0
- tlgr/ops/proxy.py +750 -0
- tlgr/ops/reaction.py +1475 -0
- tlgr/ops/resolve.py +1140 -0
- tlgr/ops/search.py +521 -0
- tlgr/ops/settings.py +1066 -0
- tlgr/ops/stars.py +594 -0
- tlgr/ops/sticker.py +1602 -0
- tlgr/ops/story.py +3216 -0
- tlgr/ops/sync.py +788 -0
- tlgr/ops/todo.py +514 -0
- tlgr/ops/user.py +1406 -0
- tlgr/ops/vc.py +2351 -0
- tlgr/ops/webapp.py +717 -0
- tlgr/ops/webhook.py +418 -0
- tlgr/parity.py +386 -0
- tlgr/processors/__init__.py +125 -0
- tlgr/processors/regex.py +26 -0
- tlgr/processors/text.py +56 -0
- tlgr/registry.py +519 -0
- tlgr/schema.py +173 -0
- tlgr/transport/__init__.py +30 -0
- tlgr/transport/autostart.py +293 -0
- tlgr/transport/client.py +805 -0
- tlgr/transport/ndjson.py +44 -0
- tlgr/version.py +31 -0
- tlgr_cli-2.0.1.dist-info/METADATA +957 -0
- tlgr_cli-2.0.1.dist-info/RECORD +192 -0
- tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
- tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
- tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
- tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/transport/client.py
ADDED
|
@@ -0,0 +1,805 @@
|
|
|
1
|
+
"""The only code in the CLI that opens a socket (§5.1).
|
|
2
|
+
|
|
3
|
+
v1 wrote the request line by hand, concatenated headers into an f-string,
|
|
4
|
+
`json.dumps(..., default=str)`-ed the body, read until the socket went quiet
|
|
5
|
+
and then decoded chunked transfer-encoding with a hand-rolled loop over a
|
|
6
|
+
`str`. That produced COR-04 (a Persian search query never arrived), COR-31 (a
|
|
7
|
+
"timeout" was indistinguishable from a short read, so a truncated response
|
|
8
|
+
looked like success) and COR-32 (a chunked body with a multi-byte character on
|
|
9
|
+
a chunk boundary was corrupted).
|
|
10
|
+
|
|
11
|
+
All three are consequences of not using `http.client`, which already knows how
|
|
12
|
+
to frame a request, how to read a chunked body and how to raise on a truncated
|
|
13
|
+
one. This module supplies the two things it does not know: how to connect to a
|
|
14
|
+
Unix socket, and what tlgr's error envelope means.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import contextlib
|
|
20
|
+
import http.client
|
|
21
|
+
import os
|
|
22
|
+
import socket
|
|
23
|
+
import time
|
|
24
|
+
import uuid
|
|
25
|
+
from collections.abc import Iterator
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import msgspec
|
|
30
|
+
|
|
31
|
+
from tlgr.core.errors import (
|
|
32
|
+
DaemonNotRunningError,
|
|
33
|
+
IPCError,
|
|
34
|
+
RetryableError,
|
|
35
|
+
TlgrError,
|
|
36
|
+
UsageError,
|
|
37
|
+
)
|
|
38
|
+
from tlgr.core.paths import TlgrPaths
|
|
39
|
+
from tlgr.models.envelope import OpRequest
|
|
40
|
+
from tlgr.transport import autostart
|
|
41
|
+
from tlgr.transport.ndjson import parse_frame
|
|
42
|
+
from tlgr.version import (
|
|
43
|
+
HEADER_CLIENT,
|
|
44
|
+
HEADER_PROTOCOL,
|
|
45
|
+
HEADER_REQUEST_ID,
|
|
46
|
+
HEADER_TOKEN,
|
|
47
|
+
PROTOCOL,
|
|
48
|
+
VERSION,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"DaemonClient",
|
|
53
|
+
"admin",
|
|
54
|
+
"error_from_body",
|
|
55
|
+
"events",
|
|
56
|
+
"make_dispatcher",
|
|
57
|
+
"op",
|
|
58
|
+
"status",
|
|
59
|
+
"stream",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
DEFAULT_TIMEOUT = 120.0
|
|
63
|
+
#: A stream is open for as long as the caller wants it; the read timeout has
|
|
64
|
+
#: to be longer than the heartbeat interval or every quiet minute looks dead.
|
|
65
|
+
STREAM_TIMEOUT = 3600.0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _UnixHTTPConnection(http.client.HTTPConnection):
|
|
69
|
+
"""`http.client` over `AF_UNIX`. The whole connection story."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, path: str, timeout: float) -> None:
|
|
72
|
+
super().__init__("localhost", timeout=timeout)
|
|
73
|
+
self._path = path
|
|
74
|
+
|
|
75
|
+
def connect(self) -> None:
|
|
76
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
77
|
+
sock.settimeout(self.timeout)
|
|
78
|
+
try:
|
|
79
|
+
sock.connect(self._path)
|
|
80
|
+
except BaseException:
|
|
81
|
+
sock.close()
|
|
82
|
+
raise
|
|
83
|
+
self.sock = sock
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
#: Error codes that have a dedicated exception class. Everything else becomes
|
|
87
|
+
#: a `RemoteError` carrying the daemon's own code and exit status, so a code
|
|
88
|
+
#: added on the daemon side does not need a CLI release to exit correctly.
|
|
89
|
+
def _code_classes() -> dict[str, type[TlgrError]]:
|
|
90
|
+
from tlgr.core import errors
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
"USAGE": errors.UsageError,
|
|
94
|
+
"ACCOUNT_REQUIRED": errors.AccountRequiredError,
|
|
95
|
+
"AUTH_ERROR": errors.AuthenticationError,
|
|
96
|
+
"AUTH_PASSWORD_REQUIRED": errors.AuthPasswordRequiredError,
|
|
97
|
+
"SESSION_ERROR": errors.SessionError,
|
|
98
|
+
"NOT_FOUND": errors.NotFoundError,
|
|
99
|
+
"CHAT_NOT_FOUND": errors.ChatNotFoundError,
|
|
100
|
+
"ACCOUNT_NOT_FOUND": errors.AccountNotFoundError,
|
|
101
|
+
"PERMISSION_DENIED": errors.PermissionError_,
|
|
102
|
+
"RATE_LIMITED": errors.RateLimitError,
|
|
103
|
+
"PEER_FLOOD": errors.SpamFlagError,
|
|
104
|
+
"ACCOUNT_FROZEN": errors.AccountFrozenError,
|
|
105
|
+
"RETRYABLE": errors.RetryableError,
|
|
106
|
+
"CONFIG_ERROR": errors.ConfigurationError,
|
|
107
|
+
"DAEMON_ERROR": errors.DaemonError,
|
|
108
|
+
"DAEMON_NOT_RUNNING": errors.DaemonNotRunningError,
|
|
109
|
+
"DAEMON_VERSION_MISMATCH": errors.DaemonVersionMismatchError,
|
|
110
|
+
"INDETERMINATE": errors.IndeterminateError,
|
|
111
|
+
"NOT_SUPPORTED": errors.NotSupportedError,
|
|
112
|
+
"IPC_ERROR": errors.IPCError,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class RemoteError(TlgrError):
|
|
117
|
+
"""An error the daemon classified, carried across the socket intact.
|
|
118
|
+
|
|
119
|
+
The daemon has already decided the code, the exit status and whether a
|
|
120
|
+
retry is worth attempting; re-deriving any of that from an HTTP status on
|
|
121
|
+
this side would be a second opinion that can disagree with the first.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def error_from_body(body: dict[str, Any], *, status_code: int = 500) -> TlgrError:
|
|
126
|
+
"""Rebuild the exception the daemon classified."""
|
|
127
|
+
message = str(body.get("message") or body.get("error") or f"daemon returned {status_code}")
|
|
128
|
+
code = str(body.get("code") or "IPC_ERROR")
|
|
129
|
+
classes = _code_classes()
|
|
130
|
+
klass = classes.get(code)
|
|
131
|
+
|
|
132
|
+
error: TlgrError
|
|
133
|
+
if code == "RATE_LIMITED" and klass is not None:
|
|
134
|
+
error = klass(message, wait_seconds=int(body.get("wait_seconds") or 0)) # type: ignore[call-arg]
|
|
135
|
+
elif klass is not None:
|
|
136
|
+
error = klass(message)
|
|
137
|
+
else:
|
|
138
|
+
error = RemoteError(message, code=code)
|
|
139
|
+
|
|
140
|
+
error.code = code
|
|
141
|
+
exit_code = body.get("exit_code")
|
|
142
|
+
if isinstance(exit_code, int):
|
|
143
|
+
error.exit_code = exit_code
|
|
144
|
+
if body.get("hint"):
|
|
145
|
+
error.hint = str(body["hint"])
|
|
146
|
+
if body.get("retryable"):
|
|
147
|
+
error.retryable = True
|
|
148
|
+
for extra in ("field", "wait_seconds", "rpc", "account", "request_id", "reason"):
|
|
149
|
+
if body.get(extra) is not None:
|
|
150
|
+
setattr(error, extra, body[extra])
|
|
151
|
+
return error
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class DaemonClient:
|
|
155
|
+
"""One connection story: connect, send JSON, read JSON or NDJSON.
|
|
156
|
+
|
|
157
|
+
Instances are cheap and hold no socket between calls — HTTP/1.1 with
|
|
158
|
+
`Connection: close` is the right trade for a CLI that makes one or two
|
|
159
|
+
requests and exits, and it removes a whole class of "the daemon restarted
|
|
160
|
+
under our keep-alive" failures.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def __init__(
|
|
164
|
+
self,
|
|
165
|
+
base: Path | None = None,
|
|
166
|
+
*,
|
|
167
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
168
|
+
auto_start: bool | None = None,
|
|
169
|
+
no_restart: bool = False,
|
|
170
|
+
token: str | None = None,
|
|
171
|
+
) -> None:
|
|
172
|
+
self.paths = TlgrPaths(base)
|
|
173
|
+
self.timeout = timeout
|
|
174
|
+
self._auto_start = auto_start
|
|
175
|
+
self._no_restart = no_restart
|
|
176
|
+
self._token = token
|
|
177
|
+
self._ready = False
|
|
178
|
+
self._restarted = False
|
|
179
|
+
|
|
180
|
+
# -- configuration -----------------------------------------------------
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def auto_start(self) -> bool:
|
|
184
|
+
if self._auto_start is not None:
|
|
185
|
+
return self._auto_start
|
|
186
|
+
try:
|
|
187
|
+
from tlgr.core.config import load_app_config
|
|
188
|
+
|
|
189
|
+
return bool(load_app_config(self.paths.base).daemon.auto_start)
|
|
190
|
+
except Exception:
|
|
191
|
+
return True
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def start_timeout(self) -> float:
|
|
195
|
+
try:
|
|
196
|
+
from tlgr.core.config import load_app_config
|
|
197
|
+
|
|
198
|
+
return float(load_app_config(self.paths.base).daemon.start_timeout)
|
|
199
|
+
except Exception:
|
|
200
|
+
return 30.0
|
|
201
|
+
|
|
202
|
+
def token(self) -> str | None:
|
|
203
|
+
if self._token is not None:
|
|
204
|
+
return self._token
|
|
205
|
+
env = os.environ.get("TLGR_IPC_TOKEN", "").strip()
|
|
206
|
+
if env:
|
|
207
|
+
self._token = env
|
|
208
|
+
return env
|
|
209
|
+
try:
|
|
210
|
+
self._token = self.paths.token.read_text().strip() or None
|
|
211
|
+
except OSError:
|
|
212
|
+
self._token = None
|
|
213
|
+
return self._token
|
|
214
|
+
|
|
215
|
+
def _headers(self, request_id: str, *, body: bool) -> dict[str, str]:
|
|
216
|
+
headers = {
|
|
217
|
+
HEADER_CLIENT: f"tlgr/{VERSION}",
|
|
218
|
+
HEADER_PROTOCOL: str(PROTOCOL),
|
|
219
|
+
HEADER_REQUEST_ID: request_id,
|
|
220
|
+
"Accept": "application/json, application/x-ndjson",
|
|
221
|
+
"Connection": "close",
|
|
222
|
+
}
|
|
223
|
+
if body:
|
|
224
|
+
headers["Content-Type"] = "application/json"
|
|
225
|
+
token = self.token()
|
|
226
|
+
if token:
|
|
227
|
+
headers[HEADER_TOKEN] = token
|
|
228
|
+
return headers
|
|
229
|
+
|
|
230
|
+
# -- raw plumbing ------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
def _open(
|
|
233
|
+
self,
|
|
234
|
+
method: str,
|
|
235
|
+
path: str,
|
|
236
|
+
*,
|
|
237
|
+
body: bytes | None = None,
|
|
238
|
+
timeout: float | None = None,
|
|
239
|
+
request_id: str = "",
|
|
240
|
+
) -> tuple[_UnixHTTPConnection, http.client.HTTPResponse]:
|
|
241
|
+
conn = _UnixHTTPConnection(str(self.paths.socket), timeout or self.timeout)
|
|
242
|
+
try:
|
|
243
|
+
conn.request(
|
|
244
|
+
method, path, body=body, headers=self._headers(request_id, body=body is not None)
|
|
245
|
+
)
|
|
246
|
+
response = conn.getresponse()
|
|
247
|
+
except (FileNotFoundError, ConnectionRefusedError) as exc:
|
|
248
|
+
conn.close()
|
|
249
|
+
raise DaemonNotRunningError(f"cannot connect to the daemon socket: {exc}") from exc
|
|
250
|
+
except TimeoutError as exc:
|
|
251
|
+
conn.close()
|
|
252
|
+
raise RetryableError(
|
|
253
|
+
f"the daemon did not answer within {timeout or self.timeout:g}s"
|
|
254
|
+
) from exc
|
|
255
|
+
except BaseException:
|
|
256
|
+
conn.close()
|
|
257
|
+
raise
|
|
258
|
+
return conn, response
|
|
259
|
+
|
|
260
|
+
def request(
|
|
261
|
+
self,
|
|
262
|
+
method: str,
|
|
263
|
+
path: str,
|
|
264
|
+
*,
|
|
265
|
+
body: Any = None,
|
|
266
|
+
params: dict[str, Any] | None = None,
|
|
267
|
+
timeout: float | None = None,
|
|
268
|
+
idempotent: bool | None = None,
|
|
269
|
+
request_id: str = "",
|
|
270
|
+
) -> Any:
|
|
271
|
+
"""One request, one decoded JSON body. Raises the daemon's error.
|
|
272
|
+
|
|
273
|
+
A connection that breaks *before* a reply is one retry for an
|
|
274
|
+
idempotent request (a GET, or an op that declares itself idempotent):
|
|
275
|
+
the daemon restarting between our connect and our write is common and
|
|
276
|
+
is not the caller's problem. A request that reached the daemon is
|
|
277
|
+
never retried — replaying a send is worse than reporting a failure.
|
|
278
|
+
"""
|
|
279
|
+
if idempotent is None:
|
|
280
|
+
idempotent = method.upper() in ("GET", "HEAD")
|
|
281
|
+
target = _with_query(path, params)
|
|
282
|
+
payload = _encode_body(body)
|
|
283
|
+
request_id = request_id or _request_id()
|
|
284
|
+
|
|
285
|
+
attempts = 2 if idempotent else 1
|
|
286
|
+
last: Exception | None = None
|
|
287
|
+
for attempt in range(attempts):
|
|
288
|
+
self._ensure_daemon()
|
|
289
|
+
try:
|
|
290
|
+
conn, response = self._open(
|
|
291
|
+
method, target, body=payload, timeout=timeout, request_id=request_id
|
|
292
|
+
)
|
|
293
|
+
except (DaemonNotRunningError, ConnectionError, http.client.HTTPException) as exc:
|
|
294
|
+
last = exc
|
|
295
|
+
self._ready = False
|
|
296
|
+
if attempt + 1 < attempts:
|
|
297
|
+
continue
|
|
298
|
+
if isinstance(exc, TlgrError):
|
|
299
|
+
raise
|
|
300
|
+
raise IPCError(f"the daemon connection broke before it answered: {exc}") from exc
|
|
301
|
+
try:
|
|
302
|
+
raw = response.read()
|
|
303
|
+
except (http.client.IncompleteRead, ConnectionResetError, OSError) as exc:
|
|
304
|
+
conn.close()
|
|
305
|
+
last = exc
|
|
306
|
+
self._ready = False
|
|
307
|
+
if attempt + 1 < attempts:
|
|
308
|
+
continue
|
|
309
|
+
raise IPCError(f"the daemon closed the connection mid-reply: {exc}") from exc
|
|
310
|
+
finally:
|
|
311
|
+
conn.close()
|
|
312
|
+
return _decode(raw, response.status)
|
|
313
|
+
raise IPCError(str(last) if last else "request failed")
|
|
314
|
+
|
|
315
|
+
def stream(
|
|
316
|
+
self,
|
|
317
|
+
method: str,
|
|
318
|
+
path: str,
|
|
319
|
+
*,
|
|
320
|
+
body: Any = None,
|
|
321
|
+
params: dict[str, Any] | None = None,
|
|
322
|
+
timeout: float | None = None,
|
|
323
|
+
) -> Iterator[dict[str, Any]]:
|
|
324
|
+
"""Yield NDJSON frames until the daemon closes the response.
|
|
325
|
+
|
|
326
|
+
A stream that ends without an `end` frame is `RETRYABLE`, never a
|
|
327
|
+
silent success: "the connection dropped after 400 of 4,120 items" and
|
|
328
|
+
"there were 400 items" are different facts (§5.3).
|
|
329
|
+
"""
|
|
330
|
+
self._ensure_daemon()
|
|
331
|
+
conn, response = self._open(
|
|
332
|
+
method,
|
|
333
|
+
_with_query(path, params),
|
|
334
|
+
body=_encode_body(body),
|
|
335
|
+
timeout=timeout if timeout is not None else STREAM_TIMEOUT,
|
|
336
|
+
request_id=_request_id(),
|
|
337
|
+
)
|
|
338
|
+
try:
|
|
339
|
+
if response.status >= 400:
|
|
340
|
+
yield from _error_frames(response.read(), response.status)
|
|
341
|
+
return
|
|
342
|
+
saw_end = False
|
|
343
|
+
while True:
|
|
344
|
+
line = response.readline()
|
|
345
|
+
if not line:
|
|
346
|
+
break
|
|
347
|
+
stripped = line.strip()
|
|
348
|
+
if not stripped:
|
|
349
|
+
continue
|
|
350
|
+
frame = parse_frame(stripped)
|
|
351
|
+
if frame.get("type") == "end":
|
|
352
|
+
saw_end = True
|
|
353
|
+
yield frame
|
|
354
|
+
if saw_end:
|
|
355
|
+
break
|
|
356
|
+
if not saw_end:
|
|
357
|
+
raise RetryableError(
|
|
358
|
+
"the daemon closed the stream without an end frame; "
|
|
359
|
+
"the result is incomplete — retry"
|
|
360
|
+
)
|
|
361
|
+
finally:
|
|
362
|
+
conn.close()
|
|
363
|
+
|
|
364
|
+
# -- protocol ----------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
def probe_status(self) -> dict[str, Any] | None:
|
|
367
|
+
"""`GET /v1/status`, or None when nothing is listening."""
|
|
368
|
+
try:
|
|
369
|
+
conn, response = self._open("GET", "/v1/status", timeout=5.0)
|
|
370
|
+
except (DaemonNotRunningError, RetryableError):
|
|
371
|
+
return None
|
|
372
|
+
try:
|
|
373
|
+
raw = response.read()
|
|
374
|
+
except OSError:
|
|
375
|
+
return None
|
|
376
|
+
finally:
|
|
377
|
+
conn.close()
|
|
378
|
+
if response.status != 200:
|
|
379
|
+
return None
|
|
380
|
+
try:
|
|
381
|
+
decoded = msgspec.json.decode(raw)
|
|
382
|
+
except msgspec.DecodeError:
|
|
383
|
+
return None
|
|
384
|
+
return decoded if isinstance(decoded, dict) else None
|
|
385
|
+
|
|
386
|
+
def _ensure_daemon(self) -> None:
|
|
387
|
+
"""Start the daemon if needed and agree on a protocol, once per client."""
|
|
388
|
+
if self._ready:
|
|
389
|
+
return
|
|
390
|
+
state = autostart.ensure_running(
|
|
391
|
+
self.paths,
|
|
392
|
+
self.probe_status,
|
|
393
|
+
auto_start=self.auto_start,
|
|
394
|
+
start_timeout=self.start_timeout,
|
|
395
|
+
)
|
|
396
|
+
if autostart.check_protocol(state, client_protocol=PROTOCOL) == -1:
|
|
397
|
+
self._restart_older_daemon(state)
|
|
398
|
+
self._ready = True
|
|
399
|
+
|
|
400
|
+
def _restart_older_daemon(self, state: dict[str, Any]) -> None:
|
|
401
|
+
daemon = state.get("daemon") or {}
|
|
402
|
+
running = daemon.get("protocol", 0)
|
|
403
|
+
if self._no_restart:
|
|
404
|
+
from tlgr.core.errors import DaemonVersionMismatchError
|
|
405
|
+
|
|
406
|
+
raise DaemonVersionMismatchError(
|
|
407
|
+
f"the running daemon speaks protocol {running}, this CLI speaks {PROTOCOL}, "
|
|
408
|
+
"and --no-daemon-restart was given"
|
|
409
|
+
)
|
|
410
|
+
if self._restarted:
|
|
411
|
+
from tlgr.core.errors import DaemonVersionMismatchError
|
|
412
|
+
|
|
413
|
+
raise DaemonVersionMismatchError(
|
|
414
|
+
"restarted the daemon once and it still speaks an older protocol"
|
|
415
|
+
)
|
|
416
|
+
self._restarted = True
|
|
417
|
+
managed = autostart.daemon_state(self.paths)
|
|
418
|
+
if managed.supervised:
|
|
419
|
+
from tlgr.core.errors import DaemonVersionMismatchError
|
|
420
|
+
|
|
421
|
+
raise DaemonVersionMismatchError(
|
|
422
|
+
f"the daemon is managed by {managed.managed_by}; restart it with "
|
|
423
|
+
"tlgr daemon restart so the supervisor keeps ownership"
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
import sys
|
|
427
|
+
|
|
428
|
+
print(
|
|
429
|
+
f"daemon is running an older protocol ({running} < {PROTOCOL}); restarting it",
|
|
430
|
+
file=sys.stderr,
|
|
431
|
+
)
|
|
432
|
+
# A stop that fails is not fatal here: the point is only to make the
|
|
433
|
+
# old daemon let go, and the readiness poll below is the real check.
|
|
434
|
+
with contextlib.suppress(TlgrError):
|
|
435
|
+
self.request("POST", "/v1/admin/stop", body={"drain_s": 5}, timeout=10.0)
|
|
436
|
+
deadline = time.monotonic() + 15.0
|
|
437
|
+
while time.monotonic() < deadline and self.probe_status() is not None:
|
|
438
|
+
time.sleep(0.05)
|
|
439
|
+
fresh = autostart.ensure_running(
|
|
440
|
+
self.paths,
|
|
441
|
+
self.probe_status,
|
|
442
|
+
auto_start=True,
|
|
443
|
+
start_timeout=self.start_timeout,
|
|
444
|
+
)
|
|
445
|
+
autostart.check_protocol(fresh, client_protocol=PROTOCOL)
|
|
446
|
+
|
|
447
|
+
# -- the four verbs ----------------------------------------------------
|
|
448
|
+
|
|
449
|
+
def op(
|
|
450
|
+
self,
|
|
451
|
+
op_id: str,
|
|
452
|
+
request: Any = None,
|
|
453
|
+
*,
|
|
454
|
+
account: str = "",
|
|
455
|
+
dry_run: bool = False,
|
|
456
|
+
flood_wait_max: int | None = None,
|
|
457
|
+
limit: int | None = None,
|
|
458
|
+
cursor: str | None = None,
|
|
459
|
+
fetch_all: bool = False,
|
|
460
|
+
idempotent: bool = False,
|
|
461
|
+
timeout: float | None = None,
|
|
462
|
+
) -> dict[str, Any]:
|
|
463
|
+
"""`POST /v1/op` — the decoded success envelope, or the daemon's error."""
|
|
464
|
+
request_id = _request_id()
|
|
465
|
+
body = OpRequest(
|
|
466
|
+
op=op_id,
|
|
467
|
+
account=account,
|
|
468
|
+
request=_as_builtins(request),
|
|
469
|
+
dry_run=dry_run,
|
|
470
|
+
flood_wait_max=flood_wait_max,
|
|
471
|
+
request_id=request_id,
|
|
472
|
+
client_version=VERSION,
|
|
473
|
+
protocol=PROTOCOL,
|
|
474
|
+
limit=limit,
|
|
475
|
+
cursor=cursor,
|
|
476
|
+
all=fetch_all,
|
|
477
|
+
)
|
|
478
|
+
result = self.request(
|
|
479
|
+
"POST",
|
|
480
|
+
"/v1/op",
|
|
481
|
+
body=body,
|
|
482
|
+
timeout=timeout,
|
|
483
|
+
idempotent=idempotent,
|
|
484
|
+
request_id=request_id,
|
|
485
|
+
)
|
|
486
|
+
if not isinstance(result, dict):
|
|
487
|
+
raise IPCError("the daemon returned a non-object envelope")
|
|
488
|
+
return result
|
|
489
|
+
|
|
490
|
+
def op_stream(
|
|
491
|
+
self,
|
|
492
|
+
op_id: str,
|
|
493
|
+
request: Any = None,
|
|
494
|
+
*,
|
|
495
|
+
account: str = "",
|
|
496
|
+
dry_run: bool = False,
|
|
497
|
+
flood_wait_max: int | None = None,
|
|
498
|
+
limit: int | None = None,
|
|
499
|
+
cursor: str | None = None,
|
|
500
|
+
fetch_all: bool = False,
|
|
501
|
+
timeout: float | None = None,
|
|
502
|
+
) -> Iterator[dict[str, Any]]:
|
|
503
|
+
body = OpRequest(
|
|
504
|
+
op=op_id,
|
|
505
|
+
account=account,
|
|
506
|
+
request=_as_builtins(request),
|
|
507
|
+
dry_run=dry_run,
|
|
508
|
+
flood_wait_max=flood_wait_max,
|
|
509
|
+
request_id=_request_id(),
|
|
510
|
+
client_version=VERSION,
|
|
511
|
+
protocol=PROTOCOL,
|
|
512
|
+
stream=True,
|
|
513
|
+
limit=limit,
|
|
514
|
+
cursor=cursor,
|
|
515
|
+
all=fetch_all,
|
|
516
|
+
)
|
|
517
|
+
return self.stream("POST", "/v1/op", body=body, timeout=timeout)
|
|
518
|
+
|
|
519
|
+
def events(
|
|
520
|
+
self,
|
|
521
|
+
*,
|
|
522
|
+
account: str,
|
|
523
|
+
types: str = "",
|
|
524
|
+
since: int | str | None = None,
|
|
525
|
+
chats: str = "",
|
|
526
|
+
timeout: int = 3600,
|
|
527
|
+
**extra: Any,
|
|
528
|
+
) -> Iterator[dict[str, Any]]:
|
|
529
|
+
"""`GET /v1/events` — the push stream, as NDJSON frames.
|
|
530
|
+
|
|
531
|
+
A GET-shaped alias of `POST /v1/op {op: events.watch}`: the daemon
|
|
532
|
+
decodes the query into the same request struct and runs the same
|
|
533
|
+
implementation, so there is one filter vocabulary rather than two.
|
|
534
|
+
"""
|
|
535
|
+
params: dict[str, Any] = {"account": account, "timeout": timeout}
|
|
536
|
+
if types:
|
|
537
|
+
params["types"] = types
|
|
538
|
+
if since is not None:
|
|
539
|
+
params["since"] = since
|
|
540
|
+
if chats:
|
|
541
|
+
params["chats"] = chats
|
|
542
|
+
params.update({k: v for k, v in extra.items() if v is not None})
|
|
543
|
+
return self.stream("GET", "/v1/events", params=params, timeout=timeout + 30)
|
|
544
|
+
|
|
545
|
+
def status(self) -> dict[str, Any]:
|
|
546
|
+
result = self.request("GET", "/v1/status", timeout=15.0)
|
|
547
|
+
if not isinstance(result, dict):
|
|
548
|
+
raise IPCError("the daemon returned a non-object status")
|
|
549
|
+
return result
|
|
550
|
+
|
|
551
|
+
def admin(self, action: str, body: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
552
|
+
if "/" in action:
|
|
553
|
+
raise UsageError(f"invalid admin action {action!r}")
|
|
554
|
+
result = self.request("POST", f"/v1/admin/{action}", body=body or {})
|
|
555
|
+
if not isinstance(result, dict):
|
|
556
|
+
raise IPCError("the daemon returned a non-object admin reply")
|
|
557
|
+
return result
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
# ---------------------------------------------------------------------------
|
|
561
|
+
# Helpers
|
|
562
|
+
# ---------------------------------------------------------------------------
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _request_id() -> str:
|
|
566
|
+
return uuid.uuid4().hex
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _encode_body(body: Any) -> bytes | None:
|
|
570
|
+
if body is None:
|
|
571
|
+
return None
|
|
572
|
+
if isinstance(body, bytes):
|
|
573
|
+
return body
|
|
574
|
+
return msgspec.json.encode(body)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _as_builtins(request: Any) -> dict[str, Any]:
|
|
578
|
+
if request is None:
|
|
579
|
+
return {}
|
|
580
|
+
if isinstance(request, dict):
|
|
581
|
+
return request
|
|
582
|
+
converted = msgspec.to_builtins(request)
|
|
583
|
+
return converted if isinstance(converted, dict) else {}
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _with_query(path: str, params: dict[str, Any] | None) -> str:
|
|
587
|
+
"""Append *params*, always through `urlencode`.
|
|
588
|
+
|
|
589
|
+
This is COR-04's fix on the query side: `f"?chat={chat}"` with `chat` set
|
|
590
|
+
to `سلام #12` produced a path the server split at the `#` and decoded as
|
|
591
|
+
Latin-1. `urlencode` is the only spelling allowed in this file.
|
|
592
|
+
"""
|
|
593
|
+
if not params:
|
|
594
|
+
return path
|
|
595
|
+
from urllib.parse import urlencode
|
|
596
|
+
|
|
597
|
+
pairs = [(k, v) for k, v in params.items() if v is not None]
|
|
598
|
+
if not pairs:
|
|
599
|
+
return path
|
|
600
|
+
query = urlencode([(k, _stringify(v)) for k, v in pairs])
|
|
601
|
+
separator = "&" if "?" in path else "?"
|
|
602
|
+
return f"{path}{separator}{query}"
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _stringify(value: Any) -> str:
|
|
606
|
+
if isinstance(value, bool):
|
|
607
|
+
return "true" if value else "false"
|
|
608
|
+
return str(value)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _query_value(value: Any) -> str:
|
|
612
|
+
"""One request field as one query value.
|
|
613
|
+
|
|
614
|
+
A repeated `--chat` is a list of parsed `PeerRef`s; the query carries the
|
|
615
|
+
references the user typed, comma-separated, and the daemon parses them
|
|
616
|
+
with the same parser the CLI used. Sending the parsed dicts would mean two
|
|
617
|
+
peer parsers that could disagree about what `-100…` means.
|
|
618
|
+
"""
|
|
619
|
+
if isinstance(value, dict) and "raw" in value:
|
|
620
|
+
return str(value["raw"])
|
|
621
|
+
if isinstance(value, (list, tuple)):
|
|
622
|
+
return ",".join(_query_value(item) for item in value)
|
|
623
|
+
return _stringify(value)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _decode(raw: bytes, status_code: int) -> Any:
|
|
627
|
+
try:
|
|
628
|
+
decoded = msgspec.json.decode(raw) if raw else None
|
|
629
|
+
except msgspec.DecodeError as exc:
|
|
630
|
+
if status_code >= 400:
|
|
631
|
+
raise IPCError(f"daemon error ({status_code}): {raw[:200]!r}") from exc
|
|
632
|
+
raise IPCError(f"the daemon returned a body that is not JSON: {raw[:200]!r}") from exc
|
|
633
|
+
|
|
634
|
+
if status_code < 400:
|
|
635
|
+
return decoded
|
|
636
|
+
|
|
637
|
+
body: dict[str, Any] = {}
|
|
638
|
+
if isinstance(decoded, dict):
|
|
639
|
+
inner = decoded.get("error")
|
|
640
|
+
body = inner if isinstance(inner, dict) else decoded
|
|
641
|
+
raise error_from_body(body, status_code=status_code)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _error_frames(raw: bytes, status_code: int) -> Iterator[dict[str, Any]]:
|
|
645
|
+
try:
|
|
646
|
+
_decode(raw, status_code)
|
|
647
|
+
except TlgrError as exc:
|
|
648
|
+
from tlgr.core.errors import classify, error_body_dict
|
|
649
|
+
|
|
650
|
+
yield {"type": "end", "ok": False, "error": error_body_dict(classify(exc))}
|
|
651
|
+
return
|
|
652
|
+
yield {"type": "end", "ok": False, "error": {"code": "IPC_ERROR", "message": "stream failed"}}
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
# ---------------------------------------------------------------------------
|
|
656
|
+
# Module-level convenience — one shared client per process
|
|
657
|
+
# ---------------------------------------------------------------------------
|
|
658
|
+
|
|
659
|
+
_default: DaemonClient | None = None
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def default_client(base: Path | None = None) -> DaemonClient:
|
|
663
|
+
global _default
|
|
664
|
+
if _default is None or (base is not None and _default.paths.base != Path(base)):
|
|
665
|
+
_default = DaemonClient(base)
|
|
666
|
+
return _default
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def reset_default_client() -> None:
|
|
670
|
+
"""Drop the shared client. Tests that move `TLGR_HOME` need this."""
|
|
671
|
+
global _default
|
|
672
|
+
_default = None
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def op(op_id: str, request: Any = None, **kwargs: Any) -> dict[str, Any]:
|
|
676
|
+
return default_client().op(op_id, request, **kwargs)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def stream(op_id: str, request: Any = None, **kwargs: Any) -> Iterator[dict[str, Any]]:
|
|
680
|
+
return default_client().op_stream(op_id, request, **kwargs)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def events(**kwargs: Any) -> Iterator[dict[str, Any]]:
|
|
684
|
+
return default_client().events(**kwargs)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def status() -> dict[str, Any]:
|
|
688
|
+
return default_client().status()
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def admin(action: str, body: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
692
|
+
return default_client().admin(action, body)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
#: Set once by the CLI root from `--flood-wait-max`. Legacy commands do not
|
|
696
|
+
#: thread the flag through their own bodies (there are forty of them), and
|
|
697
|
+
#: dropping it silently is COR-15 — the flag existed and did nothing.
|
|
698
|
+
# ---------------------------------------------------------------------------
|
|
699
|
+
# The CLI dispatcher
|
|
700
|
+
# ---------------------------------------------------------------------------
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def make_dispatcher(base: Path | None = None) -> Any:
|
|
704
|
+
"""Build the callable `cli.gen.set_dispatcher()` wants.
|
|
705
|
+
|
|
706
|
+
`run_op` has already resolved the account, enforced `--enable-commands`
|
|
707
|
+
by canonical id and short-circuited `--dry-run`; all that is left is the
|
|
708
|
+
round trip. Keeping it that thin is what makes the CLI testable without a
|
|
709
|
+
daemon and the daemon testable without a CLI.
|
|
710
|
+
"""
|
|
711
|
+
|
|
712
|
+
def dispatch(spec: Any, request: Any, state: Any) -> dict[str, Any]:
|
|
713
|
+
client = DaemonClient(
|
|
714
|
+
base,
|
|
715
|
+
timeout=float(state.timeout) if state.timeout else float(spec.timeout_s),
|
|
716
|
+
no_restart=bool(state.no_daemon_restart),
|
|
717
|
+
)
|
|
718
|
+
common: dict[str, Any] = {
|
|
719
|
+
"account": state.account or "",
|
|
720
|
+
"dry_run": bool(state.dry_run),
|
|
721
|
+
"flood_wait_max": state.flood_wait_max,
|
|
722
|
+
"limit": getattr(state, "limit", None),
|
|
723
|
+
"cursor": getattr(state, "cursor", None),
|
|
724
|
+
"fetch_all": bool(getattr(state, "fetch_all", False)),
|
|
725
|
+
}
|
|
726
|
+
if spec.stream or common["fetch_all"]:
|
|
727
|
+
return _collect(client.op_stream(spec.id, request, **common), spec.id)
|
|
728
|
+
return client.op(spec.id, request, idempotent=spec.idempotent, **common)
|
|
729
|
+
|
|
730
|
+
return dispatch
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def make_stream_dispatcher(base: Path | None = None) -> Any:
|
|
734
|
+
"""The dispatcher a live-stream command uses: frames, as they arrive.
|
|
735
|
+
|
|
736
|
+
`make_dispatcher` folds a walk back into one envelope, which is right for
|
|
737
|
+
`--all` and wrong for `watch`: a stream that only prints when it ends is
|
|
738
|
+
not a stream. This one yields, and the CLI writes each frame.
|
|
739
|
+
|
|
740
|
+
`events.watch` goes over `GET /v1/events` rather than `POST /v1/op`
|
|
741
|
+
because that is the documented endpoint for the push stream and it is
|
|
742
|
+
reachable with `curl`; the daemon serves both from the same operation.
|
|
743
|
+
"""
|
|
744
|
+
|
|
745
|
+
def dispatch(spec: Any, request: Any, state: Any) -> Iterator[dict[str, Any]]:
|
|
746
|
+
client = DaemonClient(
|
|
747
|
+
base,
|
|
748
|
+
timeout=float(state.timeout) if state.timeout else float(spec.timeout_s),
|
|
749
|
+
no_restart=bool(state.no_daemon_restart),
|
|
750
|
+
)
|
|
751
|
+
body = _as_builtins(request)
|
|
752
|
+
if spec.id == "events.watch":
|
|
753
|
+
follow_for = int(body.pop("follow_for", 3600) or 3600)
|
|
754
|
+
return client.events(
|
|
755
|
+
account=state.account or "",
|
|
756
|
+
timeout=follow_for,
|
|
757
|
+
**{
|
|
758
|
+
key: _query_value(value)
|
|
759
|
+
for key, value in body.items()
|
|
760
|
+
if value is not None and value != []
|
|
761
|
+
},
|
|
762
|
+
)
|
|
763
|
+
return client.op_stream(
|
|
764
|
+
spec.id,
|
|
765
|
+
request,
|
|
766
|
+
account=state.account or "",
|
|
767
|
+
dry_run=bool(state.dry_run),
|
|
768
|
+
flood_wait_max=state.flood_wait_max,
|
|
769
|
+
limit=getattr(state, "limit", None),
|
|
770
|
+
cursor=getattr(state, "cursor", None),
|
|
771
|
+
fetch_all=bool(getattr(state, "fetch_all", False)),
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
return dispatch
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _collect(frames: Iterator[dict[str, Any]], op_id: str) -> dict[str, Any]:
|
|
778
|
+
"""Fold an NDJSON walk back into one envelope for the renderer."""
|
|
779
|
+
items: list[Any] = []
|
|
780
|
+
meta: dict[str, Any] = {}
|
|
781
|
+
page: dict[str, Any] | None = None
|
|
782
|
+
account: str | None = None
|
|
783
|
+
for frame in frames:
|
|
784
|
+
kind = frame.get("type")
|
|
785
|
+
if kind == "meta":
|
|
786
|
+
account = frame.get("account") or None
|
|
787
|
+
meta["request_id"] = frame.get("request_id", "")
|
|
788
|
+
elif kind == "item":
|
|
789
|
+
items.append(frame.get("data"))
|
|
790
|
+
elif kind == "page":
|
|
791
|
+
page = {
|
|
792
|
+
"has_more": bool(frame.get("has_more")),
|
|
793
|
+
"next_cursor": frame.get("next_cursor"),
|
|
794
|
+
}
|
|
795
|
+
elif kind == "end":
|
|
796
|
+
if not frame.get("ok", True):
|
|
797
|
+
raise error_from_body(frame.get("error") or {})
|
|
798
|
+
meta["elapsed_ms"] = frame.get("elapsed_ms", 0)
|
|
799
|
+
meta["count"] = frame.get("count", len(items))
|
|
800
|
+
envelope: dict[str, Any] = {"ok": True, "op": op_id, "result": items, "meta": meta}
|
|
801
|
+
if account:
|
|
802
|
+
envelope["account"] = account
|
|
803
|
+
if page is not None:
|
|
804
|
+
envelope["page"] = page
|
|
805
|
+
return envelope
|