tgblocks 0.2.0__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.

Potentially problematic release.


This version of tgblocks might be problematic. Click here for more details.

tgblocks/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ # tgblocks: a tiny DSL, a text syntax and a visual editor for Telegram bots.
2
+ #
3
+ # The package is a compiler: .tg DSL or the editor syntax -> aiogram 3 code.
4
+ #
5
+ # from tgblocks import parse, generate
6
+ # code = generate(parse(open("bot.tg").read()))
7
+ #
8
+ # from tgblocks.textlang import compile_text
9
+ # code = compile_text(open("bot.txt").read())
10
+
11
+ from .dsl.codegen import generate
12
+ from .dsl.parser import BotSpec, DSLParseError, parse, parse_file
13
+ from .textlang import compile_text, parse_text
14
+
15
+ __version__ = "0.2.0"
16
+
17
+ __all__ = [
18
+ "__version__",
19
+ "BotSpec",
20
+ "DSLParseError",
21
+ "compile_text",
22
+ "generate",
23
+ "parse",
24
+ "parse_file",
25
+ "parse_text",
26
+ ]
tgblocks/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ # Allows `python -m tgblocks run examples/hello.tg`.
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
tgblocks/cli.py ADDED
@@ -0,0 +1,290 @@
1
+ # Command line interface: run, compile, check and the visual editor.
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import runpy
8
+ import sys
9
+ import tempfile
10
+ from pathlib import Path
11
+ from typing import List, Optional
12
+
13
+ from . import __version__
14
+ from .dsl.codegen import generate
15
+ from .dsl.parser import DSLParseError, parse_file
16
+ from .runner import missing_token_error, usable_token
17
+
18
+
19
+ def _print_warnings(bot, stream) -> None:
20
+ for warning in getattr(bot, "warnings", ()):
21
+ print("warning: {0}".format(warning), file=stream)
22
+
23
+
24
+ def _describe(handler) -> str:
25
+ # One column that actually distinguishes two handlers of the same kind.
26
+ if handler.kind == "command":
27
+ return handler.command or "/"
28
+ if handler.kind == "state":
29
+ return "state[{0}]".format(handler.state)
30
+ if handler.kind == "callback":
31
+ return "callback[{0}]".format(handler.data) if handler.data else "callback"
32
+ extras = []
33
+ if handler.patterns:
34
+ extras.append(", ".join(handler.patterns))
35
+ if handler.chat:
36
+ extras.append("chat=" + handler.chat)
37
+ if handler.from_user:
38
+ extras.append("from_user={0}".format(handler.from_user))
39
+ if handler.regex:
40
+ extras.append("regex=" + handler.regex)
41
+ return "{0}[{1}]".format(handler.kind, "; ".join(extras)) if extras else handler.kind
42
+
43
+
44
+ def cmd_check(args: argparse.Namespace) -> int:
45
+ bot = parse_file(args.file)
46
+ events = [_describe(handler) for handler in bot.handlers]
47
+ print(
48
+ "OK {0}: handlers={1} - {2}".format(
49
+ args.file, len(bot.handlers), ", ".join(events) or "none"
50
+ )
51
+ )
52
+ _print_warnings(bot, sys.stderr)
53
+ return 0
54
+
55
+
56
+ def cmd_compile(args: argparse.Namespace) -> int:
57
+ bot = parse_file(args.file)
58
+ code = generate(bot, embed_token=args.embed_token)
59
+ _print_warnings(bot, sys.stderr)
60
+ if args.output == "-":
61
+ sys.stdout.write(code)
62
+ else:
63
+ target = Path(args.output)
64
+ target.parent.mkdir(parents=True, exist_ok=True)
65
+ target.write_text(code, encoding="utf-8")
66
+ print("wrote {0} ({1} lines)".format(target, code.count("\n")))
67
+ if not args.embed_token:
68
+ print(
69
+ "note: the token is not written into the file. "
70
+ "Start the bot with TG_BOT_TOKEN=... or pass --embed-token.",
71
+ file=sys.stderr,
72
+ )
73
+ return 0
74
+
75
+
76
+ def cmd_run(args: argparse.Namespace) -> int:
77
+ bot = parse_file(args.file)
78
+ _print_warnings(bot, sys.stderr)
79
+
80
+ if args.dry_run:
81
+ # Inspection output must be safe to paste into a repository.
82
+ sys.stdout.write(generate(bot, embed_token=False))
83
+ return 0
84
+
85
+ token = args.token or os.environ.get("TG_BOT_TOKEN") or usable_token(bot)
86
+ error = missing_token_error(bot, token)
87
+ if error:
88
+ print("error: {0} (file: {1})".format(error, args.file), file=sys.stderr)
89
+ return 2
90
+
91
+ code = generate(bot, embed_token=False)
92
+
93
+ try:
94
+ import aiogram # noqa: F401
95
+ except ImportError:
96
+ print(
97
+ 'error: aiogram is not installed. Run: pip install "tgblocks[runtime]"',
98
+ file=sys.stderr,
99
+ )
100
+ return 2
101
+
102
+ # Write the generated code to a temp file and execute it as __main__.
103
+ with tempfile.TemporaryDirectory(prefix="tgblocks-") as folder:
104
+ target = Path(folder) / "bot.py"
105
+ target.write_text(code, encoding="utf-8")
106
+ print("starting {0} (Ctrl+C to stop)".format(args.file))
107
+ runpy.run_path(str(target), run_name="__main__")
108
+ return 0
109
+
110
+
111
+ def cmd_proxy(args: argparse.Namespace) -> int:
112
+ from .network import ProxyError, check_proxy, parse_proxy, proxy_hint
113
+
114
+ proxies = list(args.proxies)
115
+ if not proxies:
116
+ from os import environ
117
+
118
+ configured = environ.get("TG_PROXY", "").strip()
119
+ if not configured:
120
+ print(
121
+ "error: no proxy given and TG_PROXY is not set.\n"
122
+ " Usage: tgblocks proxy socks5://host:port",
123
+ file=sys.stderr,
124
+ )
125
+ return 2
126
+ proxies = [configured]
127
+
128
+ failed = 0
129
+ for url in proxies:
130
+ try:
131
+ parse_proxy(url)
132
+ except ProxyError as error:
133
+ print("error: {0}: {1}".format(url, error), file=sys.stderr)
134
+ failed += 1
135
+ continue
136
+ works, message, seconds = check_proxy(url, timeout=args.timeout)
137
+ mark = "OK " if works else "FAIL"
138
+ print("{0} {1} ({2:.1f} c) {3}".format(mark, url, seconds, message))
139
+ if works:
140
+ for line in proxy_hint(url).splitlines():
141
+ print(" {0}".format(line))
142
+ else:
143
+ failed += 1
144
+ return 0 if not failed else 1
145
+
146
+
147
+ def cmd_visual(args: argparse.Namespace) -> int:
148
+ from .visual.server import serve
149
+
150
+ serve(
151
+ host=args.host,
152
+ port=args.port,
153
+ open_browser=not args.no_browser,
154
+ view=args.view,
155
+ allow_remote=args.allow_remote,
156
+ )
157
+ return 0
158
+
159
+
160
+ def build_parser() -> argparse.ArgumentParser:
161
+ parser = argparse.ArgumentParser(
162
+ prog="tgblocks",
163
+ description="A tiny text DSL and three visual editors for Telegram bots. "
164
+ "Run `tgblocks` alone to open the editor, or `tgblocks bot.tg` to start a bot.",
165
+ epilog="examples: tgblocks | tgblocks bot.tg | tgblocks compile bot.tg -o bot.py",
166
+ )
167
+ parser.add_argument(
168
+ "--version", action="version", version="tgblocks {0}".format(__version__)
169
+ )
170
+ subparsers = parser.add_subparsers(dest="command", metavar="command")
171
+
172
+ run_parser = subparsers.add_parser("run", help="compile a .tg file and start the bot")
173
+ run_parser.add_argument("file", help="path to a .tg file")
174
+ run_parser.add_argument("--token", help="bot token (overrides the DSL and the env)")
175
+ run_parser.add_argument(
176
+ "--dry-run",
177
+ action="store_true",
178
+ help="print the generated aiogram code instead of starting the bot",
179
+ )
180
+ run_parser.set_defaults(func=cmd_run)
181
+
182
+ compile_parser = subparsers.add_parser(
183
+ "compile", help="translate a .tg file into aiogram 3 Python code"
184
+ )
185
+ compile_parser.add_argument("file", help="path to a .tg file")
186
+ compile_parser.add_argument(
187
+ "-o", "--output", default="-", help="output file, or - for stdout (default)"
188
+ )
189
+ compile_parser.add_argument(
190
+ "--embed-token",
191
+ action="store_true",
192
+ help="write the token from the .tg file into the output (off by default: "
193
+ "generated bots read TG_BOT_TOKEN)",
194
+ )
195
+ compile_parser.set_defaults(func=cmd_compile)
196
+
197
+ check_parser = subparsers.add_parser("check", help="validate a .tg file only")
198
+ check_parser.add_argument("file", help="path to a .tg file")
199
+ check_parser.set_defaults(func=cmd_check)
200
+
201
+ proxy_parser = subparsers.add_parser(
202
+ "proxy", help="check that a proxy can reach api.telegram.org"
203
+ )
204
+ proxy_parser.add_argument(
205
+ "proxies", nargs="*",
206
+ help="proxy URLs: socks5://host:port, socks4://, http://, https:// "
207
+ "(default: TG_PROXY)",
208
+ )
209
+ proxy_parser.add_argument(
210
+ "--timeout", type=float, default=8.0, help="seconds per proxy (default 8)"
211
+ )
212
+ proxy_parser.set_defaults(func=cmd_proxy)
213
+
214
+ visual_parser = subparsers.add_parser(
215
+ "visual", help="open the local visual editor in a browser"
216
+ )
217
+ visual_parser.add_argument("--host", default="127.0.0.1", help="bind address")
218
+ visual_parser.add_argument("--port", type=int, default=8765, help="port (default 8765)")
219
+ visual_parser.add_argument(
220
+ "--view",
221
+ choices=["nodes", "blocks", "text"],
222
+ default="nodes",
223
+ help="editor at / : nodes (boxes), blocks (Blockly) or text",
224
+ )
225
+ visual_parser.add_argument(
226
+ "--no-browser", action="store_true", help="do not open a browser window"
227
+ )
228
+ visual_parser.add_argument(
229
+ "--allow-remote",
230
+ action="store_true",
231
+ help="allow --host other than 127.0.0.1 (the editor can run generated code)",
232
+ )
233
+ visual_parser.set_defaults(func=cmd_visual)
234
+
235
+ return parser
236
+
237
+
238
+ COMMANDS = {"run", "compile", "check", "visual"}
239
+
240
+
241
+ def _is_bot_file(argument: str) -> bool:
242
+ # `tgblocks bot.tg` is shorthand for `tgblocks run bot.tg`: the command has to
243
+ # work from any folder, without remembering a subcommand.
244
+ if not argument or argument.startswith("-") or argument in COMMANDS:
245
+ return False
246
+ if argument.lower().endswith((".tg", ".txt", ".py")):
247
+ return True
248
+ return Path(argument).is_file()
249
+
250
+
251
+ def main(argv: Optional[List[str]] = None) -> int:
252
+ parser = build_parser()
253
+ # A redirected stdout on Windows uses the ANSI codepage; without this, any
254
+ # character outside it (a Cyrillic name in a message, an em dash) would raise
255
+ # UnicodeEncodeError instead of printing.
256
+ for stream in (sys.stdout, sys.stderr):
257
+ try:
258
+ stream.reconfigure(errors="replace")
259
+ except (AttributeError, ValueError): # pragma: no cover - exotic streams
260
+ pass
261
+ arguments = list(sys.argv[1:] if argv is None else argv)
262
+ if not arguments:
263
+ # Bare `tgblocks`: the editor is what people mean, and it is also what
264
+ # the README promises.
265
+ arguments = ["visual"]
266
+ elif _is_bot_file(arguments[0]):
267
+ arguments = ["run"] + arguments
268
+ elif arguments[0].startswith("-") and arguments[0] not in ("-h", "--help", "--version"):
269
+ # `tgblocks --port 9000` is the same as `tgblocks visual --port 9000`:
270
+ # the subcommand is optional, the flags are not second-class citizens.
271
+ arguments = ["visual"] + arguments
272
+ args = parser.parse_args(arguments)
273
+ if not getattr(args, "command", None):
274
+ parser.print_help()
275
+ return 0
276
+ try:
277
+ return args.func(args) or 0
278
+ except DSLParseError as error:
279
+ print(error.render(getattr(args, "file", "<dsl>")), file=sys.stderr)
280
+ return 1
281
+ except FileNotFoundError as error:
282
+ print("error: {0}".format(error), file=sys.stderr)
283
+ return 2
284
+ except KeyboardInterrupt:
285
+ print("\nstopped", file=sys.stderr)
286
+ return 130
287
+
288
+
289
+ if __name__ == "__main__":
290
+ raise SystemExit(main())