cipher-attacks commited on
Commit
a48b894
·
1 Parent(s): 4566c49

fix gather bug

Browse files
app/bot/handlers/library.py CHANGED
@@ -1,4 +1,3 @@
1
- import asyncio
2
  import logging
3
  from typing import Union
4
 
@@ -8,7 +7,7 @@ from aiogram.types import CallbackQuery, Message
8
  from sqlalchemy.ext.asyncio import AsyncSession
9
 
10
  from app.bot.keyboards import library as kb
11
- from app.core.aio import gather_safe
12
  from app.core.catalog import KINDS
13
  from app.core.i18n import all_translations, t
14
  from app.db.models import User
@@ -26,10 +25,9 @@ async def _show(target: Union[Message, CallbackQuery], text: str, markup) -> Non
26
  if not target.message:
27
  await target.answer()
28
  return
29
- _, edited = await asyncio.gather(
30
  target.answer(),
31
  target.message.edit_text(text, reply_markup=markup),
32
- return_exceptions=True,
33
  )
34
  if isinstance(edited, Exception) and "not modified" not in str(edited):
35
  log.warning("edit_text failed (%r); sending a new message instead", edited)
 
 
1
  import logging
2
  from typing import Union
3
 
 
7
  from sqlalchemy.ext.asyncio import AsyncSession
8
 
9
  from app.bot.keyboards import library as kb
10
+ from app.core.aio import gather_results, gather_safe
11
  from app.core.catalog import KINDS
12
  from app.core.i18n import all_translations, t
13
  from app.db.models import User
 
25
  if not target.message:
26
  await target.answer()
27
  return
28
+ _, edited = await gather_results(
29
  target.answer(),
30
  target.message.edit_text(text, reply_markup=markup),
 
31
  )
32
  if isinstance(edited, Exception) and "not modified" not in str(edited):
33
  log.warning("edit_text failed (%r); sending a new message instead", edited)
app/core/aio.py CHANGED
@@ -4,9 +4,22 @@ import logging
4
  log = logging.getLogger(__name__)
5
 
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  async def gather_safe(*aws) -> None:
8
- """Run independent Telegram calls in parallel; log (never raise) failures."""
9
- results = await asyncio.gather(*aws, return_exceptions=True)
10
- for res in results:
11
  if isinstance(res, Exception):
12
  log.info("Parallel call failed: %r", res)
 
4
  log = logging.getLogger(__name__)
5
 
6
 
7
+ async def _await(aw):
8
+ return await aw
9
+
10
+
11
+ async def gather_results(*aws) -> list:
12
+ """Run awaitables in parallel and return results (exceptions included, never raised).
13
+
14
+ aiogram's bot-method objects (call.answer(), message.edit_text(), ...) are awaitable but are
15
+ unhashable pydantic models, and asyncio.gather() puts its arguments in a dict -> TypeError
16
+ "unhashable type". Wrapping each one in a coroutine avoids that.
17
+ """
18
+ return await asyncio.gather(*(_await(a) for a in aws), return_exceptions=True)
19
+
20
+
21
  async def gather_safe(*aws) -> None:
22
+ """Parallel Telegram calls; failures are logged, not raised."""
23
+ for res in await gather_results(*aws):
 
24
  if isinstance(res, Exception):
25
  log.info("Parallel call failed: %r", res)
tests/test_logic.py CHANGED
@@ -6,7 +6,10 @@ from types import SimpleNamespace
6
 
7
  sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
8
 
 
 
9
  from app.core import cache # noqa: E402
 
10
  from app.services.engagement import DAILY_POINTS, MILESTONES, touch # noqa: E402
11
  from app.services.ingest import parse_caption # noqa: E402
12
 
@@ -53,6 +56,35 @@ def test_cache_roundtrip_and_isolation():
53
  assert cache.get(7) is None
54
 
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  if __name__ == "__main__":
57
  for name, fn in list(globals().items()):
58
  if name.startswith("test_"):
 
6
 
7
  sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
8
 
9
+ import asyncio # noqa: E402
10
+
11
  from app.core import cache # noqa: E402
12
+ from app.core.aio import gather_results, gather_safe # noqa: E402
13
  from app.services.engagement import DAILY_POINTS, MILESTONES, touch # noqa: E402
14
  from app.services.ingest import parse_caption # noqa: E402
15
 
 
56
  assert cache.get(7) is None
57
 
58
 
59
+ class _Unhashable:
60
+ """Mimics aiogram method objects: awaitable, but unhashable (pydantic model)."""
61
+
62
+ __hash__ = None
63
+
64
+ def __init__(self, value):
65
+ self.value = value
66
+
67
+ def __await__(self):
68
+ async def run():
69
+ return self.value
70
+
71
+ return run().__await__()
72
+
73
+
74
+ def test_plain_gather_fails_but_ours_works():
75
+ async def go():
76
+ try:
77
+ await asyncio.gather(_Unhashable(1))
78
+ plain_ok = True
79
+ except TypeError:
80
+ plain_ok = False
81
+ assert plain_ok is False # this is the bug that broke the library buttons
82
+ assert await gather_results(_Unhashable(1), _Unhashable(2)) == [1, 2]
83
+ await gather_safe(_Unhashable(3))
84
+
85
+ asyncio.run(go())
86
+
87
+
88
  if __name__ == "__main__":
89
  for name, fn in list(globals().items()):
90
  if name.startswith("test_"):