telekit 2.6.0a1__tar.gz → 2.6.0a2__tar.gz

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 (87) hide show
  1. {telekit-2.6.0a1/telekit.egg-info → telekit-2.6.0a2}/PKG-INFO +11 -50
  2. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_version.py +1 -1
  3. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/utils.py +448 -55
  4. {telekit-2.6.0a1 → telekit-2.6.0a2/telekit.egg-info}/PKG-INFO +11 -50
  5. {telekit-2.6.0a1 → telekit-2.6.0a2}/LICENSE +0 -0
  6. {telekit-2.6.0a1 → telekit-2.6.0a2}/README.md +0 -0
  7. {telekit-2.6.0a1 → telekit-2.6.0a2}/setup.cfg +0 -0
  8. {telekit-2.6.0a1 → telekit-2.6.0a2}/setup.py +0 -0
  9. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/__init__.py +0 -0
  10. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_buildtext/__init__.py +0 -0
  11. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_buildtext/formatter.py +0 -0
  12. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_buildtext/styles.py +0 -0
  13. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_callback_query_handler.py +0 -0
  14. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_chain.py +0 -0
  15. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_chain_base.py +0 -0
  16. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_chain_entry_logic.py +0 -0
  17. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_chain_inline_keyboards_logic.py +0 -0
  18. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_chapters/__init__.py +0 -0
  19. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_chapters/chapters.py +0 -0
  20. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_handler.py +0 -0
  21. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_init.py +0 -0
  22. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_inline_buttons.py +0 -0
  23. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_inline_keyboard.py +0 -0
  24. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_input_handler.py +0 -0
  25. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_logger.py +0 -0
  26. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_on.py +0 -0
  27. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_reply_keyboard.py +0 -0
  28. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_snapvault/__init__.py +0 -0
  29. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_snapvault/snapcode.py +0 -0
  30. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_snapvault/snapvault.py +0 -0
  31. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_state.py +0 -0
  32. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_telekit_dsl/__init__.py +0 -0
  33. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_telekit_dsl/mixin.py +0 -0
  34. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_telekit_dsl/parser/__init__.py +0 -0
  35. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_telekit_dsl/parser/builder.py +0 -0
  36. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_telekit_dsl/parser/canvas_parser.py +0 -0
  37. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_telekit_dsl/parser/lexer.py +0 -0
  38. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_telekit_dsl/parser/nodes.py +0 -0
  39. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_telekit_dsl/parser/parser.py +0 -0
  40. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_telekit_dsl/parser/token.py +0 -0
  41. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_telekit_dsl/telekit_dsl.py +0 -0
  42. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_telekit_dsl/telekit_orm.py +0 -0
  43. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_text_builder.py +0 -0
  44. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_timeout.py +0 -0
  45. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_trait.py +0 -0
  46. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/_user.py +0 -0
  47. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/chat.py +0 -0
  48. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/debug.py +0 -0
  49. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/dices.py +0 -0
  50. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/example/__init__.py +0 -0
  51. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/example/example_handlers/__init__.py +0 -0
  52. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/example/example_handlers/article.py +0 -0
  53. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/example/example_handlers/calendar.py +0 -0
  54. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/example/example_handlers/complete_hotel.py +0 -0
  55. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/example/example_handlers/counter.py +0 -0
  56. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/example/example_handlers/dsl.py +0 -0
  57. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/example/example_handlers/entry.py +0 -0
  58. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/example/example_handlers/faq.py +0 -0
  59. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/example/example_handlers/hotel.py +0 -0
  60. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/example/example_handlers/on_text.py +0 -0
  61. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/example/example_handlers/pages.py +0 -0
  62. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/example/example_handlers/pyapi.py +0 -0
  63. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/example/example_handlers/qr.py +0 -0
  64. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/example/example_handlers/quiz.py +0 -0
  65. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/example/example_handlers/spells.py +0 -0
  66. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/example/example_handlers/start.py +0 -0
  67. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/example/example_handlers/style.py +0 -0
  68. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/example/example_handlers/text_document.py +0 -0
  69. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/example/example_server.py +0 -0
  70. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/html_text.py +0 -0
  71. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/inline_buttons.py +0 -0
  72. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/parameters.py +0 -0
  73. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/reply_buttons.py +0 -0
  74. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/scheduler.py +0 -0
  75. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/senders.py +0 -0
  76. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/server.py +0 -0
  77. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/styles.py +0 -0
  78. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/traits/__init__.py +0 -0
  79. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/traits/calendar_pick.py +0 -0
  80. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/traits/paginated_choice.py +0 -0
  81. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/traits/paginated_text.py +0 -0
  82. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/traits/track_handoff_origin.py +0 -0
  83. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit/types.py +0 -0
  84. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit.egg-info/SOURCES.txt +0 -0
  85. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit.egg-info/dependency_links.txt +0 -0
  86. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit.egg-info/requires.txt +0 -0
  87. {telekit-2.6.0a1 → telekit-2.6.0a2}/telekit.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: telekit
3
- Version: 2.6.0a1
3
+ Version: 2.6.0a2
4
4
  Summary: Declarative, developer-friendly library for building Telegram bots
5
5
  Home-page: https://github.com/Romashkaa/telekit
6
6
  Author: romashka
@@ -471,52 +471,13 @@ Telekit focuses on one job: making Telegram bot development easier.
471
471
 
472
472
  ---
473
473
 
474
- # Changes in version 2.6.0a1
475
-
476
- ### v2.5.4 `(bug-fix)`
477
- - Fix: Update condition to check for `None` instead of truthy value in `DSLHandler`
478
- ### v2.5.3 `(bug-fix)`
479
- - Fix: Remove `_` parameters from `_filter_entry` and `_handle_entry` methods in `DSLHandler`
480
- ### v2.5.2
481
- - Add `random_` variable prefix in `DSLHandler` — resolves to a random choice from a `list`/`tuple`/`str` static variable
482
-
483
- ### v2.5.1 `(bug-fix)`
484
- - Implement `TelegramMarkdownV2Sanitizer` for improved `MarkdownV2` handling
485
-
486
- ### v2.5.0 `(final)`
487
- - Refactor `CalendarPick` trait to use `set_keyboard`
488
-
489
- ### v2.5.0`b3`
490
- - Added `utils.Markers` class
491
- - Added `HTMLText` class for handling Telegram HTML strings with tag-aware indexing and slicing.
492
- - Added `PaginatedText` trait for displaying long HTML text in a paginated format, supporting navigation and smart splitting.
493
- - Added `__radd__` to `TextEntity`: `"Regular" + Bold(" and Bold")`
494
- - Added `__mul__` to `TextEntity`: `Bold("Text") * 3`
495
- - Added `enabled=` parameter to `TextEntity`: `Bold("bold text", enabled=is_text_bold)`
496
- - Added `TextBuilder` class – a fluent message composition API mirroring `InlineKeyboard`'s builder pattern
497
- - Added styles to `telekit.types`
498
- - Added `utils.CyclicList`
499
- - Fixed `_answer_callback_query` to always call `bot.answer_callback_query()`, even without a popup text
500
-
501
- ### v2.5.0`b2`
502
- - Added the `escape` parameter to `telekit.utils.*`:
503
- - `make_user_link`
504
- - `make_bot_link`
505
- - `Handler.handlers_dict` now excludes private handlers (classes whose names start with `_`).
506
- - Added `Debug.duplicate_handler_warnings` to warn about duplicate handler names during initialization.
507
- - Added `Handler.chat` object (BETA)
508
-
509
- ### v2.5.0`b1`
510
- - Added `Sender.send_message` method.
511
- - Added `utils.make_mention` utility for generating `tg://user?id=` mention links.
512
- - Added new inline button types to `inline_buttons`:
513
- - `ContactButton` — mentions a user by Telegram ID via `tg://user?id=`.
514
- - `UserLinkButton` — opens a user profile by username; supports pre-filled message text.
515
- - `BotLinkButton` — opens a bot by username; supports deep-link `?start=` payload.
516
- - Added new methods to `InlineKeyboard`:
517
- - `add_contact` — adds a `ContactButton`.
518
- - `add_user_link` — adds a `UserLinkButton`.
519
- - `add_bot_link` — adds a `BotLinkButton`.
520
-
521
- ### v2.5.0`b0`
522
- - Added support for t-strings (PEP 750, Python 3.14+) in `TextEntity`.
474
+ # Changes in version 2.6.0a2
475
+
476
+ ### v2.6.0 `a2`
477
+ - Reworked `.env` and token/canvas file reading in `utils`: full `.env` syntax support (comments, `export`, quotes and escapes, multi-line values, `$VAR` interpolation), a `cache` parameter (default `True`) with `clear_cache()`, detailed errors with fix suggestions and creation commands, and new `Env*Error` exceptions; `load_env` now raises `EnvFileNotFoundError` for a missing file instead of returning `{}`
478
+
479
+ ### v2.6.0 `a1`
480
+ - Added a module-loading utility in `utils`
481
+
482
+ ### v2.6.0 `a0`
483
+ - Improved formatting in `_on.py`
@@ -3,4 +3,4 @@
3
3
  # PyPI history: https://pypi.org/project/telekit/#history
4
4
  # ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
5
5
 
6
- __version__ = "2.6.0a1"
6
+ __version__ = "2.6.0a2"
@@ -1,5 +1,7 @@
1
1
  import os
2
2
  import re
3
+ import difflib
4
+ import shlex
3
5
 
4
6
  from pathlib import Path
5
7
  from urllib.parse import urlencode, quote
@@ -116,48 +118,352 @@ def format_file_size(size: int, precision: int = 1) -> str:
116
118
  return f"{formatted} {units[index]}"
117
119
 
118
120
  # ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
119
- # Environment
121
+ # Environment: errors
120
122
  # ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
121
123
 
124
+ class EnvError(Exception):
125
+ """Base class for all errors raised while reading env / token / canvas files."""
126
+
127
+
128
+ class EnvFileNotFoundError(EnvError, FileNotFoundError):
129
+ """The file does not exist. The message contains commands to create it."""
130
+
131
+
132
+ class EnvPermissionError(EnvError, PermissionError):
133
+ """The file cannot be read because of missing permissions."""
134
+
135
+
136
+ class EnvKeyError(EnvError, KeyError):
137
+ """The requested variable is missing in the ``.env`` file."""
138
+
139
+ def __str__(self) -> str:
140
+ return str(self.args[0]) if self.args else ""
141
+
142
+
143
+ class EnvSyntaxError(EnvError, ValueError):
144
+ """The ``.env`` file contains a line that cannot be parsed."""
145
+
146
+
147
+ class EnvValueError(EnvError, ValueError):
148
+ """The file exists, but its content is empty, invalid or not UTF-8."""
149
+
150
+ # ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
151
+ # Environment: cache
152
+ # ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
153
+
154
+ # Parsed ``.env`` files: absolute path -> {key: value}
155
+ _ENV_CACHE: dict[str, dict[str, str]] = {}
156
+ # Meaningful first line of plain text files (token.txt, canvas_path.txt): absolute path -> line
157
+ _TEXT_CACHE: dict[str, str] = {}
158
+
159
+
160
+ def clear_cache() -> None:
161
+ """Drop everything cached by ``cache=True`` calls."""
162
+ _ENV_CACHE.clear()
163
+ _TEXT_CACHE.clear()
164
+
165
+
166
+ def _cache_key(path: str) -> str:
167
+ return os.path.abspath(os.path.expanduser(path))
168
+
169
+ # ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
170
+ # Environment: error helpers
171
+ # ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
172
+
173
+ def _create_commands(path: str, line: str) -> str:
174
+ """Shell commands that append ``line`` to ``path`` (creating the file if needed)."""
175
+ return "\n".join([
176
+ f" Linux/macOS: printf '%s\\n' {shlex.quote(line)} >> {shlex.quote(path)}",
177
+ f' Win: echo {line} >> "{path}"',
178
+ ])
179
+
180
+
181
+ def _find_similar_files(path: str) -> list[str]:
182
+ """Look for files with a similar name next to ``path`` and in the current directory."""
183
+ p = Path(path)
184
+ found: list[str] = []
185
+
186
+ for folder in {p.parent, Path.cwd()}:
187
+ try:
188
+ if not folder.is_dir():
189
+ continue
190
+ names = [e.name for e in folder.iterdir() if e.is_file()]
191
+ except OSError:
192
+ continue
193
+
194
+ close = set(difflib.get_close_matches(p.name, names, n=5, cutoff=0.6))
195
+ if p.name.startswith(".env") or p.suffix == ".env":
196
+ close |= {n for n in names if n.startswith(".env") or n.endswith(".env")}
197
+
198
+ for name in sorted(close):
199
+ candidate = str(folder / name)
200
+ if _cache_key(candidate) != _cache_key(path) and candidate not in found:
201
+ found.append(candidate)
202
+
203
+ return found[:5]
204
+
205
+
206
+ def _missing_file_message(path: str, example: str, kind: str) -> str:
207
+ p = Path(path)
208
+ lines = [
209
+ f"{kind} file not found: '{path}'",
210
+ f" Resolved to: {_cache_key(path)}",
211
+ f" Current working directory: {os.getcwd()}",
212
+ ]
213
+
214
+ if not p.parent.exists():
215
+ lines += [
216
+ f" The directory '{p.parent}' does not exist either. Create it first:",
217
+ f" mkdir -p {shlex.quote(str(p.parent))} (Windows: mkdir \"{p.parent}\")",
218
+ ]
219
+
220
+ similar = _find_similar_files(path)
221
+ if similar:
222
+ lines.append(" Similar files found - maybe you meant one of these:")
223
+ lines += [f" {s}" for s in similar]
224
+
225
+ lines += [
226
+ " To create the file, run one of the commands below",
227
+ f" (replace the example value with your own):",
228
+ _create_commands(path, example),
229
+ " Or pass the correct location, e.g. read_*(\"path/to/file\") "
230
+ "(relative paths are resolved from the current working directory).",
231
+ ]
232
+ return "\n".join(lines)
233
+
234
+
235
+ def _syntax_error(source: str, lineno: int, line: str, problem: str, hint: str) -> EnvSyntaxError:
236
+ return EnvSyntaxError(
237
+ f"Invalid syntax in '{source}' at line {lineno}: {problem}\n"
238
+ f" > {line.strip()}\n"
239
+ f" Hint: {hint}"
240
+ )
241
+
242
+
243
+ def _read_text(path: str, example: str, kind: str) -> str:
244
+ """Read a text file as UTF-8 (BOM tolerated) and turn I/O errors into helpful ones."""
245
+ try:
246
+ return Path(path).read_text(encoding="utf-8-sig")
247
+ except FileNotFoundError:
248
+ raise EnvFileNotFoundError(_missing_file_message(path, example, kind)) from None
249
+ except IsADirectoryError:
250
+ raise EnvError(
251
+ f"Expected a file but '{path}' is a directory.\n"
252
+ f" Hint: pass the path to the file itself, e.g. '{os.path.join(path, '.env')}'."
253
+ ) from None
254
+ except PermissionError:
255
+ raise EnvPermissionError(
256
+ f"No permission to read '{path}'.\n"
257
+ f" Hint: check the access rights - Linux/macOS: `chmod u+r {shlex.quote(path)}`; "
258
+ f"Windows: file Properties -> Security. Also make sure no other program locks the file."
259
+ ) from None
260
+ except UnicodeDecodeError as e:
261
+ raise EnvValueError(
262
+ f"'{path}' is not valid UTF-8 ({e.reason} at byte {e.start}).\n"
263
+ f" Hint: re-save the file in UTF-8 encoding (in most editors: "
264
+ f"'Save with encoding' -> 'UTF-8')."
265
+ ) from None
266
+ except OSError as e:
267
+ raise EnvError(
268
+ f"Cannot read '{path}': {e.strerror or e}.\n"
269
+ f" Hint: check that the path is correct and the file is accessible."
270
+ ) from None
271
+
272
+ # ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
273
+ # Environment: .env parser
274
+ # ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
275
+
276
+ # Syntax supported:
277
+ # # full-line comment
278
+ # KEY=value unquoted, inline comment after " #"
279
+ # export KEY=value "export" prefix
280
+ # KEY = value spaces around "="
281
+ # KEY= empty value
282
+ # KEY='literal $value\n' single quotes: no escapes, no interpolation
283
+ # KEY="line1\nline2 \"q\"" double quotes: \n \r \t \" \\ \$ escapes + interpolation
284
+ # KEY="multi quoted values may span several lines
285
+ # line"
286
+ # B=${A}/x B=$A B=${A:-d} interpolation (earlier keys of the file, then os.environ);
287
+ # unknown variables without a default are left untouched
288
+ # UTF-8 BOM and CRLF line endings are handled.
289
+
290
+ _KEY_RE = re.compile(r"^(?:export\s+)?([A-Za-z_][A-Za-z0-9_.\-]*)\s*=(.*)$")
291
+ _AFTER_QUOTE_RE = re.compile(r"^\s*(?:#.*)?$")
292
+ _INLINE_COMMENT_RE = re.compile(r"\s+#.*$")
293
+ _VAR_RE = re.compile(
294
+ r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?-)([^}]*))?\}|\$([A-Za-z_][A-Za-z0-9_]*)"
295
+ )
296
+ _ESCAPES = {"n": "\n", "r": "\r", "t": "\t", '"': '"', "\\": "\\"}
297
+ _DOLLAR = "\x00" # placeholder for an escaped "\$" until interpolation is done
298
+
299
+
300
+ def _scan_double_quoted(s: str) -> tuple[str, int] | None:
301
+ """Scan ``s`` (text after an opening ``"``). Return (value, index of closing quote) or None."""
302
+ out: list[str] = []
303
+ i = 0
304
+ while i < len(s):
305
+ c = s[i]
306
+ if c == "\\" and i + 1 < len(s):
307
+ nxt = s[i + 1]
308
+ if nxt in _ESCAPES:
309
+ out.append(_ESCAPES[nxt])
310
+ elif nxt == "$":
311
+ out.append(_DOLLAR)
312
+ else:
313
+ out.append("\\" + nxt) # unknown escape stays as is
314
+ i += 2
315
+ continue
316
+ if c == '"':
317
+ return "".join(out), i
318
+ out.append(c)
319
+ i += 1
320
+ return None
321
+
322
+
323
+ def _interpolate(value: str, env: dict[str, str]) -> str:
324
+ def repl(m: re.Match) -> str:
325
+ name = m.group(1) or m.group(4)
326
+ op, default = m.group(2), m.group(3)
327
+ found = env.get(name, os.environ.get(name))
328
+
329
+ if op == ":-" and not found:
330
+ return default or ""
331
+ if op == "-" and found is None:
332
+ return default or ""
333
+ return m.group(0) if found is None else found
334
+
335
+ return _VAR_RE.sub(repl, value).replace(_DOLLAR, "$")
336
+
337
+
338
+ def _parse_env(text: str, source: str) -> dict[str, str]:
339
+ """Parse the content of a ``.env`` file. ``source`` is only used in error messages."""
340
+ env: dict[str, str] = {}
341
+ lines = text.lstrip("\ufeff").splitlines()
342
+ i = 0
343
+
344
+ while i < len(lines):
345
+ raw = lines[i]
346
+ lineno = i + 1
347
+ stripped = raw.strip()
348
+
349
+ if not stripped or stripped.startswith("#"):
350
+ i += 1
351
+ continue
352
+
353
+ m = _KEY_RE.match(stripped)
354
+ if not m:
355
+ if "=" not in stripped:
356
+ raise _syntax_error(
357
+ source, lineno, raw, "missing '='",
358
+ f"write the line as KEY=value, or start it with '#' to make it a comment, "
359
+ f"e.g. '{stripped.split()[0]}=your_value'.",
360
+ )
361
+ raise _syntax_error(
362
+ source, lineno, raw, "invalid variable name",
363
+ "names may contain letters, digits, '_', '.', '-' and must not start with a digit "
364
+ "or contain spaces, e.g. MY_TOKEN=value.",
365
+ )
366
+
367
+ key, rest = m.group(1), m.group(2).strip()
368
+
369
+ if rest[:1] in ('"', "'"):
370
+ quote_char = rest[0]
371
+ body = rest[1:]
372
+ start_line = lineno
373
+
374
+ while True:
375
+ if quote_char == '"':
376
+ scanned = _scan_double_quoted(body)
377
+ else:
378
+ end = body.find("'")
379
+ scanned = (body[:end], end) if end != -1 else None
380
+
381
+ if scanned is not None:
382
+ break
383
+ i += 1
384
+ if i >= len(lines):
385
+ raise _syntax_error(
386
+ source, start_line, raw, f"unclosed {quote_char} quote",
387
+ f"add the closing {quote_char} at the end of the value. For text that "
388
+ f"contains the same quote, use the other quote type"
389
+ + (' or escape it as \\"' if quote_char == '"' else "") + ".",
390
+ )
391
+ body += "\n" + lines[i]
392
+
393
+ value, end = scanned
394
+ tail = body[end + 1:]
395
+ if not _AFTER_QUOTE_RE.match(tail):
396
+ raise _syntax_error(
397
+ source, start_line, raw, f"unexpected text after closing quote: {tail.strip()!r}",
398
+ "put comments after whitespace and '#', or quote the whole value "
399
+ "(escape inner double quotes as \\\").",
400
+ )
401
+
402
+ if quote_char == '"':
403
+ value = _interpolate(value, env)
404
+ else:
405
+ if rest.startswith("#"):
406
+ rest = ""
407
+ value = _interpolate(_INLINE_COMMENT_RE.sub("", rest).strip(), env)
408
+
409
+ env[key] = value
410
+ i += 1
411
+
412
+ return env
413
+
414
+
122
415
  def _split_env_path(path: str, default: str) -> tuple[str, str]:
123
- if ":" in path:
124
- path, name = path.split(":", 1)
125
- else:
126
- path, name = path, default
416
+ """Split ``"file.env:KEY"`` into ``("file.env", "KEY")``. Windows drive letters are safe."""
417
+ m = re.match(r"^(.+):([A-Za-z_][A-Za-z0-9_.\-]*)$", path)
418
+ if m:
419
+ return m.group(1), m.group(2)
420
+ return path, default
421
+
422
+
423
+ def _is_env_path(path: str) -> bool:
424
+ base = os.path.basename(_split_env_path(path, "")[0])
425
+ return base.endswith(".env") or base.startswith(".env")
426
+
127
427
 
128
- return path, name
428
+ def _load_env(path: str, cache: bool, example: str) -> dict[str, str]:
429
+ key = _cache_key(path)
129
430
 
431
+ if not (cache and key in _ENV_CACHE):
432
+ text = _read_text(path, example, ".env")
433
+ _ENV_CACHE[key] = _parse_env(text, path)
130
434
 
131
- def load_env(path=".env") -> dict[str, str]:
435
+ return dict(_ENV_CACHE[key])
436
+
437
+
438
+ def load_env(path=".env", *, cache: bool = True) -> dict[str, str]:
132
439
  """
133
440
  Load all key-value pairs from a ``.env`` file.
134
441
 
135
- Lines starting with ``#`` and empty lines are ignored.
442
+ Supported syntax: ``KEY=value``, ``export KEY=value``, spaces around ``=``,
443
+ full-line and inline (`` # ...``) comments, empty values, single-quoted
444
+ (literal) and double-quoted values with ``\\n \\r \\t \\" \\\\ \\$`` escapes,
445
+ multi-line quoted values, ``$VAR`` / ``${VAR}`` / ``${VAR:-default}``
446
+ interpolation, UTF-8 BOM and CRLF line endings.
136
447
 
137
448
  :param path: Path to the ``.env`` file. Defaults to ``".env"``.
138
449
  :type path: ``str``
139
- :return: Dictionary of all key-value pairs found in the file,
140
- or an empty dict if the file does not exist.
450
+ :param cache: If ``True`` (default) and the file was already parsed, return
451
+ the in-memory result without touching the disk. If ``False``, the file
452
+ is always read and parsed again, and the cache is updated.
453
+ :type cache: ``bool``
454
+ :return: Dictionary of all key-value pairs found in the file.
141
455
  :rtype: ``dict[str, str]``
456
+ :raises EnvFileNotFoundError: If the file does not exist (the message
457
+ contains shell commands to create it). Subclass of ``FileNotFoundError``.
458
+ :raises EnvSyntaxError: If a line cannot be parsed (the message contains
459
+ the line number and a hint). Subclass of ``ValueError``.
460
+ :raises EnvPermissionError: If the file cannot be read.
461
+ :raises EnvValueError: If the file is not UTF-8.
142
462
  """
