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/cli/render.py
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""Output rendering (ARCHITECTURE §9), driven entirely by the spec.
|
|
2
|
+
|
|
3
|
+
Three modes, one rule each:
|
|
4
|
+
|
|
5
|
+
* `--json` prints the envelope verbatim, so an agent parses one shape;
|
|
6
|
+
* `--plain` prints TSV that survives `cut` and `awk`;
|
|
7
|
+
* the default prints a table a person can read, with real formatting — v1
|
|
8
|
+
printed `None`, `True` and raw dict reprs into columns (UX-03).
|
|
9
|
+
|
|
10
|
+
`--results-only` returns `result` verbatim rather than guessing which key was
|
|
11
|
+
the "primary" one, which is how v1 came to print a bare `2` for
|
|
12
|
+
`message delete` (COR-18).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import contextlib
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
from collections.abc import Iterable, Sequence
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import click
|
|
26
|
+
|
|
27
|
+
from tlgr.core.timefmt import fmt_local
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"MAX_COLUMN",
|
|
31
|
+
"format_cell",
|
|
32
|
+
"get_path",
|
|
33
|
+
"project",
|
|
34
|
+
"render",
|
|
35
|
+
"render_human",
|
|
36
|
+
"render_json",
|
|
37
|
+
"render_plain",
|
|
38
|
+
"render_stream",
|
|
39
|
+
"results_payload",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
MAX_COLUMN = 48
|
|
43
|
+
|
|
44
|
+
_RFC3339 = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def use_color(stream: Any = None) -> bool:
|
|
48
|
+
"""Colour only on a TTY, and never when `NO_COLOR` is set."""
|
|
49
|
+
if os.environ.get("NO_COLOR") is not None:
|
|
50
|
+
return False
|
|
51
|
+
stream = stream or sys.stdout
|
|
52
|
+
return bool(getattr(stream, "isatty", lambda: False)())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Projection (--select)
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_path(obj: Any, path: str) -> tuple[Any, bool]:
|
|
61
|
+
"""Walk a dot path into nested dicts and lists. Returns (value, found)."""
|
|
62
|
+
current = obj
|
|
63
|
+
for segment in (s.strip() for s in path.split(".") if s.strip()):
|
|
64
|
+
if isinstance(current, dict):
|
|
65
|
+
if segment not in current:
|
|
66
|
+
return None, False
|
|
67
|
+
current = current[segment]
|
|
68
|
+
elif isinstance(current, list):
|
|
69
|
+
try:
|
|
70
|
+
current = current[int(segment)]
|
|
71
|
+
except (ValueError, IndexError):
|
|
72
|
+
return None, False
|
|
73
|
+
else:
|
|
74
|
+
return None, False
|
|
75
|
+
return current, True
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def project(data: Any, fields: Sequence[str]) -> Any:
|
|
79
|
+
"""Keep only *fields*, recursing element-wise into lists.
|
|
80
|
+
|
|
81
|
+
Missing paths are omitted rather than emitted as null: `--select` is a
|
|
82
|
+
projection, and inventing keys would make the output lie about what the
|
|
83
|
+
operation returned.
|
|
84
|
+
"""
|
|
85
|
+
if isinstance(data, list):
|
|
86
|
+
return [project(item, fields) for item in data]
|
|
87
|
+
if not isinstance(data, dict):
|
|
88
|
+
return data
|
|
89
|
+
out: dict[str, Any] = {}
|
|
90
|
+
for path in fields:
|
|
91
|
+
value, found = get_path(data, path)
|
|
92
|
+
if found:
|
|
93
|
+
out[path] = value
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _split_fields(select: str | None) -> list[str]:
|
|
98
|
+
return [f.strip() for f in (select or "").split(",") if f.strip()]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def results_payload(envelope: dict[str, Any]) -> Any:
|
|
102
|
+
"""What `--results-only` prints.
|
|
103
|
+
|
|
104
|
+
For a paginated op that is the `Page[T]` object, so STYLE's contract
|
|
105
|
+
(`{items, has_more, next_cursor, total}`) holds; otherwise it is `result`
|
|
106
|
+
verbatim.
|
|
107
|
+
"""
|
|
108
|
+
page = envelope.get("page")
|
|
109
|
+
if page is not None:
|
|
110
|
+
return {"items": envelope.get("result") or [], **page}
|
|
111
|
+
if "error" in envelope:
|
|
112
|
+
return envelope["error"]
|
|
113
|
+
return envelope.get("result")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# JSON
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def render_json(
|
|
122
|
+
envelope: dict[str, Any],
|
|
123
|
+
*,
|
|
124
|
+
results_only: bool = False,
|
|
125
|
+
select: str | None = None,
|
|
126
|
+
stream: Any = None,
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Print the envelope, or the projection of it the flags ask for."""
|
|
129
|
+
fields = _split_fields(select)
|
|
130
|
+
if results_only:
|
|
131
|
+
payload: Any = results_payload(envelope)
|
|
132
|
+
if fields:
|
|
133
|
+
payload = project(payload, fields)
|
|
134
|
+
elif fields:
|
|
135
|
+
# `--select` alone keeps the envelope and projects only `result`;
|
|
136
|
+
# v1 projected the envelope and printed `{}`.
|
|
137
|
+
payload = dict(envelope)
|
|
138
|
+
if "result" in payload:
|
|
139
|
+
payload["result"] = project(payload["result"], fields)
|
|
140
|
+
else:
|
|
141
|
+
payload = envelope
|
|
142
|
+
|
|
143
|
+
out = stream or sys.stdout
|
|
144
|
+
json.dump(payload, out, default=str, ensure_ascii=False)
|
|
145
|
+
out.write("\n")
|
|
146
|
+
out.flush()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
# Cells
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def format_cell(value: Any, *, wide: bool = False, width: int = MAX_COLUMN) -> str:
|
|
155
|
+
"""One value, formatted for a human table (the §9 table)."""
|
|
156
|
+
if value is None:
|
|
157
|
+
return "-"
|
|
158
|
+
if isinstance(value, bool):
|
|
159
|
+
return "yes" if value else "no"
|
|
160
|
+
if isinstance(value, str):
|
|
161
|
+
text = fmt_local(value) if _RFC3339.match(value) else value
|
|
162
|
+
elif isinstance(value, dict):
|
|
163
|
+
# A dict in a column means the caller asked for the object rather than
|
|
164
|
+
# a dot path into it; summarise instead of printing a repr.
|
|
165
|
+
text = ", ".join(f"{k}={format_cell(v, wide=True)}" for k, v in value.items())
|
|
166
|
+
elif isinstance(value, Iterable) and not isinstance(value, bytes):
|
|
167
|
+
text = ", ".join(format_cell(item, wide=True) for item in value)
|
|
168
|
+
else:
|
|
169
|
+
text = str(value)
|
|
170
|
+
|
|
171
|
+
text = text.replace("\n", "⏎").replace("\r", "")
|
|
172
|
+
if not wide and len(text) > width:
|
|
173
|
+
text = text[: width - 1] + "…"
|
|
174
|
+
return text
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _rows(data: Any) -> list[dict[str, Any]]:
|
|
178
|
+
if data is None:
|
|
179
|
+
return []
|
|
180
|
+
if isinstance(data, list):
|
|
181
|
+
return [row if isinstance(row, dict) else {"value": row} for row in data]
|
|
182
|
+
if isinstance(data, dict):
|
|
183
|
+
return [data]
|
|
184
|
+
return [{"value": data}]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _default_columns(rows: Sequence[dict[str, Any]]) -> list[str]:
|
|
188
|
+
return list(rows[0].keys()) if rows else ["value"]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def resolve_columns(
|
|
192
|
+
data: Any,
|
|
193
|
+
*,
|
|
194
|
+
spec_columns: Sequence[str] = (),
|
|
195
|
+
override: str | None = None,
|
|
196
|
+
) -> list[str]:
|
|
197
|
+
if override:
|
|
198
|
+
return [c.strip() for c in override.split(",") if c.strip()]
|
|
199
|
+
if spec_columns:
|
|
200
|
+
return list(spec_columns)
|
|
201
|
+
return _default_columns(_rows(data))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
# Plain (TSV)
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _tsv(value: Any) -> str:
|
|
210
|
+
if value is None:
|
|
211
|
+
return ""
|
|
212
|
+
if isinstance(value, bool):
|
|
213
|
+
return "true" if value else "false"
|
|
214
|
+
if isinstance(value, (list, tuple, dict)):
|
|
215
|
+
text = json.dumps(value, ensure_ascii=False, default=str)
|
|
216
|
+
else:
|
|
217
|
+
text = str(value)
|
|
218
|
+
return text.replace("\t", " ").replace("\n", " ").replace("\r", " ")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def render_plain(
|
|
222
|
+
data: Any,
|
|
223
|
+
columns: Sequence[str],
|
|
224
|
+
*,
|
|
225
|
+
no_header: bool = False,
|
|
226
|
+
stream: Any = None,
|
|
227
|
+
) -> None:
|
|
228
|
+
out = stream or sys.stdout
|
|
229
|
+
if not no_header:
|
|
230
|
+
out.write("\t".join(columns) + "\n")
|
|
231
|
+
for row in _rows(data):
|
|
232
|
+
out.write("\t".join(_tsv(get_path(row, c)[0]) for c in columns) + "\n")
|
|
233
|
+
out.flush()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
# Human
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def render_human(
|
|
242
|
+
data: Any,
|
|
243
|
+
columns: Sequence[str],
|
|
244
|
+
*,
|
|
245
|
+
headers: Sequence[str] = (),
|
|
246
|
+
wide: bool = False,
|
|
247
|
+
no_header: bool = False,
|
|
248
|
+
stream: Any = None,
|
|
249
|
+
) -> None:
|
|
250
|
+
"""Table for lists, key/value for a single object."""
|
|
251
|
+
out = stream or sys.stdout
|
|
252
|
+
rows = _rows(data)
|
|
253
|
+
|
|
254
|
+
if isinstance(data, dict) and not columns:
|
|
255
|
+
_render_object(data, wide=wide, stream=out)
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
if not rows:
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
titles = list(headers) if headers else [c.upper() for c in columns]
|
|
262
|
+
cells = [[format_cell(get_path(row, c)[0], wide=wide) for c in columns] for row in rows]
|
|
263
|
+
widths = [len(t) for t in titles]
|
|
264
|
+
for row_cells in cells:
|
|
265
|
+
for index, value in enumerate(row_cells):
|
|
266
|
+
widths[index] = max(widths[index], len(value))
|
|
267
|
+
|
|
268
|
+
gap = " "
|
|
269
|
+
if not no_header:
|
|
270
|
+
header = gap.join(t.ljust(w) for t, w in zip(titles, widths, strict=True)).rstrip()
|
|
271
|
+
out.write((click.style(header, bold=True) if use_color(out) else header) + "\n")
|
|
272
|
+
for row_cells in cells:
|
|
273
|
+
out.write(
|
|
274
|
+
gap.join(v.ljust(w) for v, w in zip(row_cells, widths, strict=True)).rstrip() + "\n"
|
|
275
|
+
)
|
|
276
|
+
out.flush()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _render_object(data: dict[str, Any], *, wide: bool, stream: Any) -> None:
|
|
280
|
+
"""A single object as a key/value block, with maps of records as tables.
|
|
281
|
+
|
|
282
|
+
A mapping whose values are all objects (the exit-code table, a per-account
|
|
283
|
+
health map) is data with a natural key column; printing it as one long
|
|
284
|
+
`k=v` cell would be technically correct and useless.
|
|
285
|
+
"""
|
|
286
|
+
scalars = {k: v for k, v in data.items() if not _is_record_map(v)}
|
|
287
|
+
if scalars:
|
|
288
|
+
width = max(len(k) for k in scalars)
|
|
289
|
+
for key, value in scalars.items():
|
|
290
|
+
stream.write(f"{key.ljust(width)} {format_cell(value, wide=wide)}\n")
|
|
291
|
+
|
|
292
|
+
for key, value in data.items():
|
|
293
|
+
if not _is_record_map(value):
|
|
294
|
+
continue
|
|
295
|
+
inner_columns = sorted({k for record in value.values() for k in record})
|
|
296
|
+
rows = [{"name": name, **record} for name, record in value.items()]
|
|
297
|
+
if scalars:
|
|
298
|
+
stream.write("\n")
|
|
299
|
+
stream.write(f"{key}:\n")
|
|
300
|
+
render_human(rows, ["name", *inner_columns], wide=wide, stream=stream)
|
|
301
|
+
stream.flush()
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _is_record_map(value: Any) -> bool:
|
|
305
|
+
return (
|
|
306
|
+
isinstance(value, dict)
|
|
307
|
+
and bool(value)
|
|
308
|
+
and all(isinstance(item, dict) for item in value.values())
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# ---------------------------------------------------------------------------
|
|
313
|
+
# Entry point
|
|
314
|
+
# ---------------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def render(
|
|
318
|
+
envelope: dict[str, Any],
|
|
319
|
+
*,
|
|
320
|
+
fmt: str = "human",
|
|
321
|
+
results_only: bool = False,
|
|
322
|
+
select: str | None = None,
|
|
323
|
+
spec_columns: Sequence[str] = (),
|
|
324
|
+
headers: Sequence[str] = (),
|
|
325
|
+
columns: str | None = None,
|
|
326
|
+
wide: bool = False,
|
|
327
|
+
no_header: bool = False,
|
|
328
|
+
stream: Any = None,
|
|
329
|
+
) -> None:
|
|
330
|
+
"""Render one response envelope in the mode the flags ask for."""
|
|
331
|
+
if fmt == "json":
|
|
332
|
+
render_json(envelope, results_only=results_only, select=select, stream=stream)
|
|
333
|
+
return
|
|
334
|
+
|
|
335
|
+
data = results_payload(envelope)
|
|
336
|
+
if isinstance(data, dict) and "items" in data and envelope.get("page") is not None:
|
|
337
|
+
data = data["items"]
|
|
338
|
+
fields = _split_fields(select)
|
|
339
|
+
if fields:
|
|
340
|
+
data = project(data, fields)
|
|
341
|
+
spec_columns = fields
|
|
342
|
+
|
|
343
|
+
resolved = resolve_columns(data, spec_columns=spec_columns, override=columns)
|
|
344
|
+
if fmt == "plain":
|
|
345
|
+
render_plain(data, resolved, no_header=no_header, stream=stream)
|
|
346
|
+
return
|
|
347
|
+
|
|
348
|
+
if isinstance(data, dict) and not columns and not fields:
|
|
349
|
+
render_human(data, (), wide=wide, stream=stream)
|
|
350
|
+
return
|
|
351
|
+
render_human(
|
|
352
|
+
data,
|
|
353
|
+
resolved,
|
|
354
|
+
headers=headers if len(headers) == len(resolved) else (),
|
|
355
|
+
wide=wide,
|
|
356
|
+
no_header=no_header,
|
|
357
|
+
stream=stream,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
#: Frames that describe the *stream* rather than something that happened. A
|
|
362
|
+
#: consumer filtering on the event type has to be able to tell them apart,
|
|
363
|
+
#: which is why they are named here rather than recognised by their absence.
|
|
364
|
+
CONTROL_FRAMES = frozenset(
|
|
365
|
+
{"meta", "end", "heartbeat", "lag", "gap", "watching", "cursor", "page", "item"}
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def render_stream(
|
|
370
|
+
frames: Iterable[dict[str, Any]],
|
|
371
|
+
*,
|
|
372
|
+
fmt: str = "human",
|
|
373
|
+
results_only: bool = False,
|
|
374
|
+
select: str | None = None,
|
|
375
|
+
stream: Any = None,
|
|
376
|
+
) -> int:
|
|
377
|
+
"""Print an NDJSON stream as it arrives, and return the exit code.
|
|
378
|
+
|
|
379
|
+
Flushed per frame, deliberately: a `watch` piped into `jq` that only
|
|
380
|
+
appears once a 4 KB buffer fills is indistinguishable from a daemon that
|
|
381
|
+
is not delivering anything.
|
|
382
|
+
|
|
383
|
+
`--results-only` restores v1's line shape — `{"event_type", "chat_id",
|
|
384
|
+
"data"}` — and drops the control frames, because that is what a script
|
|
385
|
+
written against v1's `tlgr watch` already parses (§12.4).
|
|
386
|
+
"""
|
|
387
|
+
out = stream or sys.stdout
|
|
388
|
+
fields = _split_fields(select)
|
|
389
|
+
exit_code = 0
|
|
390
|
+
for frame in frames:
|
|
391
|
+
kind = str(frame.get("type", ""))
|
|
392
|
+
if kind == "end" and not frame.get("ok", True):
|
|
393
|
+
from tlgr.core.errors import EXIT_RETRYABLE
|
|
394
|
+
|
|
395
|
+
body = frame.get("error") or {}
|
|
396
|
+
exit_code = int(body.get("exit_code", EXIT_RETRYABLE))
|
|
397
|
+
click.echo(f"Error: {body.get('message') or 'the stream ended'}", err=True)
|
|
398
|
+
continue
|
|
399
|
+
if results_only:
|
|
400
|
+
if kind in CONTROL_FRAMES:
|
|
401
|
+
continue
|
|
402
|
+
frame = {
|
|
403
|
+
"event_type": kind,
|
|
404
|
+
"chat_id": frame.get("chat_id"),
|
|
405
|
+
"data": frame.get("payload", {}),
|
|
406
|
+
"seq": frame.get("seq"),
|
|
407
|
+
"account": frame.get("account"),
|
|
408
|
+
}
|
|
409
|
+
if fields:
|
|
410
|
+
frame = project(frame, fields)
|
|
411
|
+
_write_frame(frame, out)
|
|
412
|
+
return exit_code
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _write_frame(frame: Any, out: Any) -> None:
|
|
416
|
+
out.write(json.dumps(frame, ensure_ascii=False, default=str) + "\n")
|
|
417
|
+
with contextlib.suppress(AttributeError, ValueError, OSError): # a closed pipe
|
|
418
|
+
out.flush()
|
tlgr/core/__init__.py
ADDED
|
File without changes
|