tgdata 0.0.5__tar.gz → 0.0.7__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.
- {tgdata-0.0.5 → tgdata-0.0.7}/PKG-INFO +1 -1
- {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/__init__.py +3 -2
- {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/connection_engine.py +36 -0
- {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/message_engine.py +49 -10
- {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/tgdata.py +4 -2
- {tgdata-0.0.5 → tgdata-0.0.7}/tgdata.egg-info/PKG-INFO +1 -1
- {tgdata-0.0.5 → tgdata-0.0.7}/README.md +0 -0
- {tgdata-0.0.5 → tgdata-0.0.7}/setup.cfg +0 -0
- {tgdata-0.0.5 → tgdata-0.0.7}/setup.py +0 -0
- {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/models.py +0 -0
- {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/progress.py +0 -0
- {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/__init__.py +0 -0
- {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/my_test.py +0 -0
- {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/test_00_connection.py +0 -0
- {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/test_01_list_group_chats.py +0 -0
- {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/test_02_get_message_count.py +0 -0
- {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/test_03_get_all_messages.py +0 -0
- {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/test_04_get_all_messages_in_batches.py +0 -0
- {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/test_05_with_progress_feature.py +0 -0
- {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/test_06_polling_simple.py +0 -0
- {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/test_07_on_new_message_simple.py +0 -0
- {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/test_07_real_time_event_handler.py +0 -0
- {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/test_08_batch_callback.py +0 -0
- {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/test_08_polling.py +0 -0
- {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/test_10_advanced_features.py +0 -0
- {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/test_debug_min_id.py +0 -0
- {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/test_polling_debug.py +0 -0
- {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/test_username_support.py +0 -0
- {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/utils.py +0 -0
- {tgdata-0.0.5 → tgdata-0.0.7}/tgdata.egg-info/SOURCES.txt +0 -0
- {tgdata-0.0.5 → tgdata-0.0.7}/tgdata.egg-info/dependency_links.txt +0 -0
- {tgdata-0.0.5 → tgdata-0.0.7}/tgdata.egg-info/requires.txt +0 -0
- {tgdata-0.0.5 → tgdata-0.0.7}/tgdata.egg-info/top_level.txt +0 -0
|
@@ -33,7 +33,7 @@ from .utils import (
|
|
|
33
33
|
# Progress tracking
|
|
34
34
|
from .progress import ProgressTracker
|
|
35
35
|
|
|
36
|
-
__version__ = "0.0.
|
|
36
|
+
__version__ = "0.0.7"
|
|
37
37
|
|
|
38
38
|
__all__ = [
|
|
39
39
|
# Main class
|
|
@@ -60,4 +60,5 @@ __all__ = [
|
|
|
60
60
|
|
|
61
61
|
# Progress
|
|
62
62
|
"ProgressTracker"
|
|
63
|
-
]
|
|
63
|
+
]
|
|
64
|
+
from tgdata.connection_engine import AuthRequiredError # noqa: E402,F401
|
|
@@ -67,6 +67,14 @@ class ConnectionPool:
|
|
|
67
67
|
self.connections.clear()
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
class AuthRequiredError(RuntimeError):
|
|
71
|
+
"""The session is not authorized and this code path is non-interactive.
|
|
72
|
+
|
|
73
|
+
Raised by ephemeral_client() instead of Telethon's start() prompt —
|
|
74
|
+
a headless process must FAIL LOUDLY here, never sit on input().
|
|
75
|
+
Fix: run an interactive login for this account once, by hand."""
|
|
76
|
+
|
|
77
|
+
|
|
70
78
|
class ConnectionEngine:
|
|
71
79
|
"""
|
|
72
80
|
Manages Telegram connections with advanced features.
|
|
@@ -189,6 +197,34 @@ class ConnectionEngine:
|
|
|
189
197
|
yield client
|
|
190
198
|
# Intentionally NO disconnect here — the connection is persistent.
|
|
191
199
|
|
|
200
|
+
@asynccontextmanager
|
|
201
|
+
async def ephemeral_client(self):
|
|
202
|
+
"""USE-AND-CLOSE (2026-08-05): a fresh client on the same resolved
|
|
203
|
+
session, connected WITHOUT start() (never interactive), disconnected
|
|
204
|
+
in finally — for short, human-triggered lookups (identity fetch,
|
|
205
|
+
access probe) that must not park a live transport next to a standing
|
|
206
|
+
loop sharing the session file. The persistent session()/pool path
|
|
207
|
+
above is untouched — it remains correct for the loop's own fetches,
|
|
208
|
+
where ONE process owns the session for its lifetime.
|
|
209
|
+
|
|
210
|
+
Raises AuthRequiredError when the session is not authorized: the
|
|
211
|
+
one situation start() would have prompted for a login code and hung
|
|
212
|
+
a headless process on stdin forever."""
|
|
213
|
+
config = self._load_config()
|
|
214
|
+
client = TelegramClient(config.session_file, config.api_id, config.api_hash)
|
|
215
|
+
try:
|
|
216
|
+
await client.connect()
|
|
217
|
+
if not await client.is_user_authorized():
|
|
218
|
+
raise AuthRequiredError(
|
|
219
|
+
f"session {config.session_file!r} is not authorized — "
|
|
220
|
+
f"run an interactive login for this account once, then retry")
|
|
221
|
+
yield client
|
|
222
|
+
finally:
|
|
223
|
+
try:
|
|
224
|
+
await client.disconnect()
|
|
225
|
+
except Exception: # noqa: BLE001 — teardown must never mask the work
|
|
226
|
+
pass
|
|
227
|
+
|
|
192
228
|
async def _init_primary_client(self):
|
|
193
229
|
"""Create and authenticate the primary client once (may be interactive)."""
|
|
194
230
|
config = self._load_config()
|
|
@@ -64,7 +64,8 @@ class MessageEngine:
|
|
|
64
64
|
rate_limit_strategy: str = 'wait',
|
|
65
65
|
allow_full_fetch: bool = False,
|
|
66
66
|
download_media_to: Optional[str] = None,
|
|
67
|
-
include_media: bool = False
|
|
67
|
+
include_media: bool = False,
|
|
68
|
+
heartbeat: Optional[Callable] = None) -> pd.DataFrame:
|
|
68
69
|
"""
|
|
69
70
|
Fetch messages from a group with various filters.
|
|
70
71
|
|
|
@@ -112,6 +113,19 @@ class MessageEngine:
|
|
|
112
113
|
)
|
|
113
114
|
progress_tracker.start()
|
|
114
115
|
|
|
116
|
+
# Liveness heartbeat: called with a phase label on EVERY sign of life —
|
|
117
|
+
# each message, each media chunk, each slice of a designed sleep. The
|
|
118
|
+
# caller's watchdog can then treat SILENCE as the hang signal with a
|
|
119
|
+
# small flat threshold, instead of guessing from total duration
|
|
120
|
+
# (which punishes big fetches). Exception-safe: a broken callback
|
|
121
|
+
# must never kill a fetch.
|
|
122
|
+
def _beat(phase: str) -> None:
|
|
123
|
+
if heartbeat is not None:
|
|
124
|
+
try:
|
|
125
|
+
heartbeat(phase)
|
|
126
|
+
except Exception: # noqa: BLE001
|
|
127
|
+
pass
|
|
128
|
+
|
|
115
129
|
messages_data = []
|
|
116
130
|
processed_count = 0
|
|
117
131
|
iterated_count = 0
|
|
@@ -143,7 +157,7 @@ class MessageEngine:
|
|
|
143
157
|
try:
|
|
144
158
|
channel = await client.get_entity(group_id)
|
|
145
159
|
except ValueError:
|
|
146
|
-
channel = await self._entity_after_dialog_sync(client, group_id)
|
|
160
|
+
channel = await self._entity_after_dialog_sync(client, group_id, _beat)
|
|
147
161
|
logger.info(f"Fetching messages from: {channel.title}")
|
|
148
162
|
|
|
149
163
|
# Per-chat context stamped onto every row (provenance / deep links)
|
|
@@ -222,6 +236,7 @@ class MessageEngine:
|
|
|
222
236
|
logger.debug(f"Processing message {msg.id} (min_id={min_id})")
|
|
223
237
|
|
|
224
238
|
# Apply date filters (direction-aware: stop as soon as the
|
|
239
|
+
_beat("messages")
|
|
225
240
|
# iteration order guarantees no further in-window messages)
|
|
226
241
|
if start_date and msg.date < start_date:
|
|
227
242
|
if reverse:
|
|
@@ -244,7 +259,8 @@ class MessageEngine:
|
|
|
244
259
|
chat_id=chat_id,
|
|
245
260
|
link_base=link_base,
|
|
246
261
|
media_dir=download_media_to,
|
|
247
|
-
include_media=include_media
|
|
262
|
+
include_media=include_media,
|
|
263
|
+
_beat=_beat
|
|
248
264
|
)
|
|
249
265
|
|
|
250
266
|
if message_data:
|
|
@@ -274,7 +290,14 @@ class MessageEngine:
|
|
|
274
290
|
# Apply batch delay to avoid rate limits
|
|
275
291
|
if batch_delay > 0:
|
|
276
292
|
logger.info(f"Waiting {batch_delay}s between batches to avoid rate limits...")
|
|
277
|
-
|
|
293
|
+
# sliced so the designed pause BEATS —
|
|
294
|
+
# a paused fetch is alive, not hung
|
|
295
|
+
_left = float(batch_delay)
|
|
296
|
+
while _left > 0:
|
|
297
|
+
_beat("pause")
|
|
298
|
+
_step = min(10.0, _left)
|
|
299
|
+
await asyncio.sleep(_step)
|
|
300
|
+
_left -= _step
|
|
278
301
|
|
|
279
302
|
processed_count += 1
|
|
280
303
|
|
|
@@ -328,7 +351,17 @@ class MessageEngine:
|
|
|
328
351
|
f"FloodWait #{flood_attempts}/{MAX_FLOOD_RETRIES}: waiting {e.seconds}s, "
|
|
329
352
|
f"then resuming after message id {last_seen_id}"
|
|
330
353
|
)
|
|
331
|
-
|
|
354
|
+
if rate_limit_strategy == 'wait':
|
|
355
|
+
# sliced locally so the mandated wait BEATS with its
|
|
356
|
+
# remaining seconds — visible, alive, exact total.
|
|
357
|
+
_left = float(e.seconds)
|
|
358
|
+
while _left > 0:
|
|
359
|
+
_beat(f"flood-wait {int(_left)}s")
|
|
360
|
+
_step = min(10.0, _left)
|
|
361
|
+
await asyncio.sleep(_step)
|
|
362
|
+
_left -= _step
|
|
363
|
+
else:
|
|
364
|
+
await self.connection_engine.handle_rate_limit(e, client, strategy=rate_limit_strategy)
|
|
332
365
|
# loop continues → resume from last_seen_id with identical settings
|
|
333
366
|
|
|
334
367
|
except Exception as e:
|
|
@@ -374,7 +407,7 @@ class MessageEngine:
|
|
|
374
407
|
return df
|
|
375
408
|
|
|
376
409
|
@staticmethod
|
|
377
|
-
async def _entity_after_dialog_sync(client, group_id):
|
|
410
|
+
async def _entity_after_dialog_sync(client, group_id, _beat=lambda phase: None):
|
|
378
411
|
"""get_entity said "never seen this id" — an ambiguous error hiding two
|
|
379
412
|
different situations. Telegram sessions can only resolve ids they have
|
|
380
413
|
encountered, so a FRESH session fails even for groups the account IS a
|
|
@@ -383,7 +416,9 @@ class MessageEngine:
|
|
|
383
416
|
non-member fails again, and only then is it a real access problem —
|
|
384
417
|
reported as one, instead of Telethon's cache riddle."""
|
|
385
418
|
logger.info(f"Entity {group_id} unknown to this session — syncing dialogs to check membership")
|
|
419
|
+
_beat("dialog-sync")
|
|
386
420
|
await client.get_dialogs()
|
|
421
|
+
_beat("dialog-sync done")
|
|
387
422
|
try:
|
|
388
423
|
return await client.get_entity(group_id)
|
|
389
424
|
except ValueError:
|
|
@@ -446,7 +481,7 @@ class MessageEngine:
|
|
|
446
481
|
"""
|
|
447
482
|
return f"{chat_id}_{message_id}" if chat_id is not None else str(message_id)
|
|
448
483
|
|
|
449
|
-
async def _download_or_reuse(self, client, msg, media_dir: str, chat_id: Optional[int]):
|
|
484
|
+
async def _download_or_reuse(self, client, msg, media_dir: str, chat_id: Optional[int], _beat=lambda phase: None):
|
|
450
485
|
"""
|
|
451
486
|
Download a message's media to '<media_dir>/<chat_id>_<message_id>.<ext>',
|
|
452
487
|
REUSING the file if it already exists (idempotent re-scrape).
|
|
@@ -463,7 +498,10 @@ class MessageEngine:
|
|
|
463
498
|
logger.debug(f"Media for message {msg.id} already present ({stem}{ext}); reusing")
|
|
464
499
|
return target
|
|
465
500
|
# File doesn't exist yet: Telethon writes exactly to `target` (no rename).
|
|
466
|
-
|
|
501
|
+
# progress_callback fires per chunk — a 50MB video beats continuously.
|
|
502
|
+
return await client.download_media(
|
|
503
|
+
msg, file=target,
|
|
504
|
+
progress_callback=lambda cur, tot: _beat("media"))
|
|
467
505
|
|
|
468
506
|
async def _process_message(self,
|
|
469
507
|
msg,
|
|
@@ -472,7 +510,8 @@ class MessageEngine:
|
|
|
472
510
|
chat_id: Optional[int] = None,
|
|
473
511
|
link_base: Optional[str] = None,
|
|
474
512
|
media_dir: Optional[str] = None,
|
|
475
|
-
include_media: bool = False
|
|
513
|
+
include_media: bool = False,
|
|
514
|
+
_beat=lambda phase: None) -> Optional[MessageData]:
|
|
476
515
|
"""Process a single message"""
|
|
477
516
|
try:
|
|
478
517
|
sender = await msg.get_sender()
|
|
@@ -514,7 +553,7 @@ class MessageEngine:
|
|
|
514
553
|
if media_dir and msg.media is not None:
|
|
515
554
|
try:
|
|
516
555
|
message_data.media_path = await self._download_or_reuse(
|
|
517
|
-
client, msg, media_dir, chat_id
|
|
556
|
+
client, msg, media_dir, chat_id, _beat
|
|
518
557
|
)
|
|
519
558
|
except Exception as e:
|
|
520
559
|
logger.warning(f"Failed to download media for message {msg.id}: {e}")
|
|
@@ -136,7 +136,8 @@ class TgData:
|
|
|
136
136
|
rate_limit_strategy: str = 'wait',
|
|
137
137
|
allow_full_fetch: bool = False,
|
|
138
138
|
download_media_to: Optional[str] = None,
|
|
139
|
-
include_media: bool = False
|
|
139
|
+
include_media: bool = False,
|
|
140
|
+
heartbeat: Optional[Callable] = None) -> pd.DataFrame:
|
|
140
141
|
"""
|
|
141
142
|
Get messages from a group with various options.
|
|
142
143
|
|
|
@@ -204,7 +205,8 @@ class TgData:
|
|
|
204
205
|
rate_limit_strategy=rate_limit_strategy,
|
|
205
206
|
allow_full_fetch=allow_full_fetch,
|
|
206
207
|
download_media_to=download_media_to,
|
|
207
|
-
include_media=include_media
|
|
208
|
+
include_media=include_media,
|
|
209
|
+
heartbeat=heartbeat
|
|
208
210
|
)
|
|
209
211
|
|
|
210
212
|
if with_progress:
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|