143
- if not os.path.exists(path):
144
- return {}
145
-
146
- env: dict[str, str] = {}
147
-
148
- with open(path, "r") as f:
149
- for line in f:
150
- line = line.strip()
151
- if not line or line.startswith("#"):
152
- continue
153
-
154
- key, value = line.split("=", 1)
155
- env[key.strip()] = value.strip()
156
-
157
- return env
463
+ return _load_env(path, cache, "KEY=value")
158
464
 
159
465
 
160
- def read_envar(path: str, name: str) -> str:
466
+ def read_envar(path: str, name: str, *, cache: bool = True) -> str:
161
467
  """
162
468
  Read a single environment variable from a ``.env`` file.
163
469
 
@@ -165,19 +471,75 @@ def read_envar(path: str, name: str) -> str:
165
471
  :type path: ``str``
166
472
  :param name: Name of the key to read.
167
473
  :type name: ``str``
168
- :return: Value of the key.
474
+ :param cache: Use the in-memory copy of the file if available
475
+ (see :func:`load_env`). Default is ``True``.
476
+ :type cache: ``bool``
477
+ :return: Value of the key (may be an empty string if it is declared empty).
169
478
  :rtype: ``str``
170
- :raises KeyError: If ``name`` is not found in the file.
479
+ :raises EnvKeyError: If ``name`` is not found in the file. The message
480
+ lists available keys, close matches and the command to add the key.
481
+ Subclass of ``KeyError``.
482
+ :raises EnvFileNotFoundError: See :func:`load_env`.
171
483
  """
