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.
Files changed (33) hide show
  1. {tgdata-0.0.5 → tgdata-0.0.7}/PKG-INFO +1 -1
  2. {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/__init__.py +3 -2
  3. {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/connection_engine.py +36 -0
  4. {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/message_engine.py +49 -10
  5. {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/tgdata.py +4 -2
  6. {tgdata-0.0.5 → tgdata-0.0.7}/tgdata.egg-info/PKG-INFO +1 -1
  7. {tgdata-0.0.5 → tgdata-0.0.7}/README.md +0 -0
  8. {tgdata-0.0.5 → tgdata-0.0.7}/setup.cfg +0 -0
  9. {tgdata-0.0.5 → tgdata-0.0.7}/setup.py +0 -0
  10. {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/models.py +0 -0
  11. {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/progress.py +0 -0
  12. {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/__init__.py +0 -0
  13. {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/my_test.py +0 -0
  14. {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/test_00_connection.py +0 -0
  15. {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/test_01_list_group_chats.py +0 -0
  16. {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/test_02_get_message_count.py +0 -0
  17. {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/test_03_get_all_messages.py +0 -0
  18. {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/test_04_get_all_messages_in_batches.py +0 -0
  19. {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/test_05_with_progress_feature.py +0 -0
  20. {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/test_06_polling_simple.py +0 -0
  21. {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/test_07_on_new_message_simple.py +0 -0
  22. {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/test_07_real_time_event_handler.py +0 -0
  23. {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/test_08_batch_callback.py +0 -0
  24. {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/test_08_polling.py +0 -0
  25. {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/test_10_advanced_features.py +0 -0
  26. {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/test_debug_min_id.py +0 -0
  27. {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/test_polling_debug.py +0 -0
  28. {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/smoke_tests/test_username_support.py +0 -0
  29. {tgdata-0.0.5 → tgdata-0.0.7}/tgdata/utils.py +0 -0
  30. {tgdata-0.0.5 → tgdata-0.0.7}/tgdata.egg-info/SOURCES.txt +0 -0
  31. {tgdata-0.0.5 → tgdata-0.0.7}/tgdata.egg-info/dependency_links.txt +0 -0
  32. {tgdata-0.0.5 → tgdata-0.0.7}/tgdata.egg-info/requires.txt +0 -0
  33. {tgdata-0.0.5 → tgdata-0.0.7}/tgdata.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tgdata
3
- Version: 0.0.5
3
+ Version: 0.0.7
4
4
  Summary: A production-grade Python library for extracting and processing Telegram group and channel messages
5
5
  Author: enes kuzucu
6
6
  Classifier: Development Status :: 3 - Alpha
@@ -33,7 +33,7 @@ from .utils import (
33
33
  # Progress tracking
34
34
  from .progress import ProgressTracker
35
35
 
36
- __version__ = "0.0.5"
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) -> pd.DataFrame:
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
- await asyncio.sleep(batch_delay)
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
- await self.connection_engine.handle_rate_limit(e, client, strategy=rate_limit_strategy)
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
- return await client.download_media(msg, file=target)
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) -> Optional[MessageData]:
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) -> pd.DataFrame:
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tgdata
3
- Version: 0.0.5
3
+ Version: 0.0.7
4
4
  Summary: A production-grade Python library for extracting and processing Telegram group and channel messages
5
5
  Author: enes kuzucu
6
6
  Classifier: Development Status :: 3 - Alpha
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes