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/registry.py
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
"""The operation registry: one mapping, one lookup, and the lints that guard it.
|
|
2
|
+
|
|
3
|
+
Every generated artefact (the Click tree, the daemon dispatch table, the JSON
|
|
4
|
+
Schema, the reference docs, the contract tests) reads this module. The lints
|
|
5
|
+
run at import, so a malformed spec cannot ship: an import-time failure is
|
|
6
|
+
noisy in a way a stale doc or a missing example is not.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import inspect
|
|
12
|
+
import re
|
|
13
|
+
import types
|
|
14
|
+
import typing
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import msgspec
|
|
18
|
+
|
|
19
|
+
from tlgr.core.errors import EXIT_EMPTY, EXIT_SUCCESS, UsageError
|
|
20
|
+
from tlgr.models.page import Page
|
|
21
|
+
from tlgr.ops._params import cli_meta
|
|
22
|
+
from tlgr.ops._spec import RATE_CLASSES, OperationSpec, Surface
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"ALIASES",
|
|
26
|
+
"REGISTRY",
|
|
27
|
+
"VERBS",
|
|
28
|
+
"by_group",
|
|
29
|
+
"canonical",
|
|
30
|
+
"get",
|
|
31
|
+
"groups",
|
|
32
|
+
"lint",
|
|
33
|
+
"policy_allows",
|
|
34
|
+
"register",
|
|
35
|
+
"reset",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
REGISTRY: dict[str, OperationSpec] = {}
|
|
39
|
+
ALIASES: dict[str, str] = {}
|
|
40
|
+
|
|
41
|
+
_ID_RE = re.compile(r"^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*){1,2}$")
|
|
42
|
+
|
|
43
|
+
#: STYLE §1's verb vocabulary plus the two documented extensions from
|
|
44
|
+
#: COMMANDS.md (accepted verbs, and protocol/lifecycle verbs that name a
|
|
45
|
+
#: Telegram or daemon operation with no synonym in the list).
|
|
46
|
+
VERBS: frozenset[str] = frozenset(
|
|
47
|
+
[
|
|
48
|
+
"list",
|
|
49
|
+
"get",
|
|
50
|
+
"create",
|
|
51
|
+
"send",
|
|
52
|
+
"edit",
|
|
53
|
+
"set",
|
|
54
|
+
"unset",
|
|
55
|
+
"delete",
|
|
56
|
+
"add",
|
|
57
|
+
"remove",
|
|
58
|
+
"pin",
|
|
59
|
+
"unpin",
|
|
60
|
+
"mute",
|
|
61
|
+
"unmute",
|
|
62
|
+
"archive",
|
|
63
|
+
"unarchive",
|
|
64
|
+
"block",
|
|
65
|
+
"unblock",
|
|
66
|
+
"join",
|
|
67
|
+
"leave",
|
|
68
|
+
"start",
|
|
69
|
+
"stop",
|
|
70
|
+
"open",
|
|
71
|
+
"read",
|
|
72
|
+
"search",
|
|
73
|
+
"download",
|
|
74
|
+
"upload",
|
|
75
|
+
"export",
|
|
76
|
+
"import",
|
|
77
|
+
"enable",
|
|
78
|
+
"disable",
|
|
79
|
+
"toggle",
|
|
80
|
+
"approve",
|
|
81
|
+
"deny",
|
|
82
|
+
"revoke",
|
|
83
|
+
"promote",
|
|
84
|
+
"demote",
|
|
85
|
+
"ban",
|
|
86
|
+
"unban",
|
|
87
|
+
"restrict",
|
|
88
|
+
"transfer",
|
|
89
|
+
"forward",
|
|
90
|
+
"react",
|
|
91
|
+
"vote",
|
|
92
|
+
"close",
|
|
93
|
+
"reopen",
|
|
94
|
+
"hide",
|
|
95
|
+
"unhide",
|
|
96
|
+
"watch",
|
|
97
|
+
"terminate-all",
|
|
98
|
+
"read-all",
|
|
99
|
+
"mark-unread",
|
|
100
|
+
# `chat unread` is v1's spelling of markDialogUnread and AGENT.md
|
|
101
|
+
# documents it; "mark-unread" above is the same operation named the
|
|
102
|
+
# STYLE way, and both resolve to the one op.
|
|
103
|
+
"unread",
|
|
104
|
+
"report",
|
|
105
|
+
"clear",
|
|
106
|
+
"check",
|
|
107
|
+
"accept",
|
|
108
|
+
"decline",
|
|
109
|
+
"convert",
|
|
110
|
+
"press",
|
|
111
|
+
"invoke",
|
|
112
|
+
"translate",
|
|
113
|
+
"link",
|
|
114
|
+
"status",
|
|
115
|
+
"test",
|
|
116
|
+
"reorder",
|
|
117
|
+
"post",
|
|
118
|
+
"end",
|
|
119
|
+
"discard",
|
|
120
|
+
"answer",
|
|
121
|
+
"query",
|
|
122
|
+
"catchup",
|
|
123
|
+
"typing",
|
|
124
|
+
"invite",
|
|
125
|
+
"share",
|
|
126
|
+
"preview",
|
|
127
|
+
"save",
|
|
128
|
+
"catalog",
|
|
129
|
+
"send-code",
|
|
130
|
+
"resend-code",
|
|
131
|
+
"verify-code",
|
|
132
|
+
"sign-up",
|
|
133
|
+
"qr",
|
|
134
|
+
"recover",
|
|
135
|
+
"reset-account",
|
|
136
|
+
"reset",
|
|
137
|
+
"logout",
|
|
138
|
+
"terminate",
|
|
139
|
+
"confirm",
|
|
140
|
+
"accept-qr",
|
|
141
|
+
"change",
|
|
142
|
+
"install",
|
|
143
|
+
"uninstall",
|
|
144
|
+
"restart",
|
|
145
|
+
"reconnect",
|
|
146
|
+
"reload",
|
|
147
|
+
"save-state",
|
|
148
|
+
"replay",
|
|
149
|
+
"decode",
|
|
150
|
+
"ping",
|
|
151
|
+
"catch-up",
|
|
152
|
+
"difference",
|
|
153
|
+
"backfill",
|
|
154
|
+
"purge",
|
|
155
|
+
"upgrade",
|
|
156
|
+
"craft",
|
|
157
|
+
"apply",
|
|
158
|
+
"refulfill",
|
|
159
|
+
"authorize",
|
|
160
|
+
"verify",
|
|
161
|
+
"rate",
|
|
162
|
+
"signal",
|
|
163
|
+
"sync",
|
|
164
|
+
"pay",
|
|
165
|
+
"delete-history",
|
|
166
|
+
"share-phone",
|
|
167
|
+
"raise-hand",
|
|
168
|
+
"tos",
|
|
169
|
+
"whoami",
|
|
170
|
+
"capabilities",
|
|
171
|
+
"exit-codes",
|
|
172
|
+
"init",
|
|
173
|
+
"validate",
|
|
174
|
+
"path",
|
|
175
|
+
"keys",
|
|
176
|
+
"compose",
|
|
177
|
+
"summarize",
|
|
178
|
+
"transcribe",
|
|
179
|
+
"can-message",
|
|
180
|
+
"can-post",
|
|
181
|
+
"reply",
|
|
182
|
+
"nearest",
|
|
183
|
+
"dialog-status",
|
|
184
|
+
"hide-stories",
|
|
185
|
+
"rename",
|
|
186
|
+
# `resolve <kind>` is verb-first (COMMANDS.md conventions): the noun
|
|
187
|
+
# is `resolve` and the tail names what is being resolved.
|
|
188
|
+
"peer",
|
|
189
|
+
"phone",
|
|
190
|
+
"username",
|
|
191
|
+
"info",
|
|
192
|
+
"temp",
|
|
193
|
+
"retry",
|
|
194
|
+
"logs",
|
|
195
|
+
"floods",
|
|
196
|
+
"venue",
|
|
197
|
+
"stealth",
|
|
198
|
+
"schema",
|
|
199
|
+
"parity",
|
|
200
|
+
# `search` is one of COMMANDS.md's verb-first nouns: the tail of
|
|
201
|
+
# `search global` / `search hashtag` names the *scope*, not an
|
|
202
|
+
# action. They are listed here because L1 checks the last segment
|
|
203
|
+
# and has no way to know which nouns are verb-first.
|
|
204
|
+
"global",
|
|
205
|
+
"hashtag",
|
|
206
|
+
# PR-2. Each names a Telegram or tlgr operation with no synonym in the
|
|
207
|
+
# STYLE list: `switch` is not `set` (it changes which account later
|
|
208
|
+
# commands use, not a field on one) and `completion` is a v1 path
|
|
209
|
+
# §12.4 promises stays invocable.
|
|
210
|
+
"switch",
|
|
211
|
+
"completion",
|
|
212
|
+
# PR-12. `profile update` is a path v1 documented, and §12.4 makes a
|
|
213
|
+
# documented path permanent; `profile set` is its STYLE-shaped alias,
|
|
214
|
+
# so both spellings reach the one operation.
|
|
215
|
+
"update",
|
|
216
|
+
]
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
#: Reserved by the global flags and by transport-level pagination (lint L5).
|
|
220
|
+
_RESERVED_FIELDS = frozenset({"account", "json", "plain", "cursor", "limit", "all", "dry_run"})
|
|
221
|
+
|
|
222
|
+
#: A best-effort blocklist for lint L7: an op that says it does not mutate
|
|
223
|
+
#: must not be calling one of these. Waived with tags={"mutating-checked"}.
|
|
224
|
+
_MUTATING_CALLS = frozenset(
|
|
225
|
+
{
|
|
226
|
+
"send_message",
|
|
227
|
+
"send_file",
|
|
228
|
+
"edit_message",
|
|
229
|
+
"delete_messages",
|
|
230
|
+
"forward_messages",
|
|
231
|
+
"send_read_acknowledge",
|
|
232
|
+
"edit_permissions",
|
|
233
|
+
"edit_admin",
|
|
234
|
+
"kick_participant",
|
|
235
|
+
"delete_dialog",
|
|
236
|
+
"pin_message",
|
|
237
|
+
"unpin_message",
|
|
238
|
+
"log_out",
|
|
239
|
+
}
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def register(spec: OperationSpec) -> OperationSpec:
|
|
244
|
+
"""Add *spec* to the registry. Returns it so a module can assign the result."""
|
|
245
|
+
if spec.id in REGISTRY:
|
|
246
|
+
raise ValueError(f"duplicate operation id {spec.id!r}")
|
|
247
|
+
REGISTRY[spec.id] = spec
|
|
248
|
+
for name in spec.names:
|
|
249
|
+
existing = ALIASES.get(name)
|
|
250
|
+
if existing is not None and existing != spec.id:
|
|
251
|
+
raise ValueError(f"alias {name!r} is claimed by both {existing!r} and {spec.id!r}")
|
|
252
|
+
ALIASES[name] = spec.id
|
|
253
|
+
return spec
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def reset() -> None:
|
|
257
|
+
"""Empty the registry. For tests that build a registry of their own."""
|
|
258
|
+
REGISTRY.clear()
|
|
259
|
+
ALIASES.clear()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def canonical(name: str) -> str:
|
|
263
|
+
"""`msg.send` → `message.send`; raises USAGE for anything unknown.
|
|
264
|
+
|
|
265
|
+
Everything that checks policy calls this first, so an allowlist written
|
|
266
|
+
against canonical ids cannot be side-stepped by using an alias (SEC-04).
|
|
267
|
+
"""
|
|
268
|
+
key = name.strip().replace(" ", ".")
|
|
269
|
+
resolved = ALIASES.get(key)
|
|
270
|
+
if resolved is None:
|
|
271
|
+
raise UsageError(f"unknown operation {name!r}", field="op")
|
|
272
|
+
return resolved
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def get(op_id_or_alias: str) -> OperationSpec:
|
|
276
|
+
return REGISTRY[canonical(op_id_or_alias)]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def by_group(group: str) -> list[OperationSpec]:
|
|
280
|
+
return [spec for spec in REGISTRY.values() if spec.group == group]
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def groups() -> list[str]:
|
|
284
|
+
return sorted({spec.group for spec in REGISTRY.values()})
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ---------------------------------------------------------------------------
|
|
288
|
+
# Lints
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _unwrap(annotation: Any) -> Any:
|
|
293
|
+
"""Strip Optional/Annotated down to the underlying type."""
|
|
294
|
+
origin = typing.get_origin(annotation)
|
|
295
|
+
if origin is typing.Annotated:
|
|
296
|
+
return _unwrap(typing.get_args(annotation)[0])
|
|
297
|
+
if origin is typing.Union or origin is types.UnionType:
|
|
298
|
+
args = [a for a in typing.get_args(annotation) if a is not type(None)]
|
|
299
|
+
return _unwrap(args[0]) if args else annotation
|
|
300
|
+
return annotation
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _response_item_type(response: Any) -> Any:
|
|
304
|
+
"""The Struct a response's columns are projected out of."""
|
|
305
|
+
if response is None:
|
|
306
|
+
return None
|
|
307
|
+
origin = typing.get_origin(response)
|
|
308
|
+
if origin is not None and typing.get_args(response):
|
|
309
|
+
# Page[Message] and list[Message] both project out of Message.
|
|
310
|
+
return _response_item_type(typing.get_args(response)[0])
|
|
311
|
+
return response
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _column_exists(item_type: Any, path: str) -> bool:
|
|
315
|
+
"""Walk a dot path through Struct annotations."""
|
|
316
|
+
current = item_type
|
|
317
|
+
for segment in path.split("."):
|
|
318
|
+
if not (isinstance(current, type) and issubclass(current, msgspec.Struct)):
|
|
319
|
+
return False
|
|
320
|
+
hints = typing.get_type_hints(current, include_extras=False)
|
|
321
|
+
if segment not in hints:
|
|
322
|
+
return False
|
|
323
|
+
current = _unwrap(hints[segment])
|
|
324
|
+
inner = typing.get_origin(current)
|
|
325
|
+
if inner in (list, tuple, set):
|
|
326
|
+
args = typing.get_args(current)
|
|
327
|
+
current = args[0] if args else Any
|
|
328
|
+
return True
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _field_metas(request: type[msgspec.Struct]) -> list[tuple[str, Any]]:
|
|
332
|
+
"""(name, type node) for every request field, in declaration order."""
|
|
333
|
+
info = msgspec.inspect.type_info(request)
|
|
334
|
+
fields = getattr(info, "fields", ())
|
|
335
|
+
return [(f.name, f.type) for f in fields]
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _double_meta_fields(request: type[msgspec.Struct]) -> list[str]:
|
|
339
|
+
"""Fields carrying two `msgspec.Meta` annotations (lint L14).
|
|
340
|
+
|
|
341
|
+
Only the first Meta's `extra`/`description` reaches the generator, so the
|
|
342
|
+
second one's help text would silently vanish (§4.2).
|
|
343
|
+
"""
|
|
344
|
+
bad: list[str] = []
|
|
345
|
+
for name, annotation in typing.get_type_hints(request, include_extras=True).items():
|
|
346
|
+
if typing.get_origin(annotation) is typing.Annotated:
|
|
347
|
+
metas = [a for a in typing.get_args(annotation)[1:] if isinstance(a, msgspec.Meta)]
|
|
348
|
+
if len(metas) > 1:
|
|
349
|
+
bad.append(name)
|
|
350
|
+
return bad
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _lint_spec(spec: OperationSpec, problems: list[str]) -> None:
|
|
354
|
+
def bad(message: str) -> None:
|
|
355
|
+
problems.append(f"{spec.id}: {message}")
|
|
356
|
+
|
|
357
|
+
# L1 — id shape and verb vocabulary.
|
|
358
|
+
if not _ID_RE.match(spec.id):
|
|
359
|
+
bad("id must be two or three lowercase dotted segments")
|
|
360
|
+
elif spec.verb not in VERBS:
|
|
361
|
+
bad(f"verb {spec.verb!r} is not in the STYLE.md vocabulary")
|
|
362
|
+
|
|
363
|
+
# L3 — request/response types.
|
|
364
|
+
if not (isinstance(spec.request, type) and issubclass(spec.request, msgspec.Struct)):
|
|
365
|
+
bad("request must be a msgspec Struct")
|
|
366
|
+
return
|
|
367
|
+
item = _response_item_type(spec.response)
|
|
368
|
+
is_struct = isinstance(item, type) and issubclass(item, msgspec.Struct)
|
|
369
|
+
is_dict = item is dict or typing.get_origin(item) is dict
|
|
370
|
+
if item is not None and not is_struct and not is_dict:
|
|
371
|
+
bad("response must be a Struct, list[Struct], Page[Struct], dict or None")
|
|
372
|
+
|
|
373
|
+
# L4 — positional indices contiguous from 0, at most one variadic, last.
|
|
374
|
+
positions: list[int] = []
|
|
375
|
+
variadic_at: int | None = None
|
|
376
|
+
for name, annotation in _field_metas(spec.request):
|
|
377
|
+
cli = cli_meta(annotation)
|
|
378
|
+
if cli.get("role") != "arg":
|
|
379
|
+
continue
|
|
380
|
+
position = int(cli.get("pos", 0))
|
|
381
|
+
positions.append(position)
|
|
382
|
+
if cli.get("variadic"):
|
|
383
|
+
if variadic_at is not None:
|
|
384
|
+
bad("more than one variadic positional")
|
|
385
|
+
variadic_at = position
|
|
386
|
+
# L15 — an UNSET tri-state cannot be positional; there is no way to
|
|
387
|
+
# spell "not supplied" in a positional slot.
|
|
388
|
+
if "Unset" in str(typing.get_type_hints(spec.request, include_extras=True).get(name, "")):
|
|
389
|
+
bad(f"field {name!r} is an Unset tri-state and cannot be positional")
|
|
390
|
+
if positions and sorted(positions) != list(range(len(positions))):
|
|
391
|
+
bad(f"positional indices must be contiguous from 0, got {sorted(positions)}")
|
|
392
|
+
if variadic_at is not None and positions and variadic_at != max(positions):
|
|
393
|
+
bad("the variadic positional must be last")
|
|
394
|
+
|
|
395
|
+
# L5 — reserved field names.
|
|
396
|
+
reserved = _RESERVED_FIELDS.intersection(name for name, _ in _field_metas(spec.request))
|
|
397
|
+
if reserved:
|
|
398
|
+
bad(f"request fields {sorted(reserved)} are reserved for global flags/pagination")
|
|
399
|
+
|
|
400
|
+
# L14 — one Meta per field.
|
|
401
|
+
for name in _double_meta_fields(spec.request):
|
|
402
|
+
bad(f"field {name!r} carries two msgspec.Meta annotations; merge them")
|
|
403
|
+
|
|
404
|
+
# L6 — pagination and streaming shapes.
|
|
405
|
+
if spec.paginated is not None and typing.get_origin(spec.response) is not Page:
|
|
406
|
+
bad("paginated ops must declare response=Page[...]")
|
|
407
|
+
if spec.stream and not inspect.isasyncgenfunction(spec.impl):
|
|
408
|
+
bad("stream ops must be implemented as an async generator")
|
|
409
|
+
|
|
410
|
+
# L7 — a non-mutating op should not be calling a mutating method.
|
|
411
|
+
if not spec.mutating and "mutating-checked" not in spec.tags:
|
|
412
|
+
try:
|
|
413
|
+
source = inspect.getsource(spec.impl)
|
|
414
|
+
except (OSError, TypeError):
|
|
415
|
+
source = ""
|
|
416
|
+
for call in _MUTATING_CALLS:
|
|
417
|
+
if f".{call}(" in source:
|
|
418
|
+
bad(f"declares mutating=False but calls {call}()")
|
|
419
|
+
|
|
420
|
+
# L8 — destructive implies mutating.
|
|
421
|
+
if spec.destructive and not spec.mutating:
|
|
422
|
+
bad("destructive ops must also be mutating")
|
|
423
|
+
|
|
424
|
+
# L9 — every op documents itself.
|
|
425
|
+
if not spec.summary:
|
|
426
|
+
bad("summary is empty")
|
|
427
|
+
if spec.example is None:
|
|
428
|
+
bad("example is missing")
|
|
429
|
+
if not spec.example_args:
|
|
430
|
+
bad("example_args is missing")
|
|
431
|
+
|
|
432
|
+
# L11 — declared columns exist on the response.
|
|
433
|
+
if spec.columns and item is not None:
|
|
434
|
+
for column in spec.columns:
|
|
435
|
+
if not _column_exists(item, column):
|
|
436
|
+
bad(f"column {column!r} does not exist on the response type")
|
|
437
|
+
if spec.headers and len(spec.headers) != len(spec.columns):
|
|
438
|
+
bad("headers must line up one-to-one with columns")
|
|
439
|
+
|
|
440
|
+
# L12 — sane limits.
|
|
441
|
+
if not 5 <= spec.timeout_s <= 900:
|
|
442
|
+
bad(f"timeout_s {spec.timeout_s} is outside 5..900")
|
|
443
|
+
if spec.rate_class not in RATE_CLASSES:
|
|
444
|
+
bad(f"unknown rate_class {spec.rate_class!r}")
|
|
445
|
+
if spec.empty_exit not in (EXIT_SUCCESS, EXIT_EMPTY):
|
|
446
|
+
bad("empty_exit must be 0 or 3")
|
|
447
|
+
|
|
448
|
+
# L13 — every op is either catalogued, explicitly infrastructure, or
|
|
449
|
+
# registered-and-refused. The third case arrived with PR-10: an operation
|
|
450
|
+
# whose method needs a newer API layer is registered so that
|
|
451
|
+
# `agent capabilities` can answer "unavailable in this build" rather than
|
|
452
|
+
# "no such command" — and it must NOT claim catalog coverage for something
|
|
453
|
+
# it cannot do, which is why the tag exists instead of a partial cover.
|
|
454
|
+
exempt = {"infrastructure", "not-supported"} & set(spec.tags)
|
|
455
|
+
if not spec.covers and not spec.covers_partial and not exempt:
|
|
456
|
+
bad("declares no catalog coverage and is not tagged infrastructure/not-supported")
|
|
457
|
+
|
|
458
|
+
if spec.surface is Surface.LOCAL and spec.needs_account:
|
|
459
|
+
bad("local ops must set needs_account=False")
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def lint() -> list[str]:
|
|
463
|
+
"""Return every problem in the registry; empty means the registry is sound."""
|
|
464
|
+
problems: list[str] = []
|
|
465
|
+
seen_names: dict[str, str] = {}
|
|
466
|
+
# L16 — every path prefix that is a *group* in the generated tree. An
|
|
467
|
+
# alias naming one of these would be placed as a command where a group
|
|
468
|
+
# already stands, replacing it and taking every command inside it with it:
|
|
469
|
+
# `config app` as an alias silently deletes `config app get`.
|
|
470
|
+
groups: dict[str, str] = {}
|
|
471
|
+
for spec in REGISTRY.values():
|
|
472
|
+
path = spec.path
|
|
473
|
+
for depth in range(1, len(path)):
|
|
474
|
+
groups.setdefault(".".join(path[:depth]), spec.id)
|
|
475
|
+
|
|
476
|
+
for spec in REGISTRY.values():
|
|
477
|
+
_lint_spec(spec, problems)
|
|
478
|
+
# L2 — aliases and legacy paths are unique and disjoint from ids.
|
|
479
|
+
for name in spec.names[1:]:
|
|
480
|
+
if name in REGISTRY:
|
|
481
|
+
problems.append(f"{spec.id}: alias {name!r} collides with an operation id")
|
|
482
|
+
owner = seen_names.get(name)
|
|
483
|
+
if owner is not None:
|
|
484
|
+
problems.append(f"{spec.id}: alias {name!r} is also claimed by {owner!r}")
|
|
485
|
+
seen_names[name] = spec.id
|
|
486
|
+
owner = groups.get(name)
|
|
487
|
+
if owner is not None:
|
|
488
|
+
problems.append(
|
|
489
|
+
f"{spec.id}: alias {name!r} names a command group (from {owner!r}); "
|
|
490
|
+
"placing it would replace the group and delete the commands in it"
|
|
491
|
+
)
|
|
492
|
+
return problems
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def policy_allows(allowlist: str, op_id: str) -> bool:
|
|
496
|
+
"""Is *op_id* permitted by an `--enable-commands` / `[policy] allow` list?
|
|
497
|
+
|
|
498
|
+
Entries are canonicalised before comparison, so an allowlist written
|
|
499
|
+
against ids cannot be side-stepped by invoking an alias, and a bare group
|
|
500
|
+
name (`message`) allows every operation in it. This is SEC-04's fix, and
|
|
501
|
+
it lives in the registry because the CLI and the daemon must reach the
|
|
502
|
+
same verdict from the same data.
|
|
503
|
+
"""
|
|
504
|
+
entries = {e.strip().lower() for e in allowlist.replace(" ", ",").split(",") if e.strip()}
|
|
505
|
+
if not entries or "*" in entries or "all" in entries:
|
|
506
|
+
return True
|
|
507
|
+
canonical_id = ALIASES.get(op_id, op_id).lower()
|
|
508
|
+
for entry in entries:
|
|
509
|
+
resolved = ALIASES.get(entry.replace(" ", "."), entry)
|
|
510
|
+
if resolved == canonical_id or canonical_id.startswith(f"{resolved}."):
|
|
511
|
+
return True
|
|
512
|
+
return False
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def lint_or_raise() -> None:
|
|
516
|
+
"""Called at the end of `ops/__init__.py`; a broken spec fails the import."""
|
|
517
|
+
problems = lint()
|
|
518
|
+
if problems:
|
|
519
|
+
raise RuntimeError("operation registry lint failed:\n " + "\n ".join(problems))
|
tlgr/schema.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""JSON Schema (draft 2020-12) for every registered operation.
|
|
2
|
+
|
|
3
|
+
v1 built this by walking the Click tree and hand-maintaining an
|
|
4
|
+
`EXAMPLE_RESPONSES` dict, which covered 26 of 93 commands and went stale
|
|
5
|
+
(COR-33). Here the request and response schemas come from the same Structs the
|
|
6
|
+
daemon decodes with, and the example is the one the contract test validates,
|
|
7
|
+
so "documented" and "true" are the same artefact.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import typing
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import msgspec
|
|
16
|
+
|
|
17
|
+
from tlgr import __version__
|
|
18
|
+
from tlgr.ops._params import cli_meta
|
|
19
|
+
from tlgr.ops._spec import OperationSpec
|
|
20
|
+
from tlgr.registry import REGISTRY
|
|
21
|
+
|
|
22
|
+
__all__ = ["SCHEMA_VERSION", "build_schema", "op_schema", "schema_components"]
|
|
23
|
+
|
|
24
|
+
#: Bumped from 1: the document now carries per-op request/response schemas and
|
|
25
|
+
#: a `$defs` section, and `example_response` is generated rather than curated.
|
|
26
|
+
SCHEMA_VERSION = 2
|
|
27
|
+
|
|
28
|
+
_DIALECT = "https://json-schema.org/draft/2020-12/schema"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _response_types(spec: OperationSpec) -> list[Any]:
|
|
32
|
+
return [spec.response] if spec.response is not None else []
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def schema_components(specs: list[OperationSpec]) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
36
|
+
"""`({op id: {"request": …, "response": …}}, $defs)` for *specs*.
|
|
37
|
+
|
|
38
|
+
One `schema_components` call for the whole registry, so a model shared by
|
|
39
|
+
forty operations is defined once and referenced forty times.
|
|
40
|
+
"""
|
|
41
|
+
types: list[Any] = []
|
|
42
|
+
slots: list[tuple[str, str]] = []
|
|
43
|
+
for spec in specs:
|
|
44
|
+
types.append(spec.request)
|
|
45
|
+
slots.append((spec.id, "request"))
|
|
46
|
+
for response in _response_types(spec):
|
|
47
|
+
types.append(response)
|
|
48
|
+
slots.append((spec.id, "response"))
|
|
49
|
+
|
|
50
|
+
if not types:
|
|
51
|
+
return {}, {}
|
|
52
|
+
schemas, defs = msgspec.json.schema_components(types, ref_template="#/$defs/{name}")
|
|
53
|
+
out: dict[str, dict[str, Any]] = {}
|
|
54
|
+
for (op_id, slot), schema in zip(slots, schemas, strict=True):
|
|
55
|
+
out.setdefault(op_id, {})[slot] = schema
|
|
56
|
+
return out, defs
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _params(spec: OperationSpec) -> list[dict[str, Any]]:
|
|
60
|
+
"""The CLI shape of each request field, straight off its annotation."""
|
|
61
|
+
info = msgspec.inspect.type_info(spec.request)
|
|
62
|
+
out: list[dict[str, Any]] = []
|
|
63
|
+
for field in getattr(info, "fields", ()):
|
|
64
|
+
cli = cli_meta(field.type)
|
|
65
|
+
entry: dict[str, Any] = {
|
|
66
|
+
"name": field.name,
|
|
67
|
+
"type": "argument" if cli.get("role") == "arg" else "option",
|
|
68
|
+
"required": field.required,
|
|
69
|
+
}
|
|
70
|
+
if cli.get("role") == "arg":
|
|
71
|
+
entry["position"] = cli.get("pos", 0)
|
|
72
|
+
if cli.get("variadic"):
|
|
73
|
+
entry["variadic"] = True
|
|
74
|
+
else:
|
|
75
|
+
entry["flags"] = cli.get("flags") or [f"--{field.name.replace('_', '-')}"]
|
|
76
|
+
for key in ("metavar", "envvar", "hidden", "secret", "kind", "choices"):
|
|
77
|
+
if cli.get(key):
|
|
78
|
+
entry[key] = cli[key]
|
|
79
|
+
description = getattr(field.type, "extra_json_schema", {}) or {}
|
|
80
|
+
if description.get("description"):
|
|
81
|
+
entry["help"] = description["description"]
|
|
82
|
+
if field.default is not msgspec.NODEFAULT:
|
|
83
|
+
entry["default"] = field.default
|
|
84
|
+
out.append(entry)
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def op_schema(spec: OperationSpec, shapes: dict[str, Any]) -> dict[str, Any]:
|
|
89
|
+
"""One operation as the schema document describes it."""
|
|
90
|
+
entry: dict[str, Any] = {
|
|
91
|
+
"id": spec.id,
|
|
92
|
+
"path": spec.cli_path,
|
|
93
|
+
"summary": spec.summary,
|
|
94
|
+
"surface": spec.surface.value,
|
|
95
|
+
"mutating": spec.mutating,
|
|
96
|
+
"destructive": spec.destructive,
|
|
97
|
+
"stream": spec.stream,
|
|
98
|
+
"needs_account": spec.needs_account,
|
|
99
|
+
"empty_exit": spec.empty_exit,
|
|
100
|
+
"params": _params(spec),
|
|
101
|
+
"request_schema": shapes.get("request", {}),
|
|
102
|
+
}
|
|
103
|
+
if spec.description:
|
|
104
|
+
entry["description"] = spec.description
|
|
105
|
+
if spec.aliases:
|
|
106
|
+
entry["aliases"] = list(spec.aliases)
|
|
107
|
+
if spec.legacy_paths:
|
|
108
|
+
entry["legacy_paths"] = list(spec.legacy_paths)
|
|
109
|
+
if spec.paginated is not None:
|
|
110
|
+
entry["paginated"] = spec.paginated.value
|
|
111
|
+
if spec.columns:
|
|
112
|
+
entry["columns"] = list(spec.columns)
|
|
113
|
+
if "response" in shapes:
|
|
114
|
+
entry["response_schema"] = shapes["response"]
|
|
115
|
+
if spec.example is not None:
|
|
116
|
+
# v1 called this `example_response`; keeping the key means an agent
|
|
117
|
+
# written against schema_version 1 still finds the example.
|
|
118
|
+
entry["example_response"] = msgspec.to_builtins(spec.example)
|
|
119
|
+
if spec.example_args:
|
|
120
|
+
entry["example_args"] = spec.example_args
|
|
121
|
+
if spec.covers:
|
|
122
|
+
entry["covers"] = list(spec.covers)
|
|
123
|
+
if spec.deprecated:
|
|
124
|
+
entry["deprecated"] = spec.deprecated
|
|
125
|
+
return entry
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def build_schema(
|
|
129
|
+
*,
|
|
130
|
+
path: tuple[str, ...] = (),
|
|
131
|
+
command: dict[str, Any] | None = None,
|
|
132
|
+
include_hidden: bool = False,
|
|
133
|
+
) -> dict[str, Any]:
|
|
134
|
+
"""The whole `tlgr schema` document.
|
|
135
|
+
|
|
136
|
+
*command* is the Click command tree, passed in rather than imported: this
|
|
137
|
+
module sits below `cli/` and must not reach up into it (§2.2).
|
|
138
|
+
"""
|
|
139
|
+
prefix = ".".join(path)
|
|
140
|
+
specs = [
|
|
141
|
+
spec
|
|
142
|
+
for spec in REGISTRY.values()
|
|
143
|
+
if not prefix or spec.id == prefix or spec.id.startswith(f"{prefix}.")
|
|
144
|
+
]
|
|
145
|
+
if not include_hidden:
|
|
146
|
+
specs = [spec for spec in specs if not spec.deprecated]
|
|
147
|
+
specs.sort(key=lambda s: s.id)
|
|
148
|
+
|
|
149
|
+
shapes, defs = schema_components(specs)
|
|
150
|
+
document: dict[str, Any] = {
|
|
151
|
+
"$schema": _DIALECT,
|
|
152
|
+
"schema_version": SCHEMA_VERSION,
|
|
153
|
+
"build": __version__,
|
|
154
|
+
"ops": {spec.id: op_schema(spec, shapes.get(spec.id, {})) for spec in specs},
|
|
155
|
+
}
|
|
156
|
+
if defs:
|
|
157
|
+
document["$defs"] = defs
|
|
158
|
+
if command is not None:
|
|
159
|
+
document["command"] = command
|
|
160
|
+
return document
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def response_type_name(spec: OperationSpec) -> str:
|
|
164
|
+
"""A human label for the response shape, used by the docs generator."""
|
|
165
|
+
response = spec.response
|
|
166
|
+
if response is None:
|
|
167
|
+
return "none"
|
|
168
|
+
origin = typing.get_origin(response)
|
|
169
|
+
args = typing.get_args(response)
|
|
170
|
+
if origin is not None and args:
|
|
171
|
+
inner = getattr(args[0], "__name__", str(args[0]))
|
|
172
|
+
return f"{getattr(origin, '__name__', str(origin))}[{inner}]"
|
|
173
|
+
return getattr(response, "__name__", str(response))
|