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.
Files changed (192) hide show
  1. tlgr/__init__.py +3 -0
  2. tlgr/__main__.py +6 -0
  3. tlgr/actions/__init__.py +45 -0
  4. tlgr/actions/forward.py +74 -0
  5. tlgr/actions/reply.py +32 -0
  6. tlgr/cli/__init__.py +259 -0
  7. tlgr/cli/confirm.py +55 -0
  8. tlgr/cli/errors.py +84 -0
  9. tlgr/cli/gen.py +690 -0
  10. tlgr/cli/globals.py +273 -0
  11. tlgr/cli/introspect.py +170 -0
  12. tlgr/cli/params.py +189 -0
  13. tlgr/cli/render.py +418 -0
  14. tlgr/core/__init__.py +0 -0
  15. tlgr/core/accounts.py +384 -0
  16. tlgr/core/config.py +358 -0
  17. tlgr/core/custom_tl.py +170 -0
  18. tlgr/core/errors.py +687 -0
  19. tlgr/core/eventtypes.py +1170 -0
  20. tlgr/core/identity.py +127 -0
  21. tlgr/core/launchd.py +122 -0
  22. tlgr/core/logging.py +194 -0
  23. tlgr/core/media.py +134 -0
  24. tlgr/core/output.py +251 -0
  25. tlgr/core/pagination.py +227 -0
  26. tlgr/core/paths.py +360 -0
  27. tlgr/core/peers.py +427 -0
  28. tlgr/core/process.py +138 -0
  29. tlgr/core/signing.py +38 -0
  30. tlgr/core/systemd.py +96 -0
  31. tlgr/core/telethon_compat.py +295 -0
  32. tlgr/core/text.py +211 -0
  33. tlgr/core/timefmt.py +199 -0
  34. tlgr/core/tl.py +98 -0
  35. tlgr/daemon/__init__.py +0 -0
  36. tlgr/daemon/app.py +869 -0
  37. tlgr/daemon/dispatch.py +446 -0
  38. tlgr/daemon/events.py +723 -0
  39. tlgr/daemon/files.py +431 -0
  40. tlgr/daemon/idle.py +119 -0
  41. tlgr/daemon/jobs.py +68 -0
  42. tlgr/daemon/main.py +161 -0
  43. tlgr/daemon/peercred.py +75 -0
  44. tlgr/daemon/policy.py +113 -0
  45. tlgr/daemon/preauth.py +366 -0
  46. tlgr/daemon/ratelimit.py +391 -0
  47. tlgr/daemon/server.py +24 -0
  48. tlgr/daemon/session.py +648 -0
  49. tlgr/daemon/sessions.py +274 -0
  50. tlgr/daemon/singleton.py +114 -0
  51. tlgr/daemon/stream.py +193 -0
  52. tlgr/daemon/transfers.py +219 -0
  53. tlgr/daemon/webhook.py +390 -0
  54. tlgr/data/catalog_index.json +1 -0
  55. tlgr/data/parity_waivers.toml +90 -0
  56. tlgr/filters/__init__.py +42 -0
  57. tlgr/filters/compose.py +121 -0
  58. tlgr/filters/content.py +85 -0
  59. tlgr/filters/context.py +114 -0
  60. tlgr/filters/message.py +161 -0
  61. tlgr/filters/temporal.py +87 -0
  62. tlgr/filters/user.py +36 -0
  63. tlgr/gateway/__init__.py +1 -0
  64. tlgr/gateway/config.py +161 -0
  65. tlgr/gateway/engine.py +215 -0
  66. tlgr/gateway/event.py +22 -0
  67. tlgr/jobs/__init__.py +0 -0
  68. tlgr/jobs/base.py +81 -0
  69. tlgr/jobs/client.py +37 -0
  70. tlgr/models/__init__.py +1220 -0
  71. tlgr/models/admin.py +744 -0
  72. tlgr/models/auth.py +510 -0
  73. tlgr/models/base.py +81 -0
  74. tlgr/models/bot.py +576 -0
  75. tlgr/models/business.py +265 -0
  76. tlgr/models/call.py +586 -0
  77. tlgr/models/config.py +101 -0
  78. tlgr/models/contact.py +481 -0
  79. tlgr/models/daemon.py +336 -0
  80. tlgr/models/dialog.py +626 -0
  81. tlgr/models/envelope.py +68 -0
  82. tlgr/models/error.py +30 -0
  83. tlgr/models/event.py +79 -0
  84. tlgr/models/export.py +66 -0
  85. tlgr/models/gift.py +275 -0
  86. tlgr/models/inline.py +84 -0
  87. tlgr/models/location.py +115 -0
  88. tlgr/models/media.py +507 -0
  89. tlgr/models/message.py +584 -0
  90. tlgr/models/net.py +232 -0
  91. tlgr/models/notify.py +105 -0
  92. tlgr/models/page.py +32 -0
  93. tlgr/models/payment.py +172 -0
  94. tlgr/models/peer.py +400 -0
  95. tlgr/models/poll.py +119 -0
  96. tlgr/models/premium.py +161 -0
  97. tlgr/models/privacy.py +93 -0
  98. tlgr/models/profile.py +217 -0
  99. tlgr/models/reaction.py +160 -0
  100. tlgr/models/resolve.py +175 -0
  101. tlgr/models/settings.py +103 -0
  102. tlgr/models/stars.py +101 -0
  103. tlgr/models/sticker.py +243 -0
  104. tlgr/models/story.py +467 -0
  105. tlgr/models/sync.py +105 -0
  106. tlgr/models/todo.py +36 -0
  107. tlgr/models/webapp.py +89 -0
  108. tlgr/ops/__init__.py +63 -0
  109. tlgr/ops/_admin.py +313 -0
  110. tlgr/ops/_auth.py +599 -0
  111. tlgr/ops/_bots.py +586 -0
  112. tlgr/ops/_calls.py +535 -0
  113. tlgr/ops/_common.py +160 -0
  114. tlgr/ops/_layer.py +46 -0
  115. tlgr/ops/_media.py +592 -0
  116. tlgr/ops/_params.py +212 -0
  117. tlgr/ops/_rights.py +402 -0
  118. tlgr/ops/_send.py +593 -0
  119. tlgr/ops/_serialize.py +667 -0
  120. tlgr/ops/_settings.py +306 -0
  121. tlgr/ops/_spec.py +167 -0
  122. tlgr/ops/_story.py +743 -0
  123. tlgr/ops/account.py +2604 -0
  124. tlgr/ops/agent.py +937 -0
  125. tlgr/ops/auth.py +1282 -0
  126. tlgr/ops/bot.py +4880 -0
  127. tlgr/ops/business.py +1520 -0
  128. tlgr/ops/call.py +1610 -0
  129. tlgr/ops/chat.py +4025 -0
  130. tlgr/ops/chat_admin.py +929 -0
  131. tlgr/ops/chat_extra.py +1061 -0
  132. tlgr/ops/chat_invite.py +716 -0
  133. tlgr/ops/chat_manage.py +1691 -0
  134. tlgr/ops/chat_member.py +1357 -0
  135. tlgr/ops/chat_stats.py +902 -0
  136. tlgr/ops/chat_topic.py +905 -0
  137. tlgr/ops/conference.py +791 -0
  138. tlgr/ops/config.py +1698 -0
  139. tlgr/ops/contact.py +2330 -0
  140. tlgr/ops/daemon.py +1397 -0
  141. tlgr/ops/draft.py +299 -0
  142. tlgr/ops/emoji.py +343 -0
  143. tlgr/ops/events.py +1327 -0
  144. tlgr/ops/export.py +596 -0
  145. tlgr/ops/folder.py +1322 -0
  146. tlgr/ops/gif.py +522 -0
  147. tlgr/ops/gift.py +1546 -0
  148. tlgr/ops/giveaway.py +541 -0
  149. tlgr/ops/inline.py +773 -0
  150. tlgr/ops/job.py +799 -0
  151. tlgr/ops/location.py +917 -0
  152. tlgr/ops/media.py +4495 -0
  153. tlgr/ops/message.py +3769 -0
  154. tlgr/ops/net.py +536 -0
  155. tlgr/ops/notify.py +840 -0
  156. tlgr/ops/passport.py +464 -0
  157. tlgr/ops/payment.py +907 -0
  158. tlgr/ops/poll.py +1078 -0
  159. tlgr/ops/premium.py +488 -0
  160. tlgr/ops/privacy.py +794 -0
  161. tlgr/ops/profile.py +1481 -0
  162. tlgr/ops/proxy.py +750 -0
  163. tlgr/ops/reaction.py +1475 -0
  164. tlgr/ops/resolve.py +1140 -0
  165. tlgr/ops/search.py +521 -0
  166. tlgr/ops/settings.py +1066 -0
  167. tlgr/ops/stars.py +594 -0
  168. tlgr/ops/sticker.py +1602 -0
  169. tlgr/ops/story.py +3216 -0
  170. tlgr/ops/sync.py +788 -0
  171. tlgr/ops/todo.py +514 -0
  172. tlgr/ops/user.py +1406 -0
  173. tlgr/ops/vc.py +2351 -0
  174. tlgr/ops/webapp.py +717 -0
  175. tlgr/ops/webhook.py +418 -0
  176. tlgr/parity.py +386 -0
  177. tlgr/processors/__init__.py +125 -0
  178. tlgr/processors/regex.py +26 -0
  179. tlgr/processors/text.py +56 -0
  180. tlgr/registry.py +519 -0
  181. tlgr/schema.py +173 -0
  182. tlgr/transport/__init__.py +30 -0
  183. tlgr/transport/autostart.py +293 -0
  184. tlgr/transport/client.py +805 -0
  185. tlgr/transport/ndjson.py +44 -0
  186. tlgr/version.py +31 -0
  187. tlgr_cli-2.0.1.dist-info/METADATA +957 -0
  188. tlgr_cli-2.0.1.dist-info/RECORD +192 -0
  189. tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
  190. tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
  191. tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
  192. 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))