172
- env: dict[str, str] = load_env(path)
484
+ example = f"{name}=your_value"
485
+ env = _load_env(path, cache, example)
486
+
487
+ if name in env:
488
+ return env[name]
489
+
490
+ lines = [f"Variable '{name}' not found in '{path}'."]
491
+
492
+ lower = {k.lower(): k for k in env}
493
+ if name.lower() in lower:
494
+ lines.append(
495
+ f" Names are case-sensitive: the file has '{lower[name.lower()]}', not '{name}'."
496
+ )
497
+
498
+ close = difflib.get_close_matches(name, list(env), n=3, cutoff=0.6)
499
+ close = [c for c in close if c != lower.get(name.lower())]
500
+ if close:
501
+ lines.append(" Did you mean: " + ", ".join(f"'{c}'" for c in close) + "?")
502
+
503
+ if env:
504
+ lines.append(" Variables in this file: " + ", ".join(env))
505
+ else:
506
+ lines.append(" The file contains no variables (it is empty or has only comments).")
507
+
508
+ lines += [
509
+ f" To fix it, add the line `{example}` to '{path}':",
510
+ _create_commands(path, example),
511
+ " Check also that the line is not commented out with '#' "
512
+ "and that you are reading the right file.",
513
+ ]
514
+ if cache:
515
+ lines.append(" If you edited the file after the first read, call again with cache=False (or clear_cache()).")
516
+
517
+ raise EnvKeyError("\n".join(lines))
173
518
 
174
- if name not in env:
175
- raise KeyError(f"{name} not found in {path}")
176
519
 
177
- return env[name]
520
+ def _read_first_value(path: str, example: str, kind: str, cache: bool) -> str:
521
+ """First meaningful (non-empty, non-comment) line of a plain text file."""
522
+ key = _cache_key(path)
523
+ if cache and key in _TEXT_CACHE:
524
+ return _TEXT_CACHE[key]
178
525
 
526
+ text = _read_text(path, example, kind)
179
527
 
180
- def read_token(path: str = "token.txt") -> str:
528
+ for line in text.splitlines():
529
+ line = line.strip()
530
+ if line and not line.startswith("#"):
531
+ _TEXT_CACHE[key] = line
532
+ return line
533
+
534
+ _TEXT_CACHE.pop(key, None)
535
+ raise EnvValueError(
536
+ f"{kind} file '{path}' is empty (no lines other than blanks and '#' comments).\n"
537
+ f" Write the value on the first line, e.g. `{example}`:\n"
538
+ f"{_create_commands(path, example)}"
539
+ )
540
+
541
+
542
+ def read_token(path: str = "token.txt", *, cache: bool = True) -> str:
181
543
  """
182
544
  Read the bot token from a file or ``.env``.
183
545
 
@@ -187,9 +549,9 @@ def read_token(path: str = "token.txt") -> str:
187
549
  123456789:BotSecretToken Main production bot
188
550
  987654321:AnotherToken Backup bot
189
551
 
190
- Reads only the first line. Inline comments (everything after the first
191
- whitespace) are ignored. Multiple tokens can be stored and swapped by
192
- reordering lines.
552
+ Reads only the first meaningful line (blank lines and ``#`` comments are
553
+ skipped). Inline comments (everything after the first whitespace) are
554
+ ignored. Multiple tokens can be stored and swapped by reordering lines.
193
555
 
194
556
  **Environment file** (``.env``)::
195
557
 
@@ -200,21 +562,35 @@ def read_token(path: str = "token.txt") -> str:
200
562
  :param path: Path to a token file, or ``".env"`` / ``".env:KEY"`` for
201
563
  environment files. Defaults to ``"token.txt"``.
202
564
  :type path: ``str``
565
+ :param cache: If ``True`` (default), use the in-memory copy if available; if
566
+ ``False``, re-read the file and refresh the cache.
567
+ :type cache: ``bool``
203
568
  :return: Bot token string.
204
569
  :rtype: ``str``
205
- :raises KeyError: If the key is not found in the ``.env`` file.
206
- :raises FileNotFoundError: If the file does not exist.
570
+ :raises EnvKeyError: If the key is not found in the ``.env`` file.
571
+ :raises EnvFileNotFoundError: If the file does not exist (message contains
572
+ commands to create it).
573
+ :raises EnvValueError: If the file or the value is empty.
207
574
  """
208
- if path.endswith(".env") or ".env:" in path:
209
- return read_envar(*_split_env_path(path, "TOKEN"))
210
-
211
- with open(path) as f:
212
- first_line: str = f.readline().strip()
213
- token, *_ = first_line.split()
575
+ example = "123456789:YourBotToken"
576
+
577
+ if _is_env_path(path):
578
+ file_path, name = _split_env_path(path, "TOKEN")
579
+ token = read_envar(file_path, name, cache=cache).strip()
580
+
581
+ if not token:
582
+ raise EnvValueError(
583
+ f"Variable '{name}' in '{file_path}' is empty.\n"
584
+ f" Hint: set the token after '=', e.g. `{name}={example}` "
585
+ f"(get one from @BotFather in Telegram)."
586
+ )
214
587
  return token
215
-
216
588
 
217
- def read_canvas_path(path: str = "canvas_path.txt") -> str:
589
+ line = _read_first_value(path, example, "Token", cache)
590
+ return line.split()[0]
591
+
592
+
593
+ def read_canvas_path(path: str = "canvas_path.txt", *, cache: bool = True) -> str:
218
594
  """
219
595
  Read the ``.canvas`` file path from a file or ``.env``.
220
596
 
@@ -224,8 +600,10 @@ def read_canvas_path(path: str = "canvas_path.txt") -> str:
224
600
  /home/user/project/main.canvas Production canvas
225
601
  /home/user/project/test.canvas Test canvas
226
602
 
227
- Reads only the first line. Multiple paths can be stored and swapped by
228
- reordering lines.
603
+ Reads only the first meaningful line (blank lines and ``#`` comments are
604
+ skipped). A comment may follow the path after two or more spaces or a tab
605
+ (single spaces are part of the path). Multiple paths can be stored and
606
+ swapped by reordering lines.
229
607
 
230
608
  **Environment file** (``.env``)::
231
609
 
@@ -236,16 +614,31 @@ def read_canvas_path(path: str = "canvas_path.txt") -> str:
236
614
  :param path: Path to a canvas path file, or ``".env"`` / ``".env:KEY"`` for
237
615
  environment files. Defaults to ``"canvas_path.txt"``.
238
616
  :type path: ``str``
617
+ :param cache: If ``True`` (default), use the in-memory copy if available; if
618
+ ``False``, re-read the file and refresh the cache.
619
+ :type cache: ``bool``
239
620
  :return: Path to the ``.canvas`` file.
240
621
  :rtype: ``str``
241
- :raises KeyError: If the key is not found in the ``.env`` file.
242
- :raises FileNotFoundError: If the file does not exist.
622
+ :raises EnvKeyError: If the key is not found in the ``.env`` file.
623
+ :raises EnvFileNotFoundError: If the file does not exist (message contains
624
+ commands to create it).
625
+ :raises EnvValueError: If the file or the value is empty.
243
626
  """
244
- if path.endswith(".env") or ".env:" in path:
245
- return read_envar(*_split_env_path(path, "CANVAS_PATH"))
246
-
247
- with open(path) as f:
248
- return f.readline().strip()
627
+ example = "/path/to/project/main.canvas"
628
+
629
+ if _is_env_path(path):
630
+ file_path, name = _split_env_path(path, "CANVAS_PATH")
631
+ value = read_envar(file_path, name, cache=cache).strip()
632
+
633
+ if not value:
634
+ raise EnvValueError(
635
+ f"Variable '{name}' in '{file_path}' is empty.\n"
636
+ f" Hint: set the path after '=', e.g. `{name}={example}`."
637
+ )
638
+ return value
639
+
640
+ line = _read_first_value(path, example, "Canvas path", cache)
641
+ return re.split(r"\s{2,}|\t", line, maxsplit=1)[0]
249
642
 
250
643
  # ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
251
644
  # Inline Keyboards
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: telekit
3
- Version: 2.6.0a1
3
+ Version: 2.6.0a2
4
4
  Summary: Declarative, developer-friendly library for building Telegram bots
5
5
  Home-page: https://github.com/Romashkaa/telekit
6
6
  Author: romashka
@@ -471,52 +471,13 @@ Telekit focuses on one job: making Telegram bot development easier.
471
471
 
472
472
  ---
473
473
 
474
- # Changes in version 2.6.0a1
475
-
476
- ### v2.5.4 `(bug-fix)`
477
- - Fix: Update condition to check for `None` instead of truthy value in `DSLHandler`
478
- ### v2.5.3 `(bug-fix)`
479
- - Fix: Remove `_` parameters from `_filter_entry` and `_handle_entry` methods in `DSLHandler`
480
- ### v2.5.2
481
- - Add `random_` variable prefix in `DSLHandler` — resolves to a random choice from a `list`/`tuple`/`str` static variable
482
-
483
- ### v2.5.1 `(bug-fix)`
484
- - Implement `TelegramMarkdownV2Sanitizer` for improved `MarkdownV2` handling
485
-
486
- ### v2.5.0 `(final)`
487
- - Refactor `CalendarPick` trait to use `set_keyboard`
488
-
489
- ### v2.5.0`b3`
490
- - Added `utils.Markers` class
491
- - Added `HTMLText` class for handling Telegram HTML strings with tag-aware indexing and slicing.
492
- - Added `PaginatedText` trait for displaying long HTML text in a paginated format, supporting navigation and smart splitting.
493
- - Added `__radd__` to `TextEntity`: `"Regular" + Bold(" and Bold")`
494
- - Added `__mul__` to `TextEntity`: `Bold("Text") * 3`
495
- - Added `enabled=` parameter to `TextEntity`: `Bold("bold text", enabled=is_text_bold)`
496
- - Added `TextBuilder` class – a fluent message composition API mirroring `InlineKeyboard`'s builder pattern
497
- - Added styles to `telekit.types`
498
- - Added `utils.CyclicList`
499
- - Fixed `_answer_callback_query` to always call `bot.answer_callback_query()`, even without a popup text
500
-
501
- ### v2.5.0`b2`
502
- - Added the `escape` parameter to `telekit.utils.*`:
503
- - `make_user_link`
504
- - `make_bot_link`
505
- - `Handler.handlers_dict` now excludes private handlers (classes whose names start with `_`).
506
- - Added `Debug.duplicate_handler_warnings` to warn about duplicate handler names during initialization.
507
- - Added `Handler.chat` object (BETA)
508
-
509
- ### v2.5.0`b1`
510
- - Added `Sender.send_message` method.
511
- - Added `utils.make_mention` utility for generating `tg://user?id=` mention links.
512
- - Added new inline button types to `inline_buttons`:
513
- - `ContactButton` — mentions a user by Telegram ID via `tg://user?id=`.
514
- - `UserLinkButton` — opens a user profile by username; supports pre-filled message text.
515
- - `BotLinkButton` — opens a bot by username; supports deep-link `?start=` payload.
516
- - Added new methods to `InlineKeyboard`:
517
- - `add_contact` — adds a `ContactButton`.
518
- - `add_user_link` — adds a `UserLinkButton`.
519
- - `add_bot_link` — adds a `BotLinkButton`.
520
-
521
- ### v2.5.0`b0`
522
- - Added support for t-strings (PEP 750, Python 3.14+) in `TextEntity`.
474
+ # Changes in version 2.6.0a2
475
+
476
+ ### v2.6.0 `a2`
477
+ - Reworked `.env` and token/canvas file reading in `utils`: full `.env` syntax support (comments, `export`, quotes and escapes, multi-line values, `$VAR` interpolation), a `cache` parameter (default `True`) with `clear_cache()`, detailed errors with fix suggestions and creation commands, and new `Env*Error` exceptions; `load_env` now raises `EnvFileNotFoundError` for a missing file instead of returning `{}`
478
+
479
+ ### v2.6.0 `a1`
480
+ - Added a module-loading utility in `utils`
481
+
482
+ ### v2.6.0 `a0`
483
+ - Improved formatting in `_on.py`
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes