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/globals.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""The global flags, attached to every generated command *and* to the root.
|
|
2
|
+
|
|
3
|
+
`tlgr chat list --json` and `tlgr --json chat list` must both work. In v1 only
|
|
4
|
+
the second did, and the first exited 2 with "No such option" (UX-01), which is
|
|
5
|
+
the single most common thing an agent gets wrong on its first call. The flags
|
|
6
|
+
are therefore declared once here and attached in both places; a command-level
|
|
7
|
+
value wins over a root-level one.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import click
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"GLOBAL_FLAG_NAMES",
|
|
20
|
+
"CliState",
|
|
21
|
+
"add_global_options",
|
|
22
|
+
"env_bool",
|
|
23
|
+
"resolve_account",
|
|
24
|
+
"state_from",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def env_bool(key: str) -> bool:
|
|
29
|
+
return os.environ.get(key, "").lower() in ("1", "true", "yes", "y", "on")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _env_or(key: str, fallback: str = "") -> str:
|
|
33
|
+
return os.environ.get(key, "") or fallback
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
#: Parameter names the generator must never collide with when deriving flags
|
|
37
|
+
#: from request fields.
|
|
38
|
+
GLOBAL_FLAG_NAMES = frozenset(
|
|
39
|
+
{
|
|
40
|
+
"use_json",
|
|
41
|
+
"use_plain",
|
|
42
|
+
"account",
|
|
43
|
+
"results_only",
|
|
44
|
+
"select_fields",
|
|
45
|
+
"dry_run",
|
|
46
|
+
"force",
|
|
47
|
+
"no_input",
|
|
48
|
+
"flood_wait_max",
|
|
49
|
+
"timeout",
|
|
50
|
+
"verbose",
|
|
51
|
+
"no_daemon_restart",
|
|
52
|
+
"enable_commands",
|
|
53
|
+
"columns",
|
|
54
|
+
"wide",
|
|
55
|
+
"no_header",
|
|
56
|
+
# Pagination is transport-level, not part of any request struct
|
|
57
|
+
# (registry lint L5 forbids those field names), so the generated
|
|
58
|
+
# flags land here and travel to the daemon beside the request.
|
|
59
|
+
"limit",
|
|
60
|
+
"cursor",
|
|
61
|
+
"fetch_all",
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class CliState:
|
|
68
|
+
"""The merged view of the global flags for one invocation."""
|
|
69
|
+
|
|
70
|
+
fmt: str = "human"
|
|
71
|
+
account: str = ""
|
|
72
|
+
results_only: bool = False
|
|
73
|
+
select: str | None = None
|
|
74
|
+
dry_run: bool = False
|
|
75
|
+
force: bool = False
|
|
76
|
+
no_input: bool = False
|
|
77
|
+
flood_wait_max: int | None = None
|
|
78
|
+
timeout: float | None = None
|
|
79
|
+
verbose: bool = False
|
|
80
|
+
no_daemon_restart: bool = False
|
|
81
|
+
enable_commands: str = ""
|
|
82
|
+
columns: str | None = None
|
|
83
|
+
wide: bool = False
|
|
84
|
+
no_header: bool = False
|
|
85
|
+
limit: int | None = None
|
|
86
|
+
cursor: str | None = None
|
|
87
|
+
fetch_all: bool = False
|
|
88
|
+
warnings: list[str] = field(default_factory=list)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def json(self) -> bool:
|
|
92
|
+
return self.fmt == "json"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _flag_options() -> list[Any]:
|
|
96
|
+
"""The option decorators, built fresh so Click never shares a Parameter."""
|
|
97
|
+
return [
|
|
98
|
+
click.option(
|
|
99
|
+
"--json", "use_json", is_flag=True, default=None, help="Output JSON to stdout."
|
|
100
|
+
),
|
|
101
|
+
click.option(
|
|
102
|
+
"--plain", "use_plain", is_flag=True, default=None, help="Output stable TSV for piping."
|
|
103
|
+
),
|
|
104
|
+
click.option(
|
|
105
|
+
"--account",
|
|
106
|
+
"-a",
|
|
107
|
+
default=None,
|
|
108
|
+
metavar="ALIAS",
|
|
109
|
+
help="Account alias to use. [env: TLGR_ACCOUNT]",
|
|
110
|
+
),
|
|
111
|
+
click.option(
|
|
112
|
+
"--results-only",
|
|
113
|
+
is_flag=True,
|
|
114
|
+
default=None,
|
|
115
|
+
help="In JSON mode, print only the result (no envelope).",
|
|
116
|
+
),
|
|
117
|
+
click.option(
|
|
118
|
+
"--select",
|
|
119
|
+
"select_fields",
|
|
120
|
+
default=None,
|
|
121
|
+
metavar="FIELDS",
|
|
122
|
+
help="Project comma-separated fields out of the result (dot paths allowed).",
|
|
123
|
+
),
|
|
124
|
+
click.option(
|
|
125
|
+
"--columns", default=None, metavar="COLS", help="Override the human/plain columns."
|
|
126
|
+
),
|
|
127
|
+
click.option("--wide", is_flag=True, default=None, help="Do not truncate any column."),
|
|
128
|
+
click.option("--no-header", is_flag=True, default=None, help="Omit the header row."),
|
|
129
|
+
click.option(
|
|
130
|
+
"--yes", "-y", "force", is_flag=True, default=None, help="Skip confirmations."
|
|
131
|
+
),
|
|
132
|
+
click.option(
|
|
133
|
+
"--no-input", is_flag=True, default=None, help="Never prompt; fail instead (CI/agent)."
|
|
134
|
+
),
|
|
135
|
+
click.option(
|
|
136
|
+
"--flood-wait-max",
|
|
137
|
+
type=int,
|
|
138
|
+
default=None,
|
|
139
|
+
help="Max seconds to auto-sleep on a rate limit.",
|
|
140
|
+
),
|
|
141
|
+
click.option("--timeout", type=float, default=None, help="Client-side timeout in seconds."),
|
|
142
|
+
click.option(
|
|
143
|
+
"--no-daemon-restart",
|
|
144
|
+
is_flag=True,
|
|
145
|
+
default=None,
|
|
146
|
+
help="Never restart the daemon automatically.",
|
|
147
|
+
),
|
|
148
|
+
click.option(
|
|
149
|
+
"--enable-commands",
|
|
150
|
+
default=None,
|
|
151
|
+
metavar="IDS",
|
|
152
|
+
help="Comma-separated allowlist of operation ids.",
|
|
153
|
+
),
|
|
154
|
+
click.option("--verbose", "-v", is_flag=True, default=None, help="Verbose logging."),
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def add_global_options(command: Any) -> Any:
|
|
159
|
+
"""Attach every global flag to *command*.
|
|
160
|
+
|
|
161
|
+
Defaults are `None` rather than `False` so that "not given here" stays
|
|
162
|
+
distinguishable from "given as false", which is what lets a command-level
|
|
163
|
+
flag override a root-level one without clobbering it.
|
|
164
|
+
"""
|
|
165
|
+
for decorator in reversed(_flag_options()):
|
|
166
|
+
command = decorator(command)
|
|
167
|
+
return command
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _merge(target: dict[str, Any], params: dict[str, Any]) -> None:
|
|
171
|
+
"""Fold command-level flag values over the root-level ones."""
|
|
172
|
+
for key, value in params.items():
|
|
173
|
+
if value is not None and key in GLOBAL_FLAG_NAMES:
|
|
174
|
+
target[key] = value
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def state_from(ctx: click.Context, params: dict[str, Any] | None = None) -> CliState:
|
|
178
|
+
"""Build the merged `CliState` for this invocation."""
|
|
179
|
+
merged: dict[str, Any] = dict(ctx.obj or {})
|
|
180
|
+
if params:
|
|
181
|
+
_merge(merged, params)
|
|
182
|
+
|
|
183
|
+
use_json = merged.get("use_json")
|
|
184
|
+
use_plain = merged.get("use_plain")
|
|
185
|
+
if use_json is None:
|
|
186
|
+
use_json = merged.get("json") or env_bool("TLGR_JSON")
|
|
187
|
+
if use_plain is None:
|
|
188
|
+
use_plain = env_bool("TLGR_PLAIN")
|
|
189
|
+
if use_json and use_plain:
|
|
190
|
+
raise click.UsageError("cannot combine --json and --plain")
|
|
191
|
+
|
|
192
|
+
fmt = merged.get("fmt", "human")
|
|
193
|
+
if use_json:
|
|
194
|
+
fmt = "json"
|
|
195
|
+
elif use_plain:
|
|
196
|
+
fmt = "plain"
|
|
197
|
+
|
|
198
|
+
return CliState(
|
|
199
|
+
fmt=fmt,
|
|
200
|
+
account=merged.get("account") or "",
|
|
201
|
+
results_only=bool(merged.get("results_only")),
|
|
202
|
+
select=merged.get("select_fields") or merged.get("select"),
|
|
203
|
+
dry_run=bool(merged.get("dry_run")),
|
|
204
|
+
force=bool(merged.get("force")),
|
|
205
|
+
no_input=bool(merged.get("no_input")),
|
|
206
|
+
flood_wait_max=merged.get("flood_wait_max"),
|
|
207
|
+
timeout=merged.get("timeout"),
|
|
208
|
+
verbose=bool(merged.get("verbose")),
|
|
209
|
+
no_daemon_restart=bool(merged.get("no_daemon_restart")),
|
|
210
|
+
enable_commands=merged.get("enable_commands") or "",
|
|
211
|
+
columns=merged.get("columns"),
|
|
212
|
+
wide=bool(merged.get("wide")),
|
|
213
|
+
no_header=bool(merged.get("no_header")),
|
|
214
|
+
limit=merged.get("limit"),
|
|
215
|
+
cursor=merged.get("cursor"),
|
|
216
|
+
fetch_all=bool(merged.get("fetch_all")),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _require_account_enabled() -> bool:
|
|
221
|
+
"""`TLGR_REQUIRE_ACCOUNT`, else `[defaults] require_account`."""
|
|
222
|
+
env = os.environ.get("TLGR_REQUIRE_ACCOUNT", "").strip().lower()
|
|
223
|
+
if env in ("1", "true", "yes", "on"):
|
|
224
|
+
return True
|
|
225
|
+
if env in ("0", "false", "no", "off"):
|
|
226
|
+
return False
|
|
227
|
+
try:
|
|
228
|
+
from tlgr.core.config import load_app_config
|
|
229
|
+
|
|
230
|
+
return bool(load_app_config().defaults.require_account)
|
|
231
|
+
except Exception:
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def resolve_account(
|
|
236
|
+
state: CliState,
|
|
237
|
+
*,
|
|
238
|
+
positional: str | None = None,
|
|
239
|
+
require: bool | None = None,
|
|
240
|
+
) -> str:
|
|
241
|
+
"""Resolve the account, in one place, in one order.
|
|
242
|
+
|
|
243
|
+
positional → `-a/--account` → `TLGR_ACCOUNT` → `[accounts] default` →
|
|
244
|
+
the active alias. The daemon never picks for you (COR-02); when nothing
|
|
245
|
+
resolves and the operation needs one, that is a USAGE error naming the
|
|
246
|
+
ways to supply it.
|
|
247
|
+
"""
|
|
248
|
+
alias = (positional or state.account or _env_or("TLGR_ACCOUNT")).strip()
|
|
249
|
+
|
|
250
|
+
if not alias:
|
|
251
|
+
try:
|
|
252
|
+
from tlgr.core.config import load_app_config
|
|
253
|
+
from tlgr.core.paths import default_base
|
|
254
|
+
|
|
255
|
+
alias = (load_app_config().default_account or "").strip()
|
|
256
|
+
if not alias:
|
|
257
|
+
from tlgr.core.accounts import AccountManager
|
|
258
|
+
|
|
259
|
+
# `default_base()` rather than the `CONFIG_DIR` constant:
|
|
260
|
+
# that one is captured at import, so a `TLGR_HOME` set after
|
|
261
|
+
# the first import was ignored here and the CLI read the
|
|
262
|
+
# *real* home — which is how a development run reaches a
|
|
263
|
+
# production tree at all.
|
|
264
|
+
alias = (AccountManager(default_base()).get_active() or "").strip()
|
|
265
|
+
except Exception:
|
|
266
|
+
alias = ""
|
|
267
|
+
|
|
268
|
+
if not alias and (require or (require is None and _require_account_enabled())):
|
|
269
|
+
raise click.UsageError(
|
|
270
|
+
"No account specified. Pass -a <alias>, set TLGR_ACCOUNT, or set "
|
|
271
|
+
"[accounts] default in config.toml (see: tlgr account list)."
|
|
272
|
+
)
|
|
273
|
+
return alias
|
tlgr/cli/introspect.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Describing the Click tree for `tlgr schema`.
|
|
2
|
+
|
|
3
|
+
The walker itself is v1's, kept because the schema document still has to
|
|
4
|
+
describe every not-yet-migrated command; what changed is where the examples
|
|
5
|
+
come from. v1 hand-maintained `EXAMPLE_RESPONSES` and covered 26 of 93
|
|
6
|
+
commands (COR-33); now an example is the spec's, validated by a test.
|
|
7
|
+
|
|
8
|
+
This lives in `cli/` because only the CLI knows what the CLI looks like;
|
|
9
|
+
`tlgr/schema.py` sits below it and is handed the result.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import click
|
|
17
|
+
|
|
18
|
+
__all__ = ["describe"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _param_type_name(param: click.Parameter) -> str:
|
|
22
|
+
"""Return a stable type name for a Click parameter."""
|
|
23
|
+
t = param.type
|
|
24
|
+
if isinstance(t, click.Choice):
|
|
25
|
+
return "choice"
|
|
26
|
+
if isinstance(t, click.IntRange):
|
|
27
|
+
return "int"
|
|
28
|
+
if isinstance(t, click.FloatRange):
|
|
29
|
+
return "float"
|
|
30
|
+
name = getattr(t, "name", type(t).__name__)
|
|
31
|
+
return str(name).lower()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _build_param(param: click.Parameter) -> dict[str, Any]:
|
|
35
|
+
"""Serialize a Click parameter to a schema dict."""
|
|
36
|
+
entry: dict[str, Any] = {"name": param.name or ""}
|
|
37
|
+
|
|
38
|
+
if isinstance(param, click.Option):
|
|
39
|
+
entry["type"] = "option"
|
|
40
|
+
opts = list(param.opts) + list(param.secondary_opts)
|
|
41
|
+
entry["flags"] = sorted(opts)
|
|
42
|
+
entry["is_flag"] = getattr(param, "is_flag", False)
|
|
43
|
+
elif isinstance(param, click.Argument):
|
|
44
|
+
entry["type"] = "argument"
|
|
45
|
+
entry["required"] = param.required
|
|
46
|
+
if param.nargs != 1:
|
|
47
|
+
entry["nargs"] = param.nargs
|
|
48
|
+
else:
|
|
49
|
+
entry["type"] = "parameter"
|
|
50
|
+
|
|
51
|
+
entry["param_type"] = _param_type_name(param)
|
|
52
|
+
|
|
53
|
+
if isinstance(param.type, click.Choice):
|
|
54
|
+
entry["choices"] = list(param.type.choices)
|
|
55
|
+
|
|
56
|
+
is_flag = getattr(param, "is_flag", False)
|
|
57
|
+
default = param.default
|
|
58
|
+
if (
|
|
59
|
+
default is not None
|
|
60
|
+
and default != ()
|
|
61
|
+
and not is_flag
|
|
62
|
+
and not (hasattr(default, "__class__") and "Sentinel" in type(default).__name__)
|
|
63
|
+
):
|
|
64
|
+
entry["default"] = default
|
|
65
|
+
|
|
66
|
+
help_text = getattr(param, "help", None)
|
|
67
|
+
if help_text:
|
|
68
|
+
entry["help"] = help_text
|
|
69
|
+
|
|
70
|
+
if getattr(param, "hidden", False):
|
|
71
|
+
entry["hidden"] = True
|
|
72
|
+
|
|
73
|
+
if getattr(param, "envvar", None):
|
|
74
|
+
envvar = param.envvar
|
|
75
|
+
if isinstance(envvar, str):
|
|
76
|
+
entry["envvar"] = [envvar]
|
|
77
|
+
elif envvar:
|
|
78
|
+
entry["envvar"] = list(envvar)
|
|
79
|
+
|
|
80
|
+
return entry
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _registry_example(full_path: str) -> Any:
|
|
84
|
+
"""The example for a command path, from the spec that owns it.
|
|
85
|
+
|
|
86
|
+
Not every command has one yet: an unmigrated v1 group has no spec, and
|
|
87
|
+
saying nothing is better than shipping the stale literal v1 kept here.
|
|
88
|
+
"""
|
|
89
|
+
from tlgr.registry import ALIASES, REGISTRY
|
|
90
|
+
|
|
91
|
+
op_id = ALIASES.get(full_path.removeprefix("tlgr ").replace(" ", "."))
|
|
92
|
+
spec = REGISTRY.get(op_id) if op_id else None
|
|
93
|
+
if spec is None or spec.example is None:
|
|
94
|
+
return None
|
|
95
|
+
import msgspec
|
|
96
|
+
|
|
97
|
+
return msgspec.to_builtins(spec.example)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _build_node(cmd: click.BaseCommand, name: str = "", path: str = "") -> dict[str, Any]:
|
|
101
|
+
"""Recursively build a schema node for a command."""
|
|
102
|
+
full_path = f"{path} {name}".strip() if path else name
|
|
103
|
+
|
|
104
|
+
node: dict[str, Any] = {
|
|
105
|
+
"name": name or cmd.name or "",
|
|
106
|
+
"path": full_path,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if isinstance(cmd, click.Group):
|
|
110
|
+
node["type"] = "group"
|
|
111
|
+
else:
|
|
112
|
+
node["type"] = "command"
|
|
113
|
+
|
|
114
|
+
if cmd.help:
|
|
115
|
+
node["help"] = cmd.help.split("\n")[0].strip()
|
|
116
|
+
|
|
117
|
+
if getattr(cmd, "hidden", False):
|
|
118
|
+
node["hidden"] = True
|
|
119
|
+
|
|
120
|
+
params = getattr(cmd, "params", [])
|
|
121
|
+
if params:
|
|
122
|
+
node["params"] = [_build_param(p) for p in params if p.name != "help"]
|
|
123
|
+
|
|
124
|
+
example = _registry_example(full_path)
|
|
125
|
+
if example is not None:
|
|
126
|
+
node["example_response"] = example
|
|
127
|
+
|
|
128
|
+
if isinstance(cmd, click.Group):
|
|
129
|
+
sub_names = cmd.list_commands(click.Context(cmd, info_name=name))
|
|
130
|
+
subcommands = []
|
|
131
|
+
for sub_name in sorted(sub_names):
|
|
132
|
+
sub_cmd = cmd.get_command(click.Context(cmd, info_name=name), sub_name)
|
|
133
|
+
if sub_cmd is not None:
|
|
134
|
+
subcommands.append(_build_node(sub_cmd, sub_name, full_path))
|
|
135
|
+
if subcommands:
|
|
136
|
+
node["subcommands"] = subcommands
|
|
137
|
+
|
|
138
|
+
return node
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def describe(path: tuple[str, ...] = (), *, include_hidden: bool = False) -> dict[str, Any] | None:
|
|
142
|
+
"""The command tree under *path*, or None when the path does not exist."""
|
|
143
|
+
from tlgr.cli import cli as root_cli
|
|
144
|
+
|
|
145
|
+
node: Any = root_cli
|
|
146
|
+
walked = "tlgr"
|
|
147
|
+
for token in path:
|
|
148
|
+
if not isinstance(node, click.Group):
|
|
149
|
+
return None
|
|
150
|
+
sub = node.get_command(click.Context(node, info_name=walked.split()[-1]), token)
|
|
151
|
+
if sub is None:
|
|
152
|
+
return None
|
|
153
|
+
walked = f"{walked} {token}"
|
|
154
|
+
node = sub
|
|
155
|
+
|
|
156
|
+
tree = _build_node(node, name=walked.split()[-1], path="")
|
|
157
|
+
tree["path"] = walked
|
|
158
|
+
if not include_hidden:
|
|
159
|
+
_strip_hidden(tree)
|
|
160
|
+
return tree
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _strip_hidden(node: dict[str, Any]) -> None:
|
|
164
|
+
"""Recursively remove hidden commands and params."""
|
|
165
|
+
if "params" in node:
|
|
166
|
+
node["params"] = [p for p in node["params"] if not p.get("hidden")]
|
|
167
|
+
if "subcommands" in node:
|
|
168
|
+
node["subcommands"] = [s for s in node["subcommands"] if not s.get("hidden")]
|
|
169
|
+
for sub in node["subcommands"]:
|
|
170
|
+
_strip_hidden(sub)
|
tlgr/cli/params.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Custom Click parameter types.
|
|
2
|
+
|
|
3
|
+
Each one converts to the *model* value the daemon expects and fails as a
|
|
4
|
+
USAGE error naming the offending field, so a bad argument is reported the same
|
|
5
|
+
way whether it was caught in the CLI or over the wire.
|
|
6
|
+
|
|
7
|
+
Nothing here resolves anything. `@alice` becomes a `PeerRef`, not a user; the
|
|
8
|
+
network is the daemon's business (§4.3, §6.6).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
|
|
19
|
+
from tlgr.core.text import PARSE_MODES
|
|
20
|
+
from tlgr.core.timefmt import TimeFormatError, fmt_dt, parse_dt, parse_duration
|
|
21
|
+
from tlgr.models.peer import PeerRef, parse_message_link, parse_peer_ref, parse_user_ref
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"DATETIME",
|
|
25
|
+
"DURATION",
|
|
26
|
+
"JSON",
|
|
27
|
+
"MSGREF",
|
|
28
|
+
"PARSE_MODE",
|
|
29
|
+
"PATH",
|
|
30
|
+
"PEER",
|
|
31
|
+
"USER",
|
|
32
|
+
"DateTimeParam",
|
|
33
|
+
"DurationParam",
|
|
34
|
+
"JsonParam",
|
|
35
|
+
"MsgRefParam",
|
|
36
|
+
"PathParam",
|
|
37
|
+
"PeerParam",
|
|
38
|
+
"UserParam",
|
|
39
|
+
"for_kind",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PeerParam(click.ParamType):
|
|
44
|
+
"""`@username`, an id, `+phone`, `me`/`saved`, or a t.me/tg:// link."""
|
|
45
|
+
|
|
46
|
+
name = "peer"
|
|
47
|
+
|
|
48
|
+
def convert(self, value: Any, param: Any, ctx: Any) -> Any:
|
|
49
|
+
if isinstance(value, PeerRef):
|
|
50
|
+
return value
|
|
51
|
+
try:
|
|
52
|
+
return parse_peer_ref(str(value))
|
|
53
|
+
except ValueError as exc:
|
|
54
|
+
self.fail(str(exc), param, ctx)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class UserParam(PeerParam):
|
|
58
|
+
"""`PEER` minus the forms that can only name a chat."""
|
|
59
|
+
|
|
60
|
+
name = "user"
|
|
61
|
+
|
|
62
|
+
def convert(self, value: Any, param: Any, ctx: Any) -> Any:
|
|
63
|
+
if isinstance(value, PeerRef):
|
|
64
|
+
return value
|
|
65
|
+
try:
|
|
66
|
+
return parse_user_ref(str(value))
|
|
67
|
+
except ValueError as exc:
|
|
68
|
+
self.fail(str(exc), param, ctx)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class MsgRefParam(click.ParamType):
|
|
72
|
+
"""A message id, or a link that carries one.
|
|
73
|
+
|
|
74
|
+
A link is accepted anywhere a `<chat> <msg-id>` pair is expected; the
|
|
75
|
+
generated command fills both and complains if they disagree (STYLE §2).
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
name = "msg_id"
|
|
79
|
+
|
|
80
|
+
def convert(self, value: Any, param: Any, ctx: Any) -> Any:
|
|
81
|
+
if isinstance(value, int):
|
|
82
|
+
return value
|
|
83
|
+
text = str(value).strip()
|
|
84
|
+
try:
|
|
85
|
+
return int(text)
|
|
86
|
+
except ValueError:
|
|
87
|
+
pass
|
|
88
|
+
link = parse_message_link(text)
|
|
89
|
+
if link is None:
|
|
90
|
+
self.fail(f"{text!r} is neither a message id nor a message link", param, ctx)
|
|
91
|
+
return link[1]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class DurationParam(click.ParamType):
|
|
95
|
+
"""`30s 5m 2h 7d 1w forever`. `0` is legal and means "immediately"."""
|
|
96
|
+
|
|
97
|
+
name = "duration"
|
|
98
|
+
|
|
99
|
+
def convert(self, value: Any, param: Any, ctx: Any) -> Any:
|
|
100
|
+
if value is None or isinstance(value, int):
|
|
101
|
+
return value
|
|
102
|
+
try:
|
|
103
|
+
return parse_duration(str(value))
|
|
104
|
+
except TimeFormatError as exc:
|
|
105
|
+
self.fail(str(exc), param, ctx)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class DateTimeParam(click.ParamType):
|
|
109
|
+
"""RFC-3339, `YYYY-MM-DD`, `YYYY-MM-DDTHH:MM`, or a relative `-90m`/`+3d`.
|
|
110
|
+
|
|
111
|
+
Converts to a UTC RFC-3339 string, because that is what crosses the wire;
|
|
112
|
+
a naive value is read in the local zone first (COR-23).
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
name = "datetime"
|
|
116
|
+
|
|
117
|
+
def convert(self, value: Any, param: Any, ctx: Any) -> Any:
|
|
118
|
+
if value is None:
|
|
119
|
+
return None
|
|
120
|
+
try:
|
|
121
|
+
parsed = parse_dt(str(value))
|
|
122
|
+
except TimeFormatError as exc:
|
|
123
|
+
self.fail(str(exc), param, ctx)
|
|
124
|
+
return fmt_dt(parsed)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class JsonParam(click.ParamType):
|
|
128
|
+
"""A JSON literal, or `@path` / `-` to read it from a file or stdin."""
|
|
129
|
+
|
|
130
|
+
name = "json"
|
|
131
|
+
|
|
132
|
+
def convert(self, value: Any, param: Any, ctx: Any) -> Any:
|
|
133
|
+
if not isinstance(value, str):
|
|
134
|
+
return value
|
|
135
|
+
text = value
|
|
136
|
+
if text == "-":
|
|
137
|
+
text = sys.stdin.read()
|
|
138
|
+
elif text.startswith("@"):
|
|
139
|
+
try:
|
|
140
|
+
with open(text[1:], encoding="utf-8") as handle:
|
|
141
|
+
text = handle.read()
|
|
142
|
+
except OSError as exc:
|
|
143
|
+
self.fail(f"{text[1:]}: {exc.strerror or exc}", param, ctx)
|
|
144
|
+
try:
|
|
145
|
+
json.loads(text)
|
|
146
|
+
except ValueError as exc:
|
|
147
|
+
self.fail(f"not valid JSON: {exc}", param, ctx)
|
|
148
|
+
return text
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class PathParam(click.Path):
|
|
152
|
+
"""A filesystem path where `-` keeps its stdin/stdout meaning."""
|
|
153
|
+
|
|
154
|
+
name = "path"
|
|
155
|
+
|
|
156
|
+
def convert(self, value: Any, param: Any, ctx: Any) -> Any:
|
|
157
|
+
if value == "-":
|
|
158
|
+
return "-"
|
|
159
|
+
return super().convert(value, param, ctx)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class ParseModeParam(click.Choice):
|
|
163
|
+
def __init__(self) -> None:
|
|
164
|
+
super().__init__(list(PARSE_MODES))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
PEER = PeerParam()
|
|
168
|
+
USER = UserParam()
|
|
169
|
+
MSGREF = MsgRefParam()
|
|
170
|
+
DURATION = DurationParam()
|
|
171
|
+
DATETIME = DateTimeParam()
|
|
172
|
+
JSON = JsonParam()
|
|
173
|
+
PATH = PathParam()
|
|
174
|
+
PARSE_MODE = ParseModeParam()
|
|
175
|
+
|
|
176
|
+
#: `kind=` on a request field → the Click type that implements it.
|
|
177
|
+
_BY_KIND: dict[str, click.ParamType] = {
|
|
178
|
+
"peer": PEER,
|
|
179
|
+
"user": USER,
|
|
180
|
+
"msg_id": MSGREF,
|
|
181
|
+
"duration": DURATION,
|
|
182
|
+
"datetime": DATETIME,
|
|
183
|
+
"json": JSON,
|
|
184
|
+
"path": PATH,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def for_kind(kind: str) -> click.ParamType | None:
|
|
189
|
+
return _BY_KIND.get(kind)
|