aiogram-testing 1.2.0__tar.gz → 1.3.0__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 (26) hide show
  1. {aiogram_testing-1.2.0 → aiogram_testing-1.3.0}/PKG-INFO +23 -2
  2. {aiogram_testing-1.2.0 → aiogram_testing-1.3.0}/README.md +22 -1
  3. aiogram_testing-1.3.0/aiogram_tests/__init__.py +8 -0
  4. aiogram_testing-1.2.0/aiogram_tests/requester.py → aiogram_testing-1.3.0/aiogram_tests/calls.py +5 -48
  5. {aiogram_testing-1.2.0 → aiogram_testing-1.3.0}/aiogram_tests/handler/handler.py +1 -1
  6. {aiogram_testing-1.2.0 → aiogram_testing-1.3.0}/aiogram_tests/mocked_bot.py +45 -4
  7. aiogram_testing-1.3.0/aiogram_tests/py.typed +0 -0
  8. aiogram_testing-1.3.0/aiogram_tests/requester.py +53 -0
  9. {aiogram_testing-1.2.0 → aiogram_testing-1.3.0}/aiogram_tests/tester.py +3 -3
  10. {aiogram_testing-1.2.0 → aiogram_testing-1.3.0}/pyproject.toml +1 -1
  11. aiogram_testing-1.2.0/aiogram_tests/__init__.py +0 -6
  12. {aiogram_testing-1.2.0 → aiogram_testing-1.3.0}/aiogram_tests/exceptions.py +0 -0
  13. {aiogram_testing-1.2.0 → aiogram_testing-1.3.0}/aiogram_tests/handler/__init__.py +0 -0
  14. {aiogram_testing-1.2.0 → aiogram_testing-1.3.0}/aiogram_tests/handler/base.py +0 -0
  15. {aiogram_testing-1.2.0 → aiogram_testing-1.3.0}/aiogram_tests/pytest_plugin.py +0 -0
  16. {aiogram_testing-1.2.0 → aiogram_testing-1.3.0}/aiogram_tests/types/__init__.py +0 -0
  17. {aiogram_testing-1.2.0 → aiogram_testing-1.3.0}/aiogram_tests/types/dataset/__init__.py +0 -0
  18. {aiogram_testing-1.2.0 → aiogram_testing-1.3.0}/aiogram_tests/types/dataset/base.py +0 -0
  19. {aiogram_testing-1.2.0 → aiogram_testing-1.3.0}/aiogram_tests/types/dataset/chats.py +0 -0
  20. {aiogram_testing-1.2.0 → aiogram_testing-1.3.0}/aiogram_tests/types/dataset/entities.py +0 -0
  21. {aiogram_testing-1.2.0 → aiogram_testing-1.3.0}/aiogram_tests/types/dataset/events.py +0 -0
  22. {aiogram_testing-1.2.0 → aiogram_testing-1.3.0}/aiogram_tests/types/dataset/media.py +0 -0
  23. {aiogram_testing-1.2.0 → aiogram_testing-1.3.0}/aiogram_tests/types/dataset/messages.py +0 -0
  24. {aiogram_testing-1.2.0 → aiogram_testing-1.3.0}/aiogram_tests/types/dataset/misc.py +0 -0
  25. {aiogram_testing-1.2.0 → aiogram_testing-1.3.0}/aiogram_tests/types/dataset/payments.py +0 -0
  26. {aiogram_testing-1.2.0 → aiogram_testing-1.3.0}/aiogram_tests/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiogram-testing
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: A library for testing your bots on aiogram
5
5
  License: MIT
6
6
  Author: k0te1ch
@@ -101,7 +101,28 @@ calls.assert_not_called(AnswerCallbackQuery)
101
101
  ```
102
102
 
103
103
  Accessing a method that was not called (`calls.edit_message_text`) raises `MethodIsNotCalledError`, an
104
- `AttributeError`, so `hasattr(calls, "edit_message_text")` works.
104
+ `AttributeError`, so `hasattr(calls, "edit_message_text")` works. `calls.result` is what the dispatcher returned for
105
+ the update (a handler's return value, or `UNHANDLED` when nothing matched).
106
+
107
+ ### Bot API answers and failures
108
+
109
+ With `auto_mock_success` on (the default) the bot answers like Telegram would: `send_message` returns a `Message`
110
+ with a fresh `message_id`, the target chat and the text; edits return the edited message (or `True` for inline
111
+ messages); boolean methods return `True`; `get_me` returns the bot. Queue a specific answer or a failure with
112
+ `add_result_for`; queued answers are used first, in order:
113
+
114
+ ```python
115
+ from aiogram.exceptions import TelegramBadRequest, TelegramRetryAfter
116
+
117
+ bot.add_result_for(SendMessage, ok=False, error_code=400, description="Bad Request: chat not found")
118
+ bot.add_result_for(SendMessage, ok=False, error_code=429, description="Too Many Requests", retry_after=3)
119
+ ```
120
+
121
+ aiogram raises `TelegramRetryAfter` only for a non-zero `retry_after`; with `0` the failure is a plain
122
+ `TelegramAPIError`. Patch `asyncio.sleep` in the code under test if a retry should not actually wait.
123
+
124
+ Code that talks to the bot outside the dispatcher (background jobs, notifiers) can use a `MockedBot` directly;
125
+ `bot.calls` holds every call it made.
105
126
 
106
127
  ### Testing a whole router: `BotTester`
107
128
 
@@ -79,7 +79,28 @@ calls.assert_not_called(AnswerCallbackQuery)
79
79
  ```
80
80
 
81
81
  Accessing a method that was not called (`calls.edit_message_text`) raises `MethodIsNotCalledError`, an
82
- `AttributeError`, so `hasattr(calls, "edit_message_text")` works.
82
+ `AttributeError`, so `hasattr(calls, "edit_message_text")` works. `calls.result` is what the dispatcher returned for
83
+ the update (a handler's return value, or `UNHANDLED` when nothing matched).
84
+
85
+ ### Bot API answers and failures
86
+
87
+ With `auto_mock_success` on (the default) the bot answers like Telegram would: `send_message` returns a `Message`
88
+ with a fresh `message_id`, the target chat and the text; edits return the edited message (or `True` for inline
89
+ messages); boolean methods return `True`; `get_me` returns the bot. Queue a specific answer or a failure with
90
+ `add_result_for`; queued answers are used first, in order:
91
+
92
+ ```python
93
+ from aiogram.exceptions import TelegramBadRequest, TelegramRetryAfter
94
+
95
+ bot.add_result_for(SendMessage, ok=False, error_code=400, description="Bad Request: chat not found")
96
+ bot.add_result_for(SendMessage, ok=False, error_code=429, description="Too Many Requests", retry_after=3)
97
+ ```
98
+
99
+ aiogram raises `TelegramRetryAfter` only for a non-zero `retry_after`; with `0` the failure is a plain
100
+ `TelegramAPIError`. Patch `asyncio.sleep` in the code under test if a retry should not actually wait.
101
+
102
+ Code that talks to the bot outside the dispatcher (background jobs, notifiers) can use a `MockedBot` directly;
103
+ `bot.calls` holds every call it made.
83
104
 
84
105
  ### Testing a whole router: `BotTester`
85
106
 
@@ -0,0 +1,8 @@
1
+ from .calls import Calls
2
+ from .calls import CallsList
3
+ from .mocked_bot import MockedBot
4
+ from .requester import MockedRequester
5
+ from .tester import BotTester
6
+
7
+ __all__ = ["BotTester", "Calls", "CallsList", "MockedBot", "MockedRequester"]
8
+ __version__ = "1.3.0" # x-release-please-version
@@ -1,13 +1,11 @@
1
+ from collections.abc import Iterable
1
2
  from collections.abc import Iterator
2
3
  from typing import Any
3
4
  from typing import TypeVar
4
5
 
5
6
  from aiogram.methods import TelegramMethod
6
- from aiogram.methods.base import Response
7
- from aiogram.methods.base import TelegramType
8
7
 
9
8
  from .exceptions import MethodIsNotCalledError
10
- from .handler.base import RequestHandler
11
9
  from .utils import camel_case2snake_case
12
10
 
13
11
  M = TypeVar("M", bound=TelegramMethod)
@@ -32,8 +30,11 @@ class Calls:
32
30
  the IDE and type checkers understand.
33
31
  """
34
32
 
35
- def __init__(self, methods: list[TelegramMethod] | tuple = ()):
33
+ def __init__(self, methods: Iterable[TelegramMethod] = (), result: Any = None):
36
34
  self._methods = list(methods)
35
+ # What the dispatcher returned for the update: a handler's return value, UNHANDLED, True after an error
36
+ # handler...
37
+ self.result = result
37
38
  # Same list on every access: tests pop() from calls.send_message
38
39
  self._groups: dict[str, CallsList] = {}
39
40
  for m in self._methods:
@@ -84,47 +85,3 @@ class Calls:
84
85
  if calls:
85
86
  made = "\n".join(f" {call!r}" for call in calls)
86
87
  raise AssertionError(f"{method.__name__} was called {len(calls)} time(s):\n{made}")
87
-
88
-
89
- class MockedRequester:
90
- def __init__(self, request_handler: RequestHandler):
91
- self._handler: RequestHandler = request_handler
92
-
93
- async def query(self, *args, **kwargs) -> Calls:
94
- self._check_arguments(*args, **kwargs)
95
-
96
- methods = self._handler.bot.session.methods
97
- already_made = len(methods)
98
- await self._handler(*args, **kwargs)
99
-
100
- return Calls(list(methods)[already_made:])
101
-
102
- def add_result_for(
103
- self,
104
- method: type[TelegramMethod[TelegramType]],
105
- ok: bool,
106
- result: TelegramType = None,
107
- description: str | None = None,
108
- error_code: int = 200,
109
- migrate_to_chat_id: int | None = None,
110
- retry_after: int | None = None,
111
- ) -> Response[TelegramType]:
112
- response = self._handler.add_result_for(
113
- method=method,
114
- ok=ok,
115
- result=result,
116
- description=description,
117
- error_code=error_code,
118
- migrate_to_chat_id=migrate_to_chat_id,
119
- retry_after=retry_after,
120
- )
121
- return response
122
-
123
- def _check_arguments(self, *args, **kwargs) -> None:
124
- build_update = getattr(self._handler, "build_update", None)
125
- if build_update is None:
126
- return
127
- try:
128
- build_update(*args, **kwargs)
129
- except TypeError as e:
130
- raise AttributeError(f"incorrect argument name. {e}") from e
@@ -65,7 +65,7 @@ class TelegramEventObserverHandler(RequestHandler):
65
65
  await state.set_state(self._state)
66
66
  await state.update_data(**self._state_data)
67
67
 
68
- await self.dp.feed_update(self.bot, update)
68
+ return await self.dp.feed_update(self.bot, update)
69
69
 
70
70
  def _get_state_context(self, update: types.Update) -> FSMContext:
71
71
  """
@@ -1,5 +1,10 @@
1
+ import itertools
2
+ import typing
1
3
  from collections import deque
2
4
  from collections.abc import AsyncGenerator
5
+ from datetime import UTC
6
+ from datetime import datetime
7
+ from typing import Any
3
8
 
4
9
  from aiogram import Bot
5
10
  from aiogram.client.session.base import BaseSession
@@ -7,10 +12,13 @@ from aiogram.methods import TelegramMethod
7
12
  from aiogram.methods.base import Request
8
13
  from aiogram.methods.base import Response
9
14
  from aiogram.methods.base import TelegramType
15
+ from aiogram.types import Chat
16
+ from aiogram.types import Message
10
17
  from aiogram.types import ResponseParameters
11
18
  from aiogram.types import User
12
19
  from aiogram.types.base import UNSET_TYPE
13
20
 
21
+ from .calls import Calls
14
22
  from .exceptions import MockedResponseMissingError
15
23
 
16
24
  DEFAULT_AUTO_MOCK_SUCCESS = True
@@ -47,6 +55,9 @@ class MockedSession(BaseSession):
47
55
  request = Request(method=method.__api_method__, data=method.__dict__, files=None)
48
56
  self.requests.append(request)
49
57
  self.methods.append(method)
58
+ if not self.responses and getattr(bot, "auto_mock_success", False):
59
+ # Answer at request time, so a failure queued earlier is never followed by a stale success
60
+ bot.add_result_for(type(method), ok=True, result=bot.default_result(method))
50
61
  if not self.responses:
51
62
  raise MockedResponseMissingError(
52
63
  f"no mocked response for {method.__api_method__}: call "
@@ -79,6 +90,7 @@ class MockedBot(Bot):
79
90
  language_code="ru",
80
91
  )
81
92
  self.auto_mock_success = auto_mock_success
93
+ self._message_ids = itertools.count(1)
82
94
 
83
95
  def add_result_for(
84
96
  self,
@@ -103,10 +115,39 @@ class MockedBot(Bot):
103
115
  self.session.add_result(response)
104
116
  return response
105
117
 
106
- async def __call__(self, method: TelegramMethod, request_timeout: int | None = None):
107
- if self.auto_mock_success:
108
- self.add_result_for(method.__class__, ok=True)
109
- return await super().__call__(method, request_timeout)
118
+ @property
119
+ def calls(self) -> Calls:
120
+ """
121
+ Every Bot API call this bot made, including ones outside the dispatcher (background jobs, notifiers)
122
+ """
123
+
124
+ return Calls(self.session.methods)
125
+
126
+ def default_result(self, method: TelegramMethod) -> Any:
127
+ """
128
+ What Telegram would plausibly answer to ``method`` when no result was queued: the sent or edited message
129
+ for message methods, ``True`` for boolean ones, the bot itself for ``getMe``
130
+ """
131
+
132
+ returning = method.__returning__
133
+ options = typing.get_args(returning) or (returning,)
134
+ if User in options:
135
+ return self._me
136
+ if Message in options and getattr(method, "inline_message_id", None) is None:
137
+ chat_id = getattr(method, "chat_id", None)
138
+ return Message(
139
+ message_id=getattr(method, "message_id", None) or next(self._message_ids),
140
+ date=datetime.now(UTC),
141
+ chat=Chat(id=chat_id if isinstance(chat_id, int) else -1, type="private"),
142
+ from_user=self._me,
143
+ text=getattr(method, "text", None),
144
+ caption=getattr(method, "caption", None),
145
+ )
146
+ if bool in options:
147
+ return True
148
+ if typing.get_origin(returning) is list:
149
+ return []
150
+ return None
110
151
 
111
152
  def get_request(self) -> Request:
112
153
  return self.session.get_request()
File without changes
@@ -0,0 +1,53 @@
1
+ from aiogram.methods import TelegramMethod
2
+ from aiogram.methods.base import Response
3
+ from aiogram.methods.base import TelegramType
4
+
5
+ from .calls import Calls
6
+ from .calls import CallsList
7
+ from .handler.base import RequestHandler
8
+
9
+ __all__ = ["Calls", "CallsList", "MockedRequester"]
10
+
11
+
12
+ class MockedRequester:
13
+ def __init__(self, request_handler: RequestHandler):
14
+ self._handler: RequestHandler = request_handler
15
+
16
+ async def query(self, *args, **kwargs) -> Calls:
17
+ self._check_arguments(*args, **kwargs)
18
+
19
+ methods = self._handler.bot.session.methods
20
+ already_made = len(methods)
21
+ result = await self._handler(*args, **kwargs)
22
+
23
+ return Calls(list(methods)[already_made:], result=result)
24
+
25
+ def add_result_for(
26
+ self,
27
+ method: type[TelegramMethod[TelegramType]],
28
+ ok: bool,
29
+ result: TelegramType = None,
30
+ description: str | None = None,
31
+ error_code: int = 200,
32
+ migrate_to_chat_id: int | None = None,
33
+ retry_after: int | None = None,
34
+ ) -> Response[TelegramType]:
35
+ response = self._handler.add_result_for(
36
+ method=method,
37
+ ok=ok,
38
+ result=result,
39
+ description=description,
40
+ error_code=error_code,
41
+ migrate_to_chat_id=migrate_to_chat_id,
42
+ retry_after=retry_after,
43
+ )
44
+ return response
45
+
46
+ def _check_arguments(self, *args, **kwargs) -> None:
47
+ build_update = getattr(self._handler, "build_update", None)
48
+ if build_update is None:
49
+ return
50
+ try:
51
+ build_update(*args, **kwargs)
52
+ except TypeError as e:
53
+ raise AttributeError(f"incorrect argument name. {e}") from e
@@ -12,9 +12,9 @@ from aiogram.methods import TelegramMethod
12
12
  from aiogram.methods.base import Response
13
13
  from aiogram.methods.base import TelegramType
14
14
 
15
+ from .calls import Calls
15
16
  from .mocked_bot import DEFAULT_AUTO_MOCK_SUCCESS
16
17
  from .mocked_bot import MockedBot
17
- from .requester import Calls
18
18
  from .types.dataset import CALLBACK_QUERY
19
19
  from .types.dataset import CHAT
20
20
  from .types.dataset import MESSAGE
@@ -81,8 +81,8 @@ class BotTester:
81
81
 
82
82
  methods = self.bot.session.methods
83
83
  already_made = len(methods)
84
- await self.dp.feed_update(self.bot, update, **self._workflow_data)
85
- self._last_calls = Calls(list(methods)[already_made:])
84
+ result = await self.dp.feed_update(self.bot, update, **self._workflow_data)
85
+ self._last_calls = Calls(list(methods)[already_made:], result=result)
86
86
  return self._last_calls
87
87
 
88
88
  async def send_message(
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "aiogram-testing"
3
- version = "1.2.0"
3
+ version = "1.3.0"
4
4
  description = "A library for testing your bots on aiogram"
5
5
  authors = ["k0te1ch <khvostov40@gmail.com>"]
6
6
  readme = "README.md"
@@ -1,6 +0,0 @@
1
- from .requester import Calls
2
- from .requester import MockedRequester
3
- from .tester import BotTester
4
-
5
- __all__ = ["BotTester", "Calls", "MockedRequester"]
6
- __version__ = "1.2.0" # x-release-please-version