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/gen.py
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
"""`OperationSpec` → `click.Command`. One factory, zero per-command modules.
|
|
2
|
+
|
|
3
|
+
This is where the registry pays for itself: the argument list, the flags, the
|
|
4
|
+
help text, the example epilogue, the pagination flags, the dry-run
|
|
5
|
+
short-circuit, the policy check and the rendering all come from the spec, so
|
|
6
|
+
none of them can be forgotten for one command and remembered for another —
|
|
7
|
+
which is exactly how v1 ended up honouring `--dry-run` in 9 commands and
|
|
8
|
+
ignoring it in 12 (COR-17).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import types
|
|
15
|
+
import typing
|
|
16
|
+
import uuid
|
|
17
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any, Literal
|
|
20
|
+
|
|
21
|
+
import click
|
|
22
|
+
import msgspec
|
|
23
|
+
|
|
24
|
+
from tlgr.cli import errors as cli_errors
|
|
25
|
+
from tlgr.cli import params as ptypes
|
|
26
|
+
from tlgr.cli import render as renderer
|
|
27
|
+
from tlgr.cli.confirm import confirm
|
|
28
|
+
from tlgr.cli.globals import (
|
|
29
|
+
CliState,
|
|
30
|
+
add_global_options,
|
|
31
|
+
env_bool,
|
|
32
|
+
resolve_account,
|
|
33
|
+
state_from,
|
|
34
|
+
)
|
|
35
|
+
from tlgr.core.errors import (
|
|
36
|
+
EXIT_EMPTY,
|
|
37
|
+
EXIT_INDETERMINATE,
|
|
38
|
+
DaemonError,
|
|
39
|
+
PermissionError_,
|
|
40
|
+
UsageError,
|
|
41
|
+
)
|
|
42
|
+
from tlgr.core.pagination import DATE_OFFSET_KINDS
|
|
43
|
+
from tlgr.models.base import UNSET
|
|
44
|
+
from tlgr.models.peer import PeerRef
|
|
45
|
+
from tlgr.ops._params import cli_meta
|
|
46
|
+
from tlgr.ops._spec import OperationSpec, Surface
|
|
47
|
+
from tlgr.registry import REGISTRY, policy_allows
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"LocalContext",
|
|
51
|
+
"build_click_tree",
|
|
52
|
+
"build_command",
|
|
53
|
+
"run_live",
|
|
54
|
+
"run_op",
|
|
55
|
+
"set_dispatcher",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Execution
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class LocalContext:
|
|
66
|
+
"""The `OpContext` a `Surface.LOCAL` operation runs against."""
|
|
67
|
+
|
|
68
|
+
account: str = ""
|
|
69
|
+
dry_run: bool = False
|
|
70
|
+
request_id: str = ""
|
|
71
|
+
warnings: list[str] = field(default_factory=list)
|
|
72
|
+
command_tree: Callable[[Sequence[str], bool], dict[str, Any] | None] | None = None
|
|
73
|
+
#: `--limit`/`--cursor`/`--all` are transport-level and never request
|
|
74
|
+
#: fields (registry lint L5), so a local paginated operation reads them
|
|
75
|
+
#: off the context exactly as a daemon-side one does.
|
|
76
|
+
limit: int | None = None
|
|
77
|
+
cursor: str | None = None
|
|
78
|
+
fetch_all: bool = False
|
|
79
|
+
|
|
80
|
+
def warn(self, message: str) -> None:
|
|
81
|
+
self.warnings.append(message)
|
|
82
|
+
|
|
83
|
+
def emit(self, event_type: str, payload: dict[str, Any], **kwargs: Any) -> None:
|
|
84
|
+
"""No-op: a local operation runs in the CLI, where there is no bus."""
|
|
85
|
+
|
|
86
|
+
def mark_already(self) -> None:
|
|
87
|
+
"""No-op: a local operation has no envelope meta to flag."""
|
|
88
|
+
|
|
89
|
+
def mark_indeterminate(self, reason: str = "") -> None:
|
|
90
|
+
"""No-op: a local operation answers from data it already holds."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
Dispatcher = Callable[[OperationSpec, msgspec.Struct, CliState], dict[str, Any]]
|
|
94
|
+
StreamDispatcher = Callable[[OperationSpec, msgspec.Struct, CliState], Iterator[dict[str, Any]]]
|
|
95
|
+
_dispatch: Dispatcher | None = None
|
|
96
|
+
_stream_dispatch: StreamDispatcher | None = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def set_dispatcher(
|
|
100
|
+
dispatcher: Dispatcher | None, stream_dispatcher: StreamDispatcher | None = None
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Install the daemon transport.
|
|
103
|
+
|
|
104
|
+
Stage A registers only local operations; the daemon surface arrives with
|
|
105
|
+
the transport, and until then asking for it is a daemon error rather than
|
|
106
|
+
a traceback.
|
|
107
|
+
|
|
108
|
+
The stream dispatcher is separate because a live stream cannot be folded
|
|
109
|
+
into an envelope: `watch` that only prints when it ends is not a watch.
|
|
110
|
+
"""
|
|
111
|
+
global _dispatch, _stream_dispatch
|
|
112
|
+
_dispatch = dispatcher
|
|
113
|
+
_stream_dispatch = stream_dispatcher
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def run_live(spec: OperationSpec, request: msgspec.Struct, state: CliState) -> int:
|
|
117
|
+
"""Drive a `live-stream` operation, printing frames as they arrive."""
|
|
118
|
+
if state.enable_commands and not policy_allows(state.enable_commands, spec.id):
|
|
119
|
+
raise PermissionError_(
|
|
120
|
+
f"operation {spec.id!r} is not enabled (add it to --enable-commands to allow it)",
|
|
121
|
+
)
|
|
122
|
+
if _stream_dispatch is None:
|
|
123
|
+
raise DaemonError(f"{spec.id} streams from the daemon, and no transport is wired up")
|
|
124
|
+
state.account = resolve_account(state) if spec.needs_account else state.account
|
|
125
|
+
frames = _stream_dispatch(spec, request, state)
|
|
126
|
+
return renderer.render_stream(
|
|
127
|
+
frames,
|
|
128
|
+
fmt=state.fmt,
|
|
129
|
+
results_only=state.results_only,
|
|
130
|
+
select=state.select,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _command_tree(path: Sequence[str], include_hidden: bool) -> dict[str, Any] | None:
|
|
135
|
+
"""Describe the Click tree for `tlgr schema`, imported lazily.
|
|
136
|
+
|
|
137
|
+
`cli/__init__` imports this module, so reaching back into it has to happen
|
|
138
|
+
at call time rather than at import time.
|
|
139
|
+
"""
|
|
140
|
+
from tlgr.cli.introspect import describe
|
|
141
|
+
|
|
142
|
+
return describe(tuple(path), include_hidden=include_hidden)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def run_op(spec: OperationSpec, request: msgspec.Struct, state: CliState) -> dict[str, Any]:
|
|
146
|
+
"""Execute one operation and return its response envelope."""
|
|
147
|
+
if state.enable_commands and not policy_allows(state.enable_commands, spec.id):
|
|
148
|
+
raise PermissionError_(
|
|
149
|
+
f"operation {spec.id!r} is not enabled (add it to --enable-commands to allow it)",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# A local operation must not go looking for an account at all: reading the
|
|
153
|
+
# active alias to attach it to `tlgr schema` would be a lie about what ran.
|
|
154
|
+
account = resolve_account(state) if spec.needs_account else state.account
|
|
155
|
+
request_id = uuid.uuid4().hex
|
|
156
|
+
|
|
157
|
+
# The dry-run short-circuit lives here, before any implementation runs, so
|
|
158
|
+
# an operation cannot forget to honour it.
|
|
159
|
+
if state.dry_run and spec.mutating:
|
|
160
|
+
return {
|
|
161
|
+
"ok": True,
|
|
162
|
+
"op": spec.id,
|
|
163
|
+
"account": account or None,
|
|
164
|
+
"result": {"dry_run": True, "would": spec.id, "request": msgspec.to_builtins(request)},
|
|
165
|
+
"meta": {"request_id": request_id, "dry_run": True},
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if spec.surface is Surface.DAEMON:
|
|
169
|
+
if _dispatch is None:
|
|
170
|
+
raise DaemonError(
|
|
171
|
+
f"{spec.id} runs in the daemon, and this build has no transport wired up yet"
|
|
172
|
+
)
|
|
173
|
+
return _dispatch(spec, request, state)
|
|
174
|
+
|
|
175
|
+
context = LocalContext(
|
|
176
|
+
account=account,
|
|
177
|
+
dry_run=state.dry_run,
|
|
178
|
+
request_id=request_id,
|
|
179
|
+
command_tree=_command_tree,
|
|
180
|
+
limit=state.limit,
|
|
181
|
+
cursor=state.cursor,
|
|
182
|
+
fetch_all=state.fetch_all,
|
|
183
|
+
)
|
|
184
|
+
result = asyncio.run(spec.impl(context, request))
|
|
185
|
+
body = msgspec.to_builtins(result)
|
|
186
|
+
envelope: dict[str, Any] = {
|
|
187
|
+
"ok": True,
|
|
188
|
+
"op": spec.id,
|
|
189
|
+
"result": body,
|
|
190
|
+
"meta": {"request_id": request_id, "warnings": context.warnings},
|
|
191
|
+
}
|
|
192
|
+
if spec.paginated is not None and isinstance(body, dict):
|
|
193
|
+
# The same projection the daemon does (`daemon/dispatch._envelope`).
|
|
194
|
+
# Without it a paginated *local* operation would hand back the page
|
|
195
|
+
# object where every other one hands back the items, and `--select`
|
|
196
|
+
# would need a different path depending on where the op happened to
|
|
197
|
+
# run — which is exactly the kind of difference the registry exists to
|
|
198
|
+
# remove.
|
|
199
|
+
envelope["result"] = body.get("items", [])
|
|
200
|
+
envelope["page"] = {
|
|
201
|
+
"has_more": bool(body.get("has_more")),
|
|
202
|
+
"next_cursor": body.get("next_cursor"),
|
|
203
|
+
"total": body.get("total"),
|
|
204
|
+
}
|
|
205
|
+
if account:
|
|
206
|
+
envelope["account"] = account
|
|
207
|
+
return envelope
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
# Field → Click parameter
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _unwrap(annotation: Any) -> tuple[Any, bool, bool]:
|
|
216
|
+
"""Return (base type, optional?, unset?) for a request annotation."""
|
|
217
|
+
optional = False
|
|
218
|
+
unset = False
|
|
219
|
+
origin = typing.get_origin(annotation)
|
|
220
|
+
if origin is typing.Annotated:
|
|
221
|
+
return _unwrap(typing.get_args(annotation)[0])
|
|
222
|
+
if origin is typing.Union or origin is types.UnionType:
|
|
223
|
+
args = list(typing.get_args(annotation))
|
|
224
|
+
if type(None) in args:
|
|
225
|
+
optional = True
|
|
226
|
+
args = [a for a in args if a is not type(None)]
|
|
227
|
+
if msgspec.UnsetType in args:
|
|
228
|
+
unset = True
|
|
229
|
+
args = [a for a in args if a is not msgspec.UnsetType]
|
|
230
|
+
base, inner_optional, inner_unset = _unwrap(args[0]) if args else (str, False, False)
|
|
231
|
+
return base, optional or inner_optional, unset or inner_unset
|
|
232
|
+
return annotation, optional, unset
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _click_type(base: Any, kind: str, choices: Sequence[str]) -> Any:
|
|
236
|
+
if choices:
|
|
237
|
+
return click.Choice(list(choices))
|
|
238
|
+
typed = ptypes.for_kind(kind)
|
|
239
|
+
if typed is not None:
|
|
240
|
+
return typed
|
|
241
|
+
if base is PeerRef:
|
|
242
|
+
return ptypes.PEER
|
|
243
|
+
if typing.get_origin(base) is Literal:
|
|
244
|
+
return click.Choice([str(v) for v in typing.get_args(base)])
|
|
245
|
+
if base is int:
|
|
246
|
+
return click.INT
|
|
247
|
+
if base is float:
|
|
248
|
+
return click.FLOAT
|
|
249
|
+
if base is bool:
|
|
250
|
+
return click.BOOL
|
|
251
|
+
return click.STRING
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _flag_name(name: str) -> str:
|
|
255
|
+
return "--" + name.replace("_", "-")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _describe(annotation: Any) -> str:
|
|
259
|
+
if typing.get_origin(annotation) is typing.Annotated:
|
|
260
|
+
for extra in typing.get_args(annotation)[1:]:
|
|
261
|
+
if isinstance(extra, msgspec.Meta) and extra.description:
|
|
262
|
+
return str(extra.description)
|
|
263
|
+
return ""
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _meta_of(annotation: Any) -> dict[str, Any]:
|
|
267
|
+
if typing.get_origin(annotation) is typing.Annotated:
|
|
268
|
+
for extra in typing.get_args(annotation)[1:]:
|
|
269
|
+
if isinstance(extra, msgspec.Meta):
|
|
270
|
+
return cli_meta(extra)
|
|
271
|
+
return {}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@dataclass(frozen=True)
|
|
275
|
+
class _Field:
|
|
276
|
+
name: str
|
|
277
|
+
annotation: Any
|
|
278
|
+
default: Any
|
|
279
|
+
required: bool
|
|
280
|
+
cli: dict[str, Any]
|
|
281
|
+
base: Any
|
|
282
|
+
optional: bool
|
|
283
|
+
unset: bool
|
|
284
|
+
container: Any
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _fields(request: type[msgspec.Struct]) -> list[_Field]:
|
|
288
|
+
hints = typing.get_type_hints(request, include_extras=True)
|
|
289
|
+
info = msgspec.inspect.type_info(request)
|
|
290
|
+
out: list[_Field] = []
|
|
291
|
+
for spec_field in getattr(info, "fields", ()):
|
|
292
|
+
annotation = hints[spec_field.name]
|
|
293
|
+
base, optional, unset = _unwrap(annotation)
|
|
294
|
+
container = typing.get_origin(base)
|
|
295
|
+
if container in (list, tuple, set):
|
|
296
|
+
args = typing.get_args(base)
|
|
297
|
+
base = args[0] if args else str
|
|
298
|
+
default = spec_field.default
|
|
299
|
+
if default is msgspec.NODEFAULT:
|
|
300
|
+
factory = spec_field.default_factory
|
|
301
|
+
default = factory() if factory is not msgspec.NODEFAULT else None
|
|
302
|
+
out.append(
|
|
303
|
+
_Field(
|
|
304
|
+
name=spec_field.name,
|
|
305
|
+
annotation=annotation,
|
|
306
|
+
default=None if default is UNSET else default,
|
|
307
|
+
required=spec_field.required,
|
|
308
|
+
cli=_meta_of(annotation),
|
|
309
|
+
base=base,
|
|
310
|
+
optional=optional,
|
|
311
|
+
unset=unset,
|
|
312
|
+
container=container,
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
return out
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _parameter(f: _Field) -> click.Parameter:
|
|
319
|
+
"""One request field as one Click parameter (the §4.2 table)."""
|
|
320
|
+
help_text = _describe(f.annotation)
|
|
321
|
+
kind = str(f.cli.get("kind", ""))
|
|
322
|
+
choices = f.cli.get("choices") or ()
|
|
323
|
+
click_type = _click_type(f.base, kind, choices)
|
|
324
|
+
|
|
325
|
+
if f.cli.get("role") == "arg":
|
|
326
|
+
variadic = bool(f.cli.get("variadic"))
|
|
327
|
+
return click.Argument(
|
|
328
|
+
[f.name],
|
|
329
|
+
required=bool(f.cli.get("required", True)) and not variadic,
|
|
330
|
+
nargs=-1 if variadic else 1,
|
|
331
|
+
type=click_type,
|
|
332
|
+
# Click appends the `...` that says "repeatable" itself, but only
|
|
333
|
+
# when it derives the metavar; an explicit one loses it.
|
|
334
|
+
metavar=(f.cli.get("metavar") or None) if not variadic else None,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
flags = list(f.cli.get("flags") or [])
|
|
338
|
+
if f.base is bool and not f.cli.get("count"):
|
|
339
|
+
# False default → a plain flag. True or tri-state → a paired flag,
|
|
340
|
+
# because "leave it alone" and "turn it off" are different requests.
|
|
341
|
+
# A default of True, or a tri-state, needs the negative half: "leave
|
|
342
|
+
# it alone" and "turn it off" are different requests, and only a
|
|
343
|
+
# paired flag can say both.
|
|
344
|
+
paired = f.default is True or (f.optional and f.default is None)
|
|
345
|
+
negative = f"--no-{f.name.replace('_', '-')}"
|
|
346
|
+
if not flags:
|
|
347
|
+
flags = [_flag_name(f.name)]
|
|
348
|
+
if paired and not any("/" in flag for flag in flags):
|
|
349
|
+
flags = [f"{flags[0]}/{negative}", *flags[1:]]
|
|
350
|
+
return click.Option(
|
|
351
|
+
[*flags, f.name],
|
|
352
|
+
is_flag=True,
|
|
353
|
+
default=f.default if not f.optional else None,
|
|
354
|
+
help=help_text or None,
|
|
355
|
+
hidden=bool(f.cli.get("hidden")),
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
if not flags:
|
|
359
|
+
flags = [_flag_name(f.name)]
|
|
360
|
+
# Click wants the long option first so it can derive the parameter name;
|
|
361
|
+
# `opt("-n", "--limit")` puts the short one first for readability.
|
|
362
|
+
ordered = sorted(flags, key=lambda flag: not flag.startswith("--"))
|
|
363
|
+
return click.Option(
|
|
364
|
+
[*ordered, f.name],
|
|
365
|
+
type=click_type,
|
|
366
|
+
default=f.default if not (f.unset or f.optional) else None,
|
|
367
|
+
required=f.required and not f.optional,
|
|
368
|
+
multiple=f.container in (list, tuple, set),
|
|
369
|
+
metavar=f.cli.get("metavar") or None,
|
|
370
|
+
envvar=f.cli.get("envvar") or None,
|
|
371
|
+
show_default=f.default not in (None, "", [], False),
|
|
372
|
+
help=help_text or None,
|
|
373
|
+
hidden=bool(f.cli.get("hidden")),
|
|
374
|
+
count=bool(f.cli.get("count")),
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _secret_options(f: _Field) -> list[click.Parameter]:
|
|
379
|
+
"""A secret never gets a value-taking flag (STYLE §3)."""
|
|
380
|
+
base = f.name.replace("_", "-")
|
|
381
|
+
label = f.name.replace("_", " ")
|
|
382
|
+
return [
|
|
383
|
+
click.Option(
|
|
384
|
+
[f"--{base}-env", f"{f.name}_env"],
|
|
385
|
+
metavar="VAR",
|
|
386
|
+
default=None,
|
|
387
|
+
help=f"Read the {label} from this environment variable.",
|
|
388
|
+
),
|
|
389
|
+
click.Option(
|
|
390
|
+
[f"--{base}-stdin", f"{f.name}_stdin"],
|
|
391
|
+
is_flag=True,
|
|
392
|
+
default=False,
|
|
393
|
+
help=f"Read the {label} from stdin.",
|
|
394
|
+
),
|
|
395
|
+
click.Option(
|
|
396
|
+
[f"--{base}-file", f"{f.name}_file"],
|
|
397
|
+
metavar="PATH",
|
|
398
|
+
default=None,
|
|
399
|
+
help=f"Read the {label} from this file.",
|
|
400
|
+
),
|
|
401
|
+
]
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _pagination_options(
|
|
405
|
+
spec: OperationSpec, declared: frozenset[str] = frozenset()
|
|
406
|
+
) -> list[click.Parameter]:
|
|
407
|
+
options: list[click.Parameter] = [
|
|
408
|
+
click.Option(
|
|
409
|
+
["-n", "--limit", "limit"], type=int, default=None, help="Maximum items to return."
|
|
410
|
+
),
|
|
411
|
+
click.Option(
|
|
412
|
+
["--cursor", "cursor"], default=None, metavar="TOKEN", help="Continue a page."
|
|
413
|
+
),
|
|
414
|
+
click.Option(["--all", "fetch_all"], is_flag=True, default=False, help="Walk every page."),
|
|
415
|
+
]
|
|
416
|
+
if spec.paginated in DATE_OFFSET_KINDS:
|
|
417
|
+
# Injected only when the op does not declare them itself. An op that
|
|
418
|
+
# does needs them *inside* its request — the injected pair is
|
|
419
|
+
# transport-level and would be dropped on the way to the daemon, so
|
|
420
|
+
# two parameters with one name would silently shadow the real one.
|
|
421
|
+
options += [
|
|
422
|
+
click.Option(
|
|
423
|
+
["--since", "since"], type=ptypes.DATETIME, default=None, help="Only after this."
|
|
424
|
+
)
|
|
425
|
+
for name in ("since",)
|
|
426
|
+
if name not in declared
|
|
427
|
+
]
|
|
428
|
+
options += [
|
|
429
|
+
click.Option(
|
|
430
|
+
["--until", "until"], type=ptypes.DATETIME, default=None, help="Only before this."
|
|
431
|
+
)
|
|
432
|
+
for name in ("until",)
|
|
433
|
+
if name not in declared
|
|
434
|
+
]
|
|
435
|
+
return options
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
# ---------------------------------------------------------------------------
|
|
439
|
+
# Command assembly
|
|
440
|
+
# ---------------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _build_request(spec: OperationSpec, fields: list[_Field], values: dict[str, Any]) -> Any:
|
|
444
|
+
kwargs: dict[str, Any] = {}
|
|
445
|
+
for f in fields:
|
|
446
|
+
if f.cli.get("secret"):
|
|
447
|
+
from tlgr.ops._params import read_secret
|
|
448
|
+
|
|
449
|
+
secret = read_secret(
|
|
450
|
+
f.name,
|
|
451
|
+
env=values.get(f"{f.name}_env"),
|
|
452
|
+
stdin=bool(values.get(f"{f.name}_stdin")),
|
|
453
|
+
file=values.get(f"{f.name}_file"),
|
|
454
|
+
default_env=str(f.cli.get("envvar") or ""),
|
|
455
|
+
)
|
|
456
|
+
if secret is not None:
|
|
457
|
+
kwargs[f.name] = secret
|
|
458
|
+
continue
|
|
459
|
+
|
|
460
|
+
if f.name not in values:
|
|
461
|
+
continue
|
|
462
|
+
value = values[f.name]
|
|
463
|
+
if value is None:
|
|
464
|
+
# An absent option on an Unset field sends nothing at all, which
|
|
465
|
+
# is what makes "leave alone" expressible.
|
|
466
|
+
continue
|
|
467
|
+
if f.container in (list, tuple, set):
|
|
468
|
+
if not value:
|
|
469
|
+
continue
|
|
470
|
+
value = list(value) if f.container is not tuple else tuple(value)
|
|
471
|
+
kwargs[f.name] = value
|
|
472
|
+
try:
|
|
473
|
+
request = spec.request(**kwargs)
|
|
474
|
+
# Constructing a Struct does not run msgspec's constraints (ge, le,
|
|
475
|
+
# pattern, min_length) — only decoding does. Round-tripping is what
|
|
476
|
+
# makes `--limit-hint 500` fail in the CLI instead of in the daemon.
|
|
477
|
+
return msgspec.convert(msgspec.to_builtins(request), type=spec.request)
|
|
478
|
+
except (msgspec.ValidationError, TypeError, ValueError) as exc:
|
|
479
|
+
field = None
|
|
480
|
+
message = str(exc)
|
|
481
|
+
if " - at `$." in message:
|
|
482
|
+
field = message.split(" - at `$.", 1)[1].rstrip("`")
|
|
483
|
+
raise UsageError(message, field=field) from exc
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def build_command(
|
|
487
|
+
spec: OperationSpec, *, name: str | None = None, hidden: bool = False
|
|
488
|
+
) -> click.Command:
|
|
489
|
+
"""Turn one spec into one Click command."""
|
|
490
|
+
fields = _fields(spec.request)
|
|
491
|
+
parameters: list[click.Parameter] = []
|
|
492
|
+
positional = sorted(
|
|
493
|
+
(f for f in fields if f.cli.get("role") == "arg"), key=lambda f: int(f.cli.get("pos", 0))
|
|
494
|
+
)
|
|
495
|
+
for f in positional:
|
|
496
|
+
parameters.append(_parameter(f))
|
|
497
|
+
for f in fields:
|
|
498
|
+
if f.cli.get("role") == "arg":
|
|
499
|
+
continue
|
|
500
|
+
parameters.extend(_secret_options(f) if f.cli.get("secret") else [_parameter(f)])
|
|
501
|
+
|
|
502
|
+
if spec.paginated is not None:
|
|
503
|
+
parameters.extend(_pagination_options(spec, frozenset(f.name for f in fields)))
|
|
504
|
+
parameters.append(
|
|
505
|
+
# `-n` belongs to --limit here, so --dry-run gets no short form.
|
|
506
|
+
click.Option(["--dry-run"], is_flag=True, default=None, help="Do not actually do it.")
|
|
507
|
+
)
|
|
508
|
+
else:
|
|
509
|
+
parameters.append(
|
|
510
|
+
click.Option(
|
|
511
|
+
["--dry-run", "-n"], is_flag=True, default=None, help="Do not actually do it."
|
|
512
|
+
)
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
def callback(**values: Any) -> None:
|
|
516
|
+
ctx = click.get_current_context()
|
|
517
|
+
state = state_from(ctx, values)
|
|
518
|
+
if spec.destructive and not state.dry_run:
|
|
519
|
+
confirm(
|
|
520
|
+
f"{spec.summary} — this cannot be undone.",
|
|
521
|
+
force=state.force,
|
|
522
|
+
no_input=state.no_input,
|
|
523
|
+
hint=f"pass --yes to confirm {spec.id}",
|
|
524
|
+
)
|
|
525
|
+
request = _build_request(spec, fields, values)
|
|
526
|
+
if "live-stream" in spec.tags:
|
|
527
|
+
# No envelope: the frames *are* the output, and they arrive over
|
|
528
|
+
# minutes or hours.
|
|
529
|
+
ctx.exit(run_live(spec, request, state))
|
|
530
|
+
envelope = run_op(spec, request, state)
|
|
531
|
+
# A schema document is JSON whether or not anybody asked: there is no
|
|
532
|
+
# table shape for it, and v1 printed JSON here unconditionally. When
|
|
533
|
+
# nobody asked for --json it is also printed bare, which is the exact
|
|
534
|
+
# document v1 wrote; the envelope appears only once JSON is requested.
|
|
535
|
+
json_only = "json-only" in spec.tags
|
|
536
|
+
fmt = "json" if json_only else state.fmt
|
|
537
|
+
if "text" in spec.tags and fmt != "json":
|
|
538
|
+
# An op tagged `text` produces one blob meant to be piped or
|
|
539
|
+
# pasted — a completion script, a key, a certificate. A key/value
|
|
540
|
+
# table of one very long cell is technically a rendering and
|
|
541
|
+
# practically unusable, so the `text` field is printed verbatim.
|
|
542
|
+
body = envelope.get("result") or {}
|
|
543
|
+
click.echo(body.get("text", "") if isinstance(body, dict) else body)
|
|
544
|
+
return
|
|
545
|
+
renderer.render(
|
|
546
|
+
envelope,
|
|
547
|
+
fmt=fmt,
|
|
548
|
+
results_only=state.results_only or (json_only and state.fmt != "json"),
|
|
549
|
+
select=state.select,
|
|
550
|
+
spec_columns=spec.columns,
|
|
551
|
+
headers=spec.headers,
|
|
552
|
+
columns=state.columns,
|
|
553
|
+
wide=state.wide,
|
|
554
|
+
no_header=state.no_header,
|
|
555
|
+
)
|
|
556
|
+
# An operation that could not *establish* its answer still returns
|
|
557
|
+
# the partial result; the non-zero status is what makes a caller
|
|
558
|
+
# gating on it fail closed rather than read "unknown" as "no".
|
|
559
|
+
if (envelope.get("meta") or {}).get("indeterminate"):
|
|
560
|
+
ctx.exit(EXIT_INDETERMINATE)
|
|
561
|
+
result = envelope.get("result")
|
|
562
|
+
if spec.empty_exit == EXIT_EMPTY and not result:
|
|
563
|
+
ctx.exit(EXIT_EMPTY)
|
|
564
|
+
|
|
565
|
+
command = OpCommand(
|
|
566
|
+
spec,
|
|
567
|
+
name=name or spec.verb,
|
|
568
|
+
params=parameters,
|
|
569
|
+
callback=callback,
|
|
570
|
+
help=_help_text(spec),
|
|
571
|
+
short_help=spec.summary,
|
|
572
|
+
epilog=_epilog(spec),
|
|
573
|
+
hidden=hidden or bool(spec.deprecated),
|
|
574
|
+
)
|
|
575
|
+
return add_global_options(command)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
class OpCommand(click.Command):
|
|
579
|
+
"""A generated command that reports its own failures.
|
|
580
|
+
|
|
581
|
+
Errors are caught here rather than in the root group so that the new
|
|
582
|
+
rules — the `{"ok": false, "error": {...}}` envelope, JSON usage errors
|
|
583
|
+
(UX-02), the exit code from the §7.2 table — apply to registry-generated
|
|
584
|
+
commands only. Every unmigrated v1 command keeps v1's error output until
|
|
585
|
+
its own group PR moves it.
|
|
586
|
+
"""
|
|
587
|
+
|
|
588
|
+
def __init__(self, spec: OperationSpec, *args: Any, **kwargs: Any) -> None:
|
|
589
|
+
super().__init__(*args, **kwargs)
|
|
590
|
+
self.spec = spec
|
|
591
|
+
|
|
592
|
+
def _wants_json(self, ctx: click.Context | None, args: Sequence[str] = ()) -> bool:
|
|
593
|
+
if "--json" in args:
|
|
594
|
+
return True
|
|
595
|
+
if "--plain" in args:
|
|
596
|
+
return False
|
|
597
|
+
obj = (ctx.obj if ctx is not None else None) or {}
|
|
598
|
+
return bool(obj.get("json") or obj.get("use_json")) or env_bool("TLGR_JSON")
|
|
599
|
+
|
|
600
|
+
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
|
|
601
|
+
try:
|
|
602
|
+
return super().parse_args(ctx, list(args))
|
|
603
|
+
except click.UsageError as exc:
|
|
604
|
+
if not self._wants_json(ctx, args):
|
|
605
|
+
raise
|
|
606
|
+
# v1 let Click print English to stderr and exited 2 with no JSON
|
|
607
|
+
# at all, which is UX-02: an agent got nothing to parse.
|
|
608
|
+
ctx.exit(cli_errors.handle(exc, use_json=True, op=self.spec.id))
|
|
609
|
+
raise
|
|
610
|
+
|
|
611
|
+
def invoke(self, ctx: click.Context) -> Any:
|
|
612
|
+
try:
|
|
613
|
+
return super().invoke(ctx)
|
|
614
|
+
except (click.exceptions.Exit, click.Abort):
|
|
615
|
+
raise
|
|
616
|
+
except KeyboardInterrupt as exc:
|
|
617
|
+
ctx.exit(cli_errors.handle(exc, use_json=False, op=self.spec.id))
|
|
618
|
+
except BaseException as exc:
|
|
619
|
+
use_json = self._wants_json(ctx) or bool(ctx.params.get("use_json"))
|
|
620
|
+
ctx.exit(cli_errors.handle(exc, use_json=use_json, op=self.spec.id))
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _help_text(spec: OperationSpec) -> str:
|
|
624
|
+
body = spec.summary.rstrip(".") + "."
|
|
625
|
+
if spec.description:
|
|
626
|
+
body += "\n\n" + spec.description
|
|
627
|
+
if spec.deprecated:
|
|
628
|
+
body += f"\n\nDEPRECATED: {spec.deprecated}"
|
|
629
|
+
return body
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _epilog(spec: OperationSpec) -> str:
|
|
633
|
+
if not spec.example_args:
|
|
634
|
+
return ""
|
|
635
|
+
return f"Example:\n\n tlgr {spec.example_args}"
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
# ---------------------------------------------------------------------------
|
|
639
|
+
# The tree
|
|
640
|
+
# ---------------------------------------------------------------------------
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _place(root: dict[str, Any], path: Sequence[str], command: click.Command) -> None:
|
|
644
|
+
"""Attach *command* at *path*, creating the groups it needs on the way."""
|
|
645
|
+
*groups, leaf = path
|
|
646
|
+
container: Any = root
|
|
647
|
+
walked: list[str] = []
|
|
648
|
+
for name in groups:
|
|
649
|
+
walked.append(name)
|
|
650
|
+
existing = (
|
|
651
|
+
container.get(name) if isinstance(container, dict) else container.commands.get(name)
|
|
652
|
+
)
|
|
653
|
+
if existing is None or not isinstance(existing, click.Group):
|
|
654
|
+
existing = click.Group(name=name, help=f"{' '.join(walked)} operations.")
|
|
655
|
+
if isinstance(container, dict):
|
|
656
|
+
container[name] = existing
|
|
657
|
+
else:
|
|
658
|
+
container.add_command(existing, name)
|
|
659
|
+
container = existing
|
|
660
|
+
command.name = leaf
|
|
661
|
+
if isinstance(container, dict):
|
|
662
|
+
container[leaf] = command
|
|
663
|
+
else:
|
|
664
|
+
container.add_command(command, leaf)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def build_click_tree(
|
|
668
|
+
registry: dict[str, OperationSpec] | None = None,
|
|
669
|
+
) -> dict[str, click.Command]:
|
|
670
|
+
"""Every registered operation as a nested Click tree, keyed by top-level name.
|
|
671
|
+
|
|
672
|
+
Canonical paths are visible. Aliases are hidden duplicates — they exist so
|
|
673
|
+
that a habit keeps working, not so that `--help` lists the same command
|
|
674
|
+
twice. `legacy_paths` stay visible: they are the v1 paths the docs name,
|
|
675
|
+
and §12.4 promises none of them disappears.
|
|
676
|
+
"""
|
|
677
|
+
specs = (registry or REGISTRY).values()
|
|
678
|
+
root: dict[str, Any] = {}
|
|
679
|
+
|
|
680
|
+
for spec in sorted(specs, key=lambda s: s.id):
|
|
681
|
+
_place(root, spec.path, build_command(spec))
|
|
682
|
+
for spec in sorted(specs, key=lambda s: s.id):
|
|
683
|
+
for alias in spec.aliases:
|
|
684
|
+
_place(root, tuple(alias.split(".")), build_command(spec, hidden=True))
|
|
685
|
+
for legacy in spec.legacy_paths:
|
|
686
|
+
path = tuple(legacy.replace(".", " ").split())
|
|
687
|
+
if path == spec.path:
|
|
688
|
+
continue
|
|
689
|
+
_place(root, path, build_command(spec))
|
|
690
|
+
return root
|