diff --git a/.apiversion b/.apiversion index 341d0b55..11aa1452 100644 --- a/.apiversion +++ b/.apiversion @@ -1 +1 @@ -5.2 \ No newline at end of file +5.3 \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..f519610f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +root = true + +[**] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true +max_line_length = 99 + +[**.{yml,yaml}] +indent_size = 2 + +[**.{md,txt,rst}] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.github/workflows/testpypi.yml b/.github/workflows/deploy.yml similarity index 81% rename from .github/workflows/testpypi.yml rename to .github/workflows/deploy.yml index a74db317..f6c0d175 100644 --- a/.github/workflows/testpypi.yml +++ b/.github/workflows/deploy.yml @@ -49,17 +49,17 @@ jobs: name: dist path: dist - - name: Publish a Python distribution to Test PyPI - uses: pypa/gh-action-pypi-publish@master -# if: github.event.action != 'published' - with: - user: __token__ - password: ${{ secrets.PYPI_TEST_TOKEN }} - repository_url: https://test.pypi.org/legacy/ - -# - name: Publish a Python distribution to PyPI +# - name: Publish a Python distribution to Test PyPI # uses: pypa/gh-action-pypi-publish@master -# if: github.event.action == 'published' +## if: github.event.action != 'published' # with: # user: __token__ -# password: ${{ secrets.PYPI_TOKEN }} +# password: ${{ secrets.PYPI_TEST_TOKEN }} +# repository_url: https://test.pypi.org/legacy/ + + - name: Publish a Python distribution to PyPI + uses: pypa/gh-action-pypi-publish@master +# if: github.event.action == 'published' + with: + user: __token__ + password: ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f5057032..38066ebe 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,7 +19,6 @@ jobs: - macos-latest - windows-latest python-version: - - 3.7 - 3.8 - 3.9 @@ -44,6 +43,12 @@ jobs: virtualenvs-create: true virtualenvs-in-project: true + - name: Setup redis + if: ${{ matrix.os != 'windows-latest' }} + uses: shogo82148/actions-setup-redis@v1 + with: + redis-version: 6 + - name: Load cached venv id: cached-poetry-dependencies uses: actions/cache@v2 @@ -65,7 +70,14 @@ jobs: run: | poetry run black --check --diff aiogram tests - - name: Run tests + - name: Run tests (with Redis) + if: ${{ matrix.os != 'windows-latest' }} + run: | + poetry run pytest --cov=aiogram --cov-config .coveragerc --cov-report=xml --redis redis://localhost:6379/0 + + - name: Run tests (without Redis) + # Redis can't be used on GitHub Windows Runners + if: ${{ matrix.os == 'windows-latest' }} run: | poetry run pytest --cov=aiogram --cov-config .coveragerc --cov-report=xml diff --git a/.gitignore b/.gitignore index 1f3e1971..4ffb8359 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,5 @@ aiogram/_meta.py .coverage reports -dev/ \ No newline at end of file +dev/ +.venv/ diff --git a/CHANGES.rst b/CHANGES.rst new file mode 100644 index 00000000..9028bbb8 --- /dev/null +++ b/CHANGES.rst @@ -0,0 +1,53 @@ +========= +Changelog +========= + +.. + You should *NOT* be adding new change log entries to this file, this + file is managed by towncrier. You *may* edit previous change logs to + fix problems like typo corrections or such. + To add a new change log entry, please see + https://pip.pypa.io/en/latest/development/#adding-a-news-entry + we named the news folder "CHANGES". + + WARNING: Don't drop the next directive! + +.. towncrier release notes start + +3.0.0a14 (2021-08-17) +====================== + +Features +-------- + +- add aliases for edit/delete reply markup to Message + `#662 `_ +- Reworked outer middleware chain. Prevent to call many times the outer middleware for each nested router + `#664 `_ + + +Bugfixes +-------- + +- Prepare parse mode for InputMessageContent in AnswerInlineQuery method + `#660 `_ + + +Improved Documentation +---------------------- + +- Added integration with :code:`towncrier` + `#602 `_ + + +Misc +---- + +- Added `.editorconfig` + `#650 `_ +- Redis storage speedup globals + `#651 `_ +- add allow_sending_without_reply param to Message reply aliases + `#663 `_ + + diff --git a/CHANGES/.template.rst.jinja2 b/CHANGES/.template.rst.jinja2 new file mode 100644 index 00000000..24c86a9c --- /dev/null +++ b/CHANGES/.template.rst.jinja2 @@ -0,0 +1,44 @@ +{% if top_line %} +{{ top_line }} +{{ top_underline * ((top_line)|length)}} +{% elif versiondata.name %} +{{ versiondata.version }} ({{ versiondata.date }}) +{{ top_underline * ((versiondata.version + versiondata.date)|length + 4)}} +{% else %} +{{ versiondata.version }} ({{ versiondata.date }}) +{{ top_underline * ((versiondata.version + versiondata.date)|length + 3)}} +{% endif %} +{% for section, _ in sections.items() %} +{% set underline = underlines[0] %}{% if section %}{{section}} +{{ underline * section|length }}{% set underline = underlines[1] %} + +{% endif %} + +{% if sections[section] %} +{% for category, val in definitions.items() if category in sections[section]%} +{{ definitions[category]['name'] }} +{{ underline * definitions[category]['name']|length }} + +{% if definitions[category]['showcontent'] %} +{% for text, values in sections[section][category].items() %} +- {{ text }} + {{ values|join(', ') }} +{% endfor %} + +{% else %} +- {{ sections[section][category]['']|join(', ') }} + +{% endif %} +{% if sections[section][category]|length == 0 %} +No significant changes. + +{% else %} +{% endif %} + +{% endfor %} +{% else %} +No significant changes. + + +{% endif %} +{% endfor %} diff --git a/HISTORY.rst b/HISTORY.rst new file mode 100644 index 00000000..c1dc6ad5 --- /dev/null +++ b/HISTORY.rst @@ -0,0 +1,497 @@ + +.. + Copy-pasted and reformatted from GitHub releases page + + +2.14.3 (2021-07-21) +=================== + +- Fixed :code:`ChatMember` type detection via adding customizable object serialization mechanism (`#624 `_, `#623 `_) + + +2.14.2 (2021-07-26) +=================== + +- Fixed :code:`MemoryStorage` cleaner (`#619 `_) +- Fixed unused default locale in :code:`I18nMiddleware` (`#562 `_, `#563 `_) + + +2.14 (2021-07-27) +================= + +- Full support of Bot API 5.3 (`#610 `_, `#614 `_) +- Fixed :code:`Message.send_copy` method for polls (`#603 `_) +- Updated pattern for :code:`GroupDeactivated` exception (`#549 `_ +- Added :code:`caption_entities` field in :code:`InputMedia` base class (`#583 `_) +- Fixed HTML text decorations for tag :code:`pre` (`#597 `_ fixes issues `#596 `_ and `#481 `_) +- Fixed :code:`Message.get_full_command` method for messages with caption (`#576 `_) +- Improved :code:`MongoStorage`: remove documents with empty data from :code:`aiogram_data` collection to save memory. (`#609 `_) + + +2.13 (2021-04-28) +================= + +- Added full support of Bot API 5.2 (`#572 `_) +- Fixed usage of :code:`provider_data` argument in :code:`sendInvoice` method call +- Fixed builtin command filter args (`#556 `_) (`#558 `_) +- Allowed to use State instances FSM storage directly (`#542 `_) +- Added possibility to get i18n locale without User instance (`#546 `_) +- Fixed returning type of :code:`Bot.*_chat_invite_link()` methods `#548 `_ (`#549 `_) +- Fixed deep-linking util (`#569 `_) +- Small changes in documentation - describe limits in docstrings corresponding to the current limit. (`#565 `_) +- Fixed internal call to deprecated 'is_private' method (`#553 `_) +- Added possibility to use :code:`allowed_updates` argument in Polling mode (`#564 `_) + + +2.12.1 (2021-03-22) +=================== + +- Fixed :code:`TypeError: Value should be instance of 'User' not 'NoneType'` (`#527 `_) +- Added missing :code:`Chat.message_auto_delete_time` field (`#535 `_) +- Added :code:`MediaGroup` filter (`#528 `_) +- Added :code:`Chat.delete_message` shortcut (`#526 `_) +- Added mime types parsing for :code:`aiogram.types.Document` (`#431 `_) +- Added warning in :code:`TelegramObject.__setitem__` when Telegram adds a new field (`#532 `_) +- Fixed :code:`examples/chat_type_filter.py` (`#533 `_) +- Removed redundant definitions in framework code (`#531 `_) + + +2.12 (2021-03-14) +================= + +- Full support for Telegram Bot API 5.1 (`#519 `_) +- Fixed sending playlist of audio files and documents (`#465 `_, `#468 `_) +- Fixed :code:`FSMContextProxy.setdefault` method (`#491 `_) +- Fixed :code:`Message.answer_location` and :code:`Message.reply_location` unable to send live location (`#497 `_) +- Fixed :code:`user_id` and :code:`chat_id` getters from the context at Dispatcher :code:`check_key`, :code:`release_key` and :code:`throttle` methods (`#520 `_) +- Fixed :code:`Chat.update_chat` method and all similar situations (`#516 `_) +- Fixed :code:`MediaGroup` attach methods (`#514 `_) +- Fixed state filter for inline keyboard query callback in groups (`#508 `_, `#510 `_) +- Added missing :code:`ContentTypes.DICE` (`#466 `_) +- Added missing vcard argument to :code:`InputContactMessageContent` constructor (`#473 `_) +- Add missing exceptions: :code:`MessageIdInvalid`, :code:`CantRestrictChatOwner` and :code:`UserIsAnAdministratorOfTheChat` (`#474 `_, `#512 `_) +- Added :code:`answer_chat_action` to the :code:`Message` object (`#501 `_) +- Added dice to :code:`message.send_copy` method (`#511 `_) +- Removed deprecation warning from :code:`Message.send_copy` +- Added an example of integration between externally created aiohttp Application and aiogram (`#433 `_) +- Added :code:`split_separator` argument to :code:`safe_split_text` (`#515 `_) +- Fixed some typos in docs and examples (`#489 `_, `#490 `_, `#498 `_, `#504 `_, `#514 `_) + + +2.11.2 (2021-11-10) +=================== + +- Fixed default parse mode +- Added missing "supports_streaming" argument to answer_video method `#462 `_ + + +2.11.1 (2021-11-10) +=================== + +- Fixed files URL template +- Fix MessageEntity serialization for API calls `#457 `_ +- When entities are set, default parse_mode become disabled (`#461 `_) +- Added parameter supports_streaming to reply_video, remove redundant docstrings (`#459 `_) +- Added missing parameter to promoteChatMember alias (`#458 `_) + + +2.11 (2021-11-08) +================= + +- Added full support of Telegram Bot API 5.0 (`#454 `_) +- Added possibility to more easy specify custom API Server (example) + - WARNING: API method :code:`close` was named in Bot class as close_bot in due to Bot instance already has method with the same name. It will be changed in :code:`aiogram 3.0` +- Added alias to Message object :code:`Message.copy_to` with deprecation of :code:`Message.send_copy` +- :code:`ChatType.SUPER_GROUP` renamed to :code:`ChatType.SUPERGROUP` (`#438 `_) + + +2.10.1 (2021-09-14) +=================== + +- Fixed critical bug with getting asyncio event loop in executor. (`#424 `_) :code:`AttributeError: 'NoneType' object has no attribute 'run_until_complete'` + + +2.10 (2021-09-13) +================== + +- Breaking change: Stop using _MainThread event loop in bot/dispatcher instances (`#397 `_) +- Breaking change: Replaced aiomongo with motor (`#368 `_, `#380 `_) +- Fixed: TelegramObject's aren't destroyed after update handling `#307 `_ (`#371 `_) +- Add setting current context of Telegram types (`#369 `_) +- Fixed markdown escaping issues (`#363 `_) +- Fixed HTML characters escaping (`#409 `_) +- Fixed italic and underline decorations when parse entities to Markdown +- Fixed `#413 `_: parse entities positioning (`#414 `_) +- Added missing thumb parameter (`#362 `_) +- Added public methods to register filters and middlewares (`#370 `_) +- Added ChatType builtin filter (`#356 `_) +- Fixed IDFilter checking message from channel (`#376 `_) +- Added missed answer_poll and reply_poll (`#384 `_) +- Added possibility to ignore message caption in commands filter (`#383 `_) +- Fixed addStickerToSet method +- Added preparing thumb in send_document method (`#391 `_) +- Added exception MessageToPinNotFound (`#404 `_) +- Fixed handlers parameter-spec solving (`#408 `_) +- Fixed CallbackQuery.answer() returns nothing (`#420 `_) +- CHOSEN_INLINE_RESULT is a correct API-term (`#415 `_) +- Fixed missing attributes for Animation class (`#422 `_) +- Added missed emoji argument to reply_dice (`#395 `_) +- Added is_chat_creator method to ChatMemberStatus (`#394 `_) +- Added missed ChatPermissions to __all__ (`#393 `_) +- Added is_forward method to Message (`#390 `_) +- Fixed usage of deprecated is_private function (`#421 `_) + +and many others documentation and examples changes: + +- Updated docstring of RedisStorage2 (`#423 `_) +- Updated I18n example (added docs and fixed typos) (`#419 `_) +- A little documentation revision (`#381 `_) +- Added comments about correct errors_handlers usage (`#398 `_) +- Fixed typo rexex -> regex (`#386 `_) +- Fixed docs Quick start page code blocks (`#417 `_) +- fixed type hints of callback_data (`#400 `_) +- Prettify readme, update downloads stats badge (`#406 `_) + + +2.9.2 (2021-06-13) +================== + +- Fixed :code:`Message.get_full_command()` `#352 `_ +- Fixed markdown util `#353 `_ + + +2.9 (2021-06-08) +================ + +- Added full support of Telegram Bot API 4.9 +- Fixed user context at poll_answer update (`#322 `_) +- Fix Chat.set_description (`#325 `_) +- Add lazy session generator (`#326 `_) +- Fix text decorations (`#315 `_, `#316 `_, `#328 `_) +- Fix missing :code:`InlineQueryResultPhoto` :code:`parse_mode` field (`#331 `_) +- Fix fields from parent object in :code:`KeyboardButton` (`#344 `_ fixes `#343 `_) +- Add possibility to get bot id without calling :code:`get_me` (`#296 `_) + + +2.8 (2021-04-26) +================ + +- Added full support of Bot API 4.8 +- Added :code:`Message.answer_dice` and :code:`Message.reply_dice` methods (`#306 `_) + + +2.7 (2021-04-07) +================ + +- Added full support of Bot API 4.7 (`#294 `_ `#289 `_) +- Added default parse mode for send_animation method (`#293 `_ `#292 `_) +- Added new API exception when poll requested in public chats (`#270 `_) +- Make correct User and Chat get_mention methods (`#277 `_) +- Small changes and other minor improvements + + +2.6.1 (2021-01-25) +================== + +- Fixed reply :code:`KeyboardButton` initializer with :code:`request_poll` argument (`#266 `_) +- Added helper for poll types (:code:`aiogram.types.PollType`) +- Changed behavior of Telegram_object :code:`.as_*` and :code:`.to_*` methods. It will no more mutate the object. (`#247 `_) + + +2.6 (2021-01-23) +================ + +- Full support of Telegram Bot API v4.6 (Polls 2.0) `#265 `_ +- Aded new filter - IsContactSender (commit) +- Fixed proxy extra dependencies version `#262 `_ + + +2.5.3 (2021-01-05) +================== + +- `#255 `_ Updated CallbackData factory validity check. More correct for non-latin symbols +- `#256 `_ Fixed :code:`renamed_argument` decorator error +- `#257 `_ One more fix of CommandStart filter + + +2.5.2 (2021-01-01) +================== + +- Get back :code:`quote_html` and :code:`escape_md` functions + + +2.5.1 (2021-01-01) +================== + +- Hot-fix of :code:`CommandStart` filter + + +2.5 (2021-01-01) +================ + +- Added full support of Telegram Bot API 4.5 (`#250 `_, `#251 `_) +- `#239 `_ Fixed :code:`check_token` method +- `#238 `_, `#241 `_: Added deep-linking utils +- `#248 `_ Fixed support of aiohttp-socks +- Updated setup.py. No more use of internal pip API +- Updated links to documentations (https://docs.aiogram.dev) +- Other small changes and minor improvements (`#223 `_ and others...) + + +2.4 (2021-10-29) +================ + +- Added Message.send_copy method (forward message without forwarding) +- Safe close of aiohttp client session (no more exception when application is shutdown) +- No more "adWanced" words in project `#209 `_ +- Arguments user and chat is renamed to user_id and chat_id in Dispatcher.throttle method `#196 `_ +- Fixed set_chat_permissions `#198 `_ +- Fixed Dispatcher polling task does not process cancellation `#199 `_, `#201 `_ +- Fixed compatibility with latest asyncio version `#200 `_ +- Disabled caching by default for lazy_gettext method of I18nMiddleware `#203 `_ +- Fixed HTML user mention parser `#205 `_ +- Added IsReplyFilter `#210 `_ +- Fixed send_poll method arguments `#211 `_ +- Added OrderedHelper `#215 `_ +- Fix incorrect completion order. `#217 `_ + + +2.3 (2021-08-16) +================ + +- Full support of Telegram Bot API 4.4 +- Fixed `#143 `_ +- Added new filters from issue `#151 `_: `#172 `_, `#176 `_, `#182 `_ +- Added expire argument to RedisStorage2 and other storage fixes `#145 `_ +- Fixed JSON and Pickle storages `#138 `_ +- Implemented MongoStorage `#153 `_ based on aiomongo (soon motor will be also added) +- Improved tests +- Updated examples +- Warning: Updated auth widget util. `#190 `_ +- Implemented throttle decorator `#181 `_ + + +2.2 (2021-06-09) +================ + +- Provides latest Telegram Bot API (4.3) +- Updated docs for filters +- Added opportunity to use different bot tokens from single bot instance (via context manager, `#100 `_) +- IMPORTANT: Fixed Typo: :code:`data` -> :code:`bucket` in :code:`update_bucket` for RedisStorage2 (`#132 `_) + + +2.1 (2021-04-18) +================ + +- Implemented all new features from Telegram Bot API 4.2 +- :code:`is_member` and :code:`is_admin` methods of :code:`ChatMember` and :code:`ChatMemberStatus` was renamed to :code:`is_chat_member` and :code:`is_chat_admin` +- Remover func filter +- Added some useful Message edit functions (:code:`Message.edit_caption`, :code:`Message.edit_media`, :code:`Message.edit_reply_markup`) (`#121 `_, `#103 `_, `#104 `_, `#112 `_) +- Added requests timeout for all methods (`#110 `_) +- Added :code:`answer*` methods to :code:`Message` object (`#112 `_) +- Maked some improvements of :code:`CallbackData` factory +- Added deep-linking parameter filter to :code:`CommandStart` filter +- Implemented opportunity to use DNS over socks (`#97 `_ -> `#98 `_) +- Implemented logging filter for extending LogRecord attributes (Will be usefull with external logs collector utils like GrayLog, Kibana and etc.) +- Updated :code:`requirements.txt` and :code:`dev_requirements.txt` files +- Other small changes and minor improvements + + +2.0.1 (2021-12-31) +================== + +- Implemented CallbackData factory (`example `_) +- Implemented methods for answering to inline query from context and reply with animation to the messages. `#85 `_ +- Fixed installation from tar.gz `#84 `_ +- More exceptions (:code:`ChatIdIsEmpty` and :code:`NotEnoughRightsToRestrict`) + + +2.0 (2021-10-28) +================ + +This update will break backward compability with Python 3.6 and works only with Python 3.7+: +- contextvars (PEP-567); +- New syntax for annotations (PEP-563). + +Changes: +- Used contextvars instead of :code:`aiogram.utils.context`; +- Implemented filters factory; +- Implemented new filters mechanism; +- Allowed to customize command prefix in CommandsFilter; +- Implemented mechanism of passing results from filters (as dicts) as kwargs in handlers (like fixtures in pytest); +- Implemented states group feature; +- Implemented FSM storage's proxy; +- Changed files uploading mechanism; +- Implemented pipe for uploading files from URL; +- Implemented I18n Middleware; +- Errors handlers now should accept only two arguments (current update and exception); +- Used :code:`aiohttp_socks` instead of :code:`aiosocksy` for Socks4/5 proxy; +- types.ContentType was divided to :code:`types.ContentType` and :code:`types.ContentTypes`; +- Allowed to use rapidjson instead of ujson/json; +- :code:`.current()` method in bot and dispatcher objects was renamed to :code:`get_current()`; + +Full changelog +- You can read more details about this release in migration FAQ: ``_ + + +1.4 (2021-08-03) +================ + +- Bot API 4.0 (`#57 `_) + + +1.3.3 (2021-07-16) +================== + +- Fixed markup-entities parsing; +- Added more API exceptions; +- Now InlineQueryResultLocation has live_period; +- Added more message content types; +- Other small changes and minor improvements. + + +1.3.2 (2021-05-27) +================== + +- Fixed crashing of polling process. (i think) +- Added parse_mode field into input query results according to Bot API Docs. +- Added new methods for Chat object. (`#42 `_, `#43 `_) +- **Warning**: disabled connections limit for bot aiohttp session. +- **Warning**: Destroyed "temp sessions" mechanism. +- Added new error types. +- Refactored detection of error type. +- Small fixes of executor util. +- Fixed RethinkDBStorage + +1.3.1 (2018-05-27) +================== + + +1.3 (2021-04-22) +================ + +- Allow to use Socks5 proxy (need manually install :code:`aiosocksy`). +- Refactored :code:`aiogram.utils.executor` module. +- **[Warning]** Updated requirements list. + + +1.2.3 (2018-04-14) +================== + +- Fixed API errors detection +- Fixed compability of :code:`setup.py` with pip 10.0.0 + + +1.2.2 (2018-04-08) +================== + +- Added more error types. +- Implemented method :code:`InputFile.from_url(url: str)` for downloading files. +- Implemented big part of API method tests. +- Other small changes and mminor improvements. + + +1.2.1 (2018-03-25) +================== + +- Fixed handling Venue's [`#27 `_, `#26 `_] +- Added parse_mode to all medias (Bot API 3.6 support) [`#23 `_] +- Now regexp filter can be used with callback query data [`#19 `_] +- Improvements in :code:`InlineKeyboardMarkup` & :code:`ReplyKeyboardMarkup` objects [`#21 `_] +- Other bug & typo fixes and minor improvements. + + +1.2 (2018-02-23) +================ + +- Full provide Telegram Bot API 3.6 +- Fixed critical error: :code:`Fatal Python error: PyImport_GetModuleDict: no module dictionary!` +- Implemented connection pool in RethinkDB driver +- Typo fixes of documentstion +- Other bug fixes and minor improvements. + + +1.1 (2018-01-27) +================ + +- Added more methods for data types (like :code:`message.reply_sticker(...)` or :code:`file.download(...)` +- Typo fixes of documentstion +- Allow to set default parse mode for messages (:code:`Bot( ... , parse_mode='HTML')`) +- Allowed to cancel event from the :code:`Middleware.on_pre_process_` +- Fixed sending files with correct names. +- Fixed MediaGroup +- Added RethinkDB storage for FSM (:code:`aiogram.contrib.fsm_storage.rethinkdb`) + + +1.0.4 (2018-01-10) +================== + + +1.0.3 (2018-01-07) +================== + +- Added middlewares mechanism. +- Added example for middlewares and throttling manager. +- Added logging middleware (:code:`aiogram.contrib.middlewares.logging.LoggingMiddleware`) +- Fixed handling errors in async tasks (marked as 'async_task') +- Small fixes and other minor improvements. + + +1.0.2 (2017-11-29) +================== + + +1.0.1 (2017-11-21) +================== + +- Implemented :code:`types.InputFile` for more easy sending local files +- **Danger!** Fixed typo in word pooling. Now whatever all methods with that word marked as deprecated and original methods is renamed to polling. Check it in you'r code before updating! +- Fixed helper for chat actions (:code:`types.ChatActions`) +- Added `example `_ for media group. + + +1.0 (2017-11-19) +================ + +- Remaked data types serialozation/deserialization mechanism (Speed up). +- Fully rewrited all Telegram data types. +- Bot object was fully rewritted (regenerated). +- Full provide Telegram Bot API 3.4+ (with sendMediaGroup) +- Warning: Now :code:`BaseStorage.close()` is awaitable! (FSM) +- Fixed compability with uvloop. +- More employments for :code:`aiogram.utils.context`. +- Allowed to disable :code:`ujson`. +- Other bug fixes and minor improvements. +- Migrated from Bitbucket to Github. + + +0.4.1 (2017-08-03) +================== + + +0.4 (2017-08-05) +================ + + +0.3.4 (2017-08-04) +================== + + +0.3.3 (2017-07-05) +================== + + +0.3.2 (2017-07-04) +================== + + +0.3.1 (2017-07-04) +================== + + +0.2b1 (2017-06-00) +================== + + +0.1 (2017-06-03) +================ diff --git a/Makefile b/Makefile index c1fa9797..bb66a7e0 100644 --- a/Makefile +++ b/Makefile @@ -4,8 +4,14 @@ base_python := python3 py := poetry run python := $(py) python +package_dir := aiogram +tests_dir := tests +scripts_dir := scripts +code_dir := $(package_dir) $(tests_dir) $(scripts_dir) reports_dir := reports +redis_connection := redis://localhost:6379 + .PHONY: help help: @echo "=======================================================================================" @@ -17,13 +23,8 @@ help: @echo " clean: Delete temporary files" @echo "" @echo "Code quality:" - @echo " isort: Run isort tool" - @echo " black: Run black tool" - @echo " flake8: Run flake8 tool" - @echo " flake8-report: Run flake8 with HTML reporting" - @echo " mypy: Run mypy tool" - @echo " mypy-report: Run mypy tool with HTML reporting" - @echo " lint: Run isort, black, flake8 and mypy tools" + @echo " lint: Lint code by isort, black, flake8 and mypy tools" + @echo " reformat: Reformat code by isort and black tools" @echo "" @echo "Tests:" @echo " test: Run tests" @@ -31,8 +32,8 @@ help: @echo " test-coverage-report: Open coverage report in default system web browser" @echo "" @echo "Documentation:" - @echo " docs: Build docs" - @echo " docs-serve: Serve docs for local development" + @echo " docs: Build docs" + @echo " docs-serve: Serve docs for local development" @echo " docs-prepare-reports: Move all HTML reports to docs dir" @echo "" @echo "Project" @@ -65,33 +66,17 @@ clean: # Code quality # ================================================================================================= -.PHONY: isort -isort: - $(py) isort aiogram tests scripts - -.PHONY: black -black: - $(py) black aiogram tests scripts - -.PHONY: flake8 -flake8: - $(py) flake8 aiogram - -.PHONY: flake8-report -flake8-report: - mkdir -p $(reports_dir)/flake8 - $(py) flake8 --format=html --htmldir=$(reports_dir)/flake8 aiogram - -.PHONY: mypy -mypy: - $(py) mypy aiogram - -.PHONY: mypy-report -mypy-report: - $(py) mypy aiogram --html-report $(reports_dir)/typechecking - .PHONY: lint -lint: isort black flake8 mypy +lint: + $(py) isort --check-only $(code_dir) + $(py) black --check --diff $(code_dir) + $(py) flake8 $(code_dir) + $(py) mypy $(package_dir) + +.PHONY: reformat +reformat: + $(py) black $(code_dir) + $(py) isort $(code_dir) # ================================================================================================= # Tests @@ -99,12 +84,12 @@ lint: isort black flake8 mypy .PHONY: test test: - $(py) pytest --cov=aiogram --cov-config .coveragerc tests/ + $(py) pytest --cov=aiogram --cov-config .coveragerc tests/ --redis $(redis_connection) .PHONY: test-coverage test-coverage: mkdir -p $(reports_dir)/tests/ - $(py) pytest --cov=aiogram --cov-config .coveragerc --html=$(reports_dir)/tests/index.html tests/ + $(py) pytest --cov=aiogram --cov-config .coveragerc --html=$(reports_dir)/tests/index.html tests/ --redis $(redis_connection) .PHONY: test-coverage-report test-coverage-report: @@ -138,3 +123,23 @@ build: clean flake8-report mypy-report test-coverage bump: poetry version $(args) $(python) scripts/bump_versions.py + +.PHONY: towncrier-build +towncrier-build: + towncrier build --yes + +.PHONY: towncrier-draft +towncrier-draft: + towncrier build --draft + +.PHONY: towncrier-draft-github +towncrier-draft-github: + mkdir -p dist + towncrier build --draft | pandoc - -o dist/release.md + +.PHONY: prepare-release +prepare-release: bump towncrier-draft-github towncrier-build + +.PHONY: tag-release +tag-release: + git tag v$(poetry version -s) diff --git a/README.md b/README.md index d3d5248b..ee1c3f8f 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,10 @@ [![\[Telegram\] aiogram live](https://img.shields.io/badge/telegram-aiogram-blue.svg?style=flat-square)](https://t.me/aiogram_live) [![MIT License](https://img.shields.io/pypi/l/aiogram.svg?style=flat-square)](https://opensource.org/licenses/MIT) [![Supported python versions](https://img.shields.io/pypi/pyversions/aiogram.svg?style=flat-square)](https://pypi.python.org/pypi/aiogram) -[![Telegram Bot API](https://img.shields.io/badge/Telegram%20Bot%20API-5.2-blue.svg?style=flat-square&logo=telegram)](https://core.telegram.org/bots/api) +[![Telegram Bot API](https://img.shields.io/badge/Telegram%20Bot%20API-5.3-blue.svg?style=flat-square&logo=telegram)](https://core.telegram.org/bots/api) [![PyPi Package Version](https://img.shields.io/pypi/v/aiogram.svg?style=flat-square)](https://pypi.python.org/pypi/aiogram) [![PyPi status](https://img.shields.io/pypi/status/aiogram.svg?style=flat-square)](https://pypi.python.org/pypi/aiogram) [![Downloads](https://img.shields.io/pypi/dm/aiogram.svg?style=flat-square)](https://pypi.python.org/pypi/aiogram) [![Codecov](https://img.shields.io/codecov/c/github/aiogram/aiogram?style=flat-square)](https://app.codecov.io/gh/aiogram/aiogram) -**aiogram** modern and fully asynchronous framework for [Telegram Bot API](https://core.telegram.org/bots/api) written in Python 3.7 with [asyncio](https://docs.python.org/3/library/asyncio.html) and [aiohttp](https://github.com/aio-libs/aiohttp). It helps you to make your bots faster and simpler. +**aiogram** modern and fully asynchronous framework for [Telegram Bot API](https://core.telegram.org/bots/api) written in Python 3.8 with [asyncio](https://docs.python.org/3/library/asyncio.html) and [aiohttp](https://github.com/aio-libs/aiohttp). It helps you to make your bots faster and simpler. diff --git a/aiogram/__init__.py b/aiogram/__init__.py index 31b52552..737e8185 100644 --- a/aiogram/__init__.py +++ b/aiogram/__init__.py @@ -6,6 +6,8 @@ from .dispatcher import filters, handler from .dispatcher.dispatcher import Dispatcher from .dispatcher.middlewares.base import BaseMiddleware from .dispatcher.router import Router +from .utils.text_decorations import html_decoration as _html_decoration +from .utils.text_decorations import markdown_decoration as _markdown_decoration try: import uvloop as _uvloop @@ -15,6 +17,8 @@ except ImportError: # pragma: no cover pass F = MagicFilter() +html = _html_decoration +md = _markdown_decoration __all__ = ( "__api_version__", @@ -29,7 +33,9 @@ __all__ = ( "filters", "handler", "F", + "html", + "md", ) -__version__ = "3.0.0-alpha.8" -__api_version__ = "5.2" +__version__ = "3.0.0a14" +__api_version__ = "5.3" diff --git a/aiogram/client/bot.py b/aiogram/client/bot.py index 19fe7838..592a83f8 100644 --- a/aiogram/client/bot.py +++ b/aiogram/client/bot.py @@ -28,6 +28,7 @@ from ..methods import ( AnswerInlineQuery, AnswerPreCheckoutQuery, AnswerShippingQuery, + BanChatMember, Close, CopyMessage, CreateChatInviteLink, @@ -35,6 +36,7 @@ from ..methods import ( DeleteChatPhoto, DeleteChatStickerSet, DeleteMessage, + DeleteMyCommands, DeleteStickerFromSet, DeleteWebhook, EditChatInviteLink, @@ -48,6 +50,7 @@ from ..methods import ( GetChat, GetChatAdministrators, GetChatMember, + GetChatMemberCount, GetChatMembersCount, GetFile, GetGameHighScores, @@ -105,9 +108,15 @@ from ..methods import ( from ..types import ( UNSET, BotCommand, + BotCommandScope, Chat, ChatInviteLink, - ChatMember, + ChatMemberAdministrator, + ChatMemberBanned, + ChatMemberLeft, + ChatMemberMember, + ChatMemberOwner, + ChatMemberRestricted, ChatPermissions, Downloadable, File, @@ -302,7 +311,7 @@ class Bot(ContextInstanceMixin["Bot"]): :param method: :return: """ - return await self.session.make_request(self, method, timeout=request_timeout) + return await self.session(self, method, timeout=request_timeout) def __hash__(self) -> int: """ @@ -1410,6 +1419,35 @@ class Bot(ContextInstanceMixin["Bot"]): ) return await self(call, request_timeout=request_timeout) + async def ban_chat_member( + self, + chat_id: Union[int, str], + user_id: int, + until_date: Optional[Union[datetime.datetime, datetime.timedelta, int]] = None, + revoke_messages: Optional[bool] = None, + request_timeout: Optional[int] = None, + ) -> bool: + """ + Use this method to ban a user in a group, a supergroup or a channel. In the case of supergroups and channels, the user will not be able to return to the chat on their own using invite links, etc., unless `unbanned `_ first. The bot must be an administrator in the chat for this to work and must have the appropriate admin rights. Returns :code:`True` on success. + + Source: https://core.telegram.org/bots/api#banchatmember + + :param chat_id: Unique identifier for the target group or username of the target supergroup or channel (in the format :code:`@channelusername`) + :param user_id: Unique identifier of the target user + :param until_date: Date when the user will be unbanned, unix time. If user is banned for more than 366 days or less than 30 seconds from the current time they are considered to be banned forever. Applied for supergroups and channels only. + :param revoke_messages: Pass :code:`True` to delete all messages from the chat for the user that is being removed. If :code:`False`, the user will be able to see messages in the group that were sent before the user was removed. Always :code:`True` for supergroups and channels. + :param request_timeout: Request timeout + :return: In the case of supergroups and channels, the user will not be able to return to + the chat on their own using invite links, etc. Returns True on success. + """ + call = BanChatMember( + chat_id=chat_id, + user_id=user_id, + until_date=until_date, + revoke_messages=revoke_messages, + ) + return await self(call, request_timeout=request_timeout) + async def kick_chat_member( self, chat_id: Union[int, str], @@ -1419,9 +1457,13 @@ class Bot(ContextInstanceMixin["Bot"]): request_timeout: Optional[int] = None, ) -> bool: """ - Use this method to kick a user from a group, a supergroup or a channel. In the case of supergroups and channels, the user will not be able to return to the chat on their own using invite links, etc., unless `unbanned `_ first. The bot must be an administrator in the chat for this to work and must have the appropriate admin rights. Returns :code:`True` on success. + .. warning: - Source: https://core.telegram.org/bots/api#kickchatmember + Renamed from :code:`kickChatMember` in 5.3 bot API version and can be removed in near future + + Use this method to ban a user in a group, a supergroup or a channel. In the case of supergroups and channels, the user will not be able to return to the chat on their own using invite links, etc., unless `unbanned `_ first. The bot must be an administrator in the chat for this to work and must have the appropriate admin rights. Returns :code:`True` on success. + + Source: https://core.telegram.org/bots/api#banchatmember :param chat_id: Unique identifier for the target group or username of the target supergroup or channel (in the format :code:`@channelusername`) :param user_id: Unique identifier of the target user @@ -1447,7 +1489,7 @@ class Bot(ContextInstanceMixin["Bot"]): request_timeout: Optional[int] = None, ) -> bool: """ - Use this method to unban a previously kicked user in a supergroup or channel. The user will **not** return to the group or channel automatically, but will be able to join via link, etc. The bot must be an administrator for this to work. By default, this method guarantees that after the call the user is not a member of the chat, but will be able to join it. So if the user is a member of the chat they will also be **removed** from the chat. If you don't want this, use the parameter *only_if_banned*. Returns :code:`True` on success. + Use this method to unban a previously banned user in a supergroup or channel. The user will **not** return to the group or channel automatically, but will be able to join via link, etc. The bot must be an administrator for this to work. By default, this method guarantees that after the call the user is not a member of the chat, but will be able to join it. So if the user is a member of the chat they will also be **removed** from the chat. If you don't want this, use the parameter *only_if_banned*. Returns :code:`True` on success. Source: https://core.telegram.org/bots/api#unbanchatmember @@ -1884,7 +1926,16 @@ class Bot(ContextInstanceMixin["Bot"]): self, chat_id: Union[int, str], request_timeout: Optional[int] = None, - ) -> List[ChatMember]: + ) -> List[ + Union[ + ChatMemberOwner, + ChatMemberAdministrator, + ChatMemberMember, + ChatMemberRestricted, + ChatMemberLeft, + ChatMemberBanned, + ] + ]: """ Use this method to get a list of administrators in a chat. On success, returns an Array of :class:`aiogram.types.chat_member.ChatMember` objects that contains information about all chat administrators except other bots. If the chat is a group or a supergroup and no administrators were appointed, only the creator will be returned. @@ -1902,7 +1953,7 @@ class Bot(ContextInstanceMixin["Bot"]): ) return await self(call, request_timeout=request_timeout) - async def get_chat_members_count( + async def get_chat_member_count( self, chat_id: Union[int, str], request_timeout: Optional[int] = None, @@ -1910,7 +1961,30 @@ class Bot(ContextInstanceMixin["Bot"]): """ Use this method to get the number of members in a chat. Returns *Int* on success. - Source: https://core.telegram.org/bots/api#getchatmemberscount + Source: https://core.telegram.org/bots/api#getchatmembercount + + :param chat_id: Unique identifier for the target chat or username of the target supergroup or channel (in the format :code:`@channelusername`) + :param request_timeout: Request timeout + :return: Returns Int on success. + """ + call = GetChatMemberCount( + chat_id=chat_id, + ) + return await self(call, request_timeout=request_timeout) + + async def get_chat_members_count( + self, + chat_id: Union[int, str], + request_timeout: Optional[int] = None, + ) -> int: + """ + .. warning: + + Renamed from :code:`getChatMembersCount` in 5.3 bot API version and can be removed in near future + + Use this method to get the number of members in a chat. Returns *Int* on success. + + Source: https://core.telegram.org/bots/api#getchatmembercount :param chat_id: Unique identifier for the target chat or username of the target supergroup or channel (in the format :code:`@channelusername`) :param request_timeout: Request timeout @@ -1926,7 +2000,14 @@ class Bot(ContextInstanceMixin["Bot"]): chat_id: Union[int, str], user_id: int, request_timeout: Optional[int] = None, - ) -> ChatMember: + ) -> Union[ + ChatMemberOwner, + ChatMemberAdministrator, + ChatMemberMember, + ChatMemberRestricted, + ChatMemberLeft, + ChatMemberBanned, + ]: """ Use this method to get information about a member of a chat. Returns a :class:`aiogram.types.chat_member.ChatMember` object on success. @@ -2022,35 +2103,71 @@ class Bot(ContextInstanceMixin["Bot"]): async def set_my_commands( self, commands: List[BotCommand], + scope: Optional[BotCommandScope] = None, + language_code: Optional[str] = None, request_timeout: Optional[int] = None, ) -> bool: """ - Use this method to change the list of the bot's commands. Returns :code:`True` on success. + Use this method to change the list of the bot's commands. See `https://core.telegram.org/bots#commands `_`https://core.telegram.org/bots#commands `_ for more details about bot commands. Returns :code:`True` on success. Source: https://core.telegram.org/bots/api#setmycommands :param commands: A JSON-serialized list of bot commands to be set as the list of the bot's commands. At most 100 commands can be specified. + :param scope: A JSON-serialized object, describing scope of users for which the commands are relevant. Defaults to :class:`aiogram.types.bot_command_scope_default.BotCommandScopeDefault`. + :param language_code: A two-letter ISO 639-1 language code. If empty, commands will be applied to all users from the given scope, for whose language there are no dedicated commands :param request_timeout: Request timeout :return: Returns True on success. """ call = SetMyCommands( commands=commands, + scope=scope, + language_code=language_code, + ) + return await self(call, request_timeout=request_timeout) + + async def delete_my_commands( + self, + scope: Optional[BotCommandScope] = None, + language_code: Optional[str] = None, + request_timeout: Optional[int] = None, + ) -> bool: + """ + Use this method to delete the list of the bot's commands for the given scope and user language. After deletion, `higher level commands `_ will be shown to affected users. Returns :code:`True` on success. + + Source: https://core.telegram.org/bots/api#deletemycommands + + :param scope: A JSON-serialized object, describing scope of users for which the commands are relevant. Defaults to :class:`aiogram.types.bot_command_scope_default.BotCommandScopeDefault`. + :param language_code: A two-letter ISO 639-1 language code. If empty, commands will be applied to all users from the given scope, for whose language there are no dedicated commands + :param request_timeout: Request timeout + :return: Returns True on success. + """ + call = DeleteMyCommands( + scope=scope, + language_code=language_code, ) return await self(call, request_timeout=request_timeout) async def get_my_commands( self, + scope: Optional[BotCommandScope] = None, + language_code: Optional[str] = None, request_timeout: Optional[int] = None, ) -> List[BotCommand]: """ - Use this method to get the current list of the bot's commands. Requires no parameters. Returns Array of :class:`aiogram.types.bot_command.BotCommand` on success. + Use this method to get the current list of the bot's commands for the given scope and user language. Returns Array of :class:`aiogram.types.bot_command.BotCommand` on success. If commands aren't set, an empty list is returned. Source: https://core.telegram.org/bots/api#getmycommands + :param scope: A JSON-serialized object, describing scope of users. Defaults to :class:`aiogram.types.bot_command_scope_default.BotCommandScopeDefault`. + :param language_code: A two-letter ISO 639-1 language code or an empty string :param request_timeout: Request timeout - :return: Returns Array of BotCommand on success. + :return: Returns Array of BotCommand on success. If commands aren't set, an empty list is + returned. """ - call = GetMyCommands() + call = GetMyCommands( + scope=scope, + language_code=language_code, + ) return await self(call, request_timeout=request_timeout) # ============================================================================================= diff --git a/aiogram/client/session/aiohttp.py b/aiogram/client/session/aiohttp.py index 3373d800..a25e705c 100644 --- a/aiogram/client/session/aiohttp.py +++ b/aiogram/client/session/aiohttp.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from typing import ( TYPE_CHECKING, Any, @@ -10,21 +11,21 @@ from typing import ( Optional, Tuple, Type, - TypeVar, Union, cast, ) -from aiohttp import BasicAuth, ClientSession, FormData, TCPConnector +from aiohttp import BasicAuth, ClientError, ClientSession, FormData, TCPConnector from aiogram.methods import Request, TelegramMethod +from ...methods.base import TelegramType +from ...utils.exceptions.network import NetworkError from .base import UNSET, BaseSession if TYPE_CHECKING: # pragma: no cover from ..bot import Bot -T = TypeVar("T") _ProxyBasic = Union[str, Tuple[str, BasicAuth]] _ProxyChain = Iterable[_ProxyBasic] _ProxyType = Union[_ProxyChain, _ProxyBasic] @@ -76,6 +77,8 @@ def _prepare_connector(chain_or_plain: _ProxyType) -> Tuple[Type["TCPConnector"] class AiohttpSession(BaseSession): def __init__(self, proxy: Optional[_ProxyType] = None): + super().__init__() + self._session: Optional[ClientSession] = None self._connector_type: Type[TCPConnector] = TCPConnector self._connector_init: Dict[str, Any] = {} @@ -86,7 +89,7 @@ class AiohttpSession(BaseSession): try: self._setup_proxy_connector(proxy) except ImportError as exc: # pragma: no cover - raise UserWarning( + raise RuntimeError( "In order to use aiohttp client for proxy requests, install " "https://pypi.org/project/aiohttp-socks/" ) from exc @@ -130,22 +133,25 @@ class AiohttpSession(BaseSession): return form async def make_request( - self, bot: Bot, call: TelegramMethod[T], timeout: Optional[int] = None - ) -> T: + self, bot: Bot, call: TelegramMethod[TelegramType], timeout: Optional[int] = None + ) -> TelegramType: session = await self.create_session() request = call.build_request(bot) url = self.api.api_url(token=bot.token, method=request.method) form = self.build_form_data(request) - async with session.post( - url, data=form, timeout=self.timeout if timeout is None else timeout - ) as resp: - raw_result = await resp.json(loads=self.json_loads) - - response = call.build_response(raw_result) - self.raise_for_status(response) - return cast(T, response.result) + try: + async with session.post( + url, data=form, timeout=self.timeout if timeout is None else timeout + ) as resp: + raw_result = await resp.text() + except asyncio.TimeoutError: + raise NetworkError(method=call, message="Request timeout error") + except ClientError as e: + raise NetworkError(method=call, message=f"{type(e).__name__}: {e}") + response = self.check_response(method=call, status_code=resp.status, content=raw_result) + return cast(TelegramType, response.result) async def stream_content( self, url: str, timeout: int, chunk_size: int diff --git a/aiogram/client/session/base.py b/aiogram/client/session/base.py index 7a8edd4a..0a8c2973 100644 --- a/aiogram/client/session/base.py +++ b/aiogram/client/session/base.py @@ -3,32 +3,50 @@ from __future__ import annotations import abc import datetime import json +from functools import partial +from http import HTTPStatus from types import TracebackType from typing import ( TYPE_CHECKING, Any, AsyncGenerator, + Awaitable, Callable, ClassVar, + List, Optional, Type, - TypeVar, Union, + cast, ) -from aiogram.utils.exceptions import TelegramAPIError +from aiogram.utils.exceptions.base import TelegramAPIError from aiogram.utils.helper import Default from ...methods import Response, TelegramMethod -from ...types import UNSET +from ...methods.base import TelegramType +from ...types import UNSET, TelegramObject +from ...utils.exceptions.bad_request import BadRequest +from ...utils.exceptions.conflict import ConflictError +from ...utils.exceptions.network import EntityTooLarge +from ...utils.exceptions.not_found import NotFound +from ...utils.exceptions.server import RestartingTelegram, ServerError +from ...utils.exceptions.special import MigrateToChat, RetryAfter +from ...utils.exceptions.unauthorized import UnauthorizedError from ..telegram import PRODUCTION, TelegramAPIServer if TYPE_CHECKING: # pragma: no cover from ..bot import Bot -T = TypeVar("T") _JsonLoads = Callable[..., Any] _JsonDumps = Callable[..., str] +NextRequestMiddlewareType = Callable[ + ["Bot", TelegramMethod[TelegramObject]], Awaitable[Response[TelegramObject]] +] +RequestMiddlewareType = Callable[ + ["Bot", TelegramMethod[TelegramType], NextRequestMiddlewareType], + Awaitable[Response[TelegramType]], +] class BaseSession(abc.ABC): @@ -43,16 +61,52 @@ class BaseSession(abc.ABC): timeout: Default[float] = Default(fget=lambda self: float(self.__class__.default_timeout)) """Session scope request timeout""" - @classmethod - def raise_for_status(cls, response: Response[T]) -> None: + def __init__(self) -> None: + self.middlewares: List[RequestMiddlewareType[TelegramObject]] = [] + + def check_response( + self, method: TelegramMethod[TelegramType], status_code: int, content: str + ) -> Response[TelegramType]: """ Check response status - - :param response: Response instance """ - if response.ok: - return - raise TelegramAPIError(response.description) + json_data = self.json_loads(content) + response = method.build_response(json_data) + if HTTPStatus.OK <= status_code <= HTTPStatus.IM_USED and response.ok: + return response + + description = cast(str, response.description) + + if parameters := response.parameters: + if parameters.retry_after: + raise RetryAfter( + method=method, message=description, retry_after=parameters.retry_after + ) + if parameters.migrate_to_chat_id: + raise MigrateToChat( + method=method, + message=description, + migrate_to_chat_id=parameters.migrate_to_chat_id, + ) + if status_code == HTTPStatus.BAD_REQUEST: + raise BadRequest(method=method, message=description) + if status_code == HTTPStatus.NOT_FOUND: + raise NotFound(method=method, message=description) + if status_code == HTTPStatus.CONFLICT: + raise ConflictError(method=method, message=description) + if status_code in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): + raise UnauthorizedError(method=method, message=description) + if status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE: + raise EntityTooLarge(method=method, message=description) + if status_code >= HTTPStatus.INTERNAL_SERVER_ERROR: + if "restart" in description: + raise RestartingTelegram(method=method, message=description) + raise ServerError(method=method, message=description) + + raise TelegramAPIError( + method=method, + message=description, + ) @abc.abstractmethod async def close(self) -> None: # pragma: no cover @@ -63,8 +117,8 @@ class BaseSession(abc.ABC): @abc.abstractmethod async def make_request( - self, bot: Bot, method: TelegramMethod[T], timeout: Optional[int] = UNSET - ) -> T: # pragma: no cover + self, bot: Bot, method: TelegramMethod[TelegramType], timeout: Optional[int] = UNSET + ) -> TelegramType: # pragma: no cover """ Make request to Telegram Bot API @@ -111,6 +165,20 @@ class BaseSession(abc.ABC): return {k: self.clean_json(v) for k, v in value.items() if v is not None} return value + def middleware( + self, middleware: RequestMiddlewareType[TelegramObject] + ) -> RequestMiddlewareType[TelegramObject]: + self.middlewares.append(middleware) + return middleware + + async def __call__( + self, bot: Bot, method: TelegramMethod[TelegramType], timeout: Optional[int] = UNSET + ) -> TelegramType: + middleware = partial(self.make_request, timeout=timeout) + for m in reversed(self.middlewares): + middleware = partial(m, make_request=middleware) # type: ignore + return await middleware(bot, method) + async def __aenter__(self) -> BaseSession: return self diff --git a/aiogram/dispatcher/dispatcher.py b/aiogram/dispatcher/dispatcher.py index 2978d5bd..2f4bb1ba 100644 --- a/aiogram/dispatcher/dispatcher.py +++ b/aiogram/dispatcher/dispatcher.py @@ -4,16 +4,18 @@ import asyncio import contextvars import warnings from asyncio import CancelledError, Future, Lock -from typing import Any, AsyncGenerator, Dict, Optional, Union, cast +from typing import Any, AsyncGenerator, Dict, List, Optional, Union from .. import loggers from ..client.bot import Bot -from ..methods import TelegramMethod +from ..methods import GetUpdates, TelegramMethod from ..types import TelegramObject, Update, User -from ..utils.exceptions import TelegramAPIError +from ..utils.backoff import Backoff, BackoffConfig +from ..utils.exceptions.base import TelegramAPIError +from ..utils.exceptions.network import NetworkError +from ..utils.exceptions.server import ServerError from .event.bases import UNHANDLED, SkipHandler from .event.telegram import TelegramEventObserver -from .fsm.context import FSMContext from .fsm.middleware import FSMContextMiddleware from .fsm.storage.base import BaseStorage from .fsm.storage.memory import MemoryStorage @@ -22,6 +24,8 @@ from .middlewares.error import ErrorsMiddleware from .middlewares.user_context import UserContextMiddleware from .router import Router +DEFAULT_BACKOFF_CONFIG = BackoffConfig(min_delay=1.0, max_delay=5.0, factor=1.3, jitter=0.1) + class Dispatcher(Router): """ @@ -32,7 +36,7 @@ class Dispatcher(Router): self, storage: Optional[BaseStorage] = None, fsm_strategy: FSMStrategy = FSMStrategy.USER_IN_CHAT, - isolate_events: bool = True, + isolate_events: bool = False, **kwargs: Any, ) -> None: super(Dispatcher, self).__init__(**kwargs) @@ -64,7 +68,7 @@ class Dispatcher(Router): @property def parent_router(self) -> None: """ - Dispatcher has no parent router + Dispatcher has no parent router and can't be included to any other routers or dispatchers :return: """ @@ -83,6 +87,7 @@ class Dispatcher(Router): async def feed_update(self, bot: Bot, update: Update, **kwargs: Any) -> Any: """ Main entry point for incoming updates + Response of this method can be used as Webhook response :param bot: :param update: @@ -91,9 +96,10 @@ class Dispatcher(Router): handled = False start_time = loop.time() - Bot.set_current(bot) + token = Bot.set_current(bot) try: - response = await self.update.trigger(update, bot=bot, **kwargs) + kwargs.update(bot=bot) + response = await self.update.wrap_outer_middleware(self.update.trigger, update, kwargs) handled = response is not UNHANDLED return response finally: @@ -106,6 +112,7 @@ class Dispatcher(Router): duration, bot.id, ) + Bot.reset_current(token) async def feed_raw_update(self, bot: Bot, update: Dict[str, Any], **kwargs: Any) -> Any: """ @@ -119,16 +126,53 @@ class Dispatcher(Router): return await self.feed_update(bot=bot, update=parsed_update, **kwargs) @classmethod - async def _listen_updates(cls, bot: Bot) -> AsyncGenerator[Update, None]: + async def _listen_updates( + cls, + bot: Bot, + polling_timeout: int = 30, + backoff_config: BackoffConfig = DEFAULT_BACKOFF_CONFIG, + allowed_updates: Optional[List[str]] = None, + ) -> AsyncGenerator[Update, None]: """ - Infinity updates reader + Endless updates reader with correctly handling any server-side or connection errors. + + So you may not worry that the polling will stop working. """ - update_id: Optional[int] = None + backoff = Backoff(config=backoff_config) + get_updates = GetUpdates(timeout=polling_timeout, allowed_updates=allowed_updates) + kwargs = {} + if bot.session.timeout: + # Request timeout can be lower than session timeout ant that's OK. + # To prevent false-positive TimeoutError we should wait longer than polling timeout + kwargs["request_timeout"] = int(bot.session.timeout + polling_timeout) while True: - # TODO: Skip restarting telegram error - for update in await bot.get_updates(offset=update_id): + try: + updates = await bot(get_updates, **kwargs) + except (NetworkError, ServerError) as e: + # In cases when Telegram Bot API was inaccessible don't need to stop polling process + # because some of developers can't make auto-restarting of the script + loggers.dispatcher.error("Failed to fetch updates - %s: %s", type(e).__name__, e) + # And also backoff timeout is best practice to retry any network activity + loggers.dispatcher.warning( + "Sleep for %f seconds and try again... (tryings = %d, bot id = %d)", + backoff.next_delay, + backoff.counter, + bot.id, + ) + await backoff.asleep() + continue + + # In case when network connection was fixed let's reset the backoff + # to initial value and then process updates + backoff.reset() + + for update in updates: yield update - update_id = update.update_id + 1 + # The getUpdates method returns the earliest 100 unconfirmed updates. + # To confirm an update, use the offset parameter when calling getUpdates + # All updates with update_id less than or equal to offset will be marked as confirmed on the server + # and will no longer be returned. + get_updates.offset = update.update_id + 1 async def _listen_update(self, update: Update, **kwargs: Any) -> Any: """ @@ -189,20 +233,11 @@ class Dispatcher(Router): "installed not latest version of aiogram framework", RuntimeWarning, ) - raise SkipHandler + raise SkipHandler() kwargs.update(event_update=update) - for router in self.chain: - kwargs.update(event_router=router) - observer = router.observers[update_type] - response = await observer.trigger(event, update=update, **kwargs) - if response is not UNHANDLED: - break - else: - response = UNHANDLED - - return response + return await self.propagate_event(update_type=update_type, event=event, **kwargs) @classmethod async def _silent_call_request(cls, bot: Bot, result: TelegramMethod[Any]) -> None: @@ -249,7 +284,15 @@ class Dispatcher(Router): ) return True # because update was processed but unsuccessful - async def _polling(self, bot: Bot, **kwargs: Any) -> None: + async def _polling( + self, + bot: Bot, + polling_timeout: int = 30, + handle_as_tasks: bool = True, + backoff_config: BackoffConfig = DEFAULT_BACKOFF_CONFIG, + allowed_updates: Optional[List[str]] = None, + **kwargs: Any, + ) -> None: """ Internal polling process @@ -257,8 +300,17 @@ class Dispatcher(Router): :param kwargs: :return: """ - async for update in self._listen_updates(bot): - await self._process_update(bot=bot, update=update, **kwargs) + async for update in self._listen_updates( + bot, + polling_timeout=polling_timeout, + backoff_config=backoff_config, + allowed_updates=allowed_updates, + ): + handle_update = self._process_update(bot=bot, update=update, **kwargs) + if handle_as_tasks: + asyncio.create_task(handle_update) + else: + await handle_update async def _feed_webhook_update(self, bot: Bot, update: Update, **kwargs: Any) -> Any: """ @@ -336,12 +388,23 @@ class Dispatcher(Router): return None - async def start_polling(self, *bots: Bot, **kwargs: Any) -> None: + async def start_polling( + self, + *bots: Bot, + polling_timeout: int = 10, + handle_as_tasks: bool = True, + backoff_config: BackoffConfig = DEFAULT_BACKOFF_CONFIG, + allowed_updates: Optional[List[str]] = None, + **kwargs: Any, + ) -> None: """ Polling runner :param bots: + :param polling_timeout: + :param handle_as_tasks: :param kwargs: + :param backoff_config: :return: """ async with self._running_lock: # Prevent to run this method twice at a once @@ -356,7 +419,16 @@ class Dispatcher(Router): loggers.dispatcher.info( "Run polling for bot @%s id=%d - %r", user.username, bot.id, user.full_name ) - coro_list.append(self._polling(bot=bot, **kwargs)) + coro_list.append( + self._polling( + bot=bot, + handle_as_tasks=handle_as_tasks, + polling_timeout=polling_timeout, + backoff_config=backoff_config, + allowed_updates=allowed_updates, + **kwargs, + ) + ) await asyncio.gather(*coro_list) finally: for bot in bots: # Close sessions @@ -364,19 +436,37 @@ class Dispatcher(Router): loggers.dispatcher.info("Polling stopped") await self.emit_shutdown(**workflow_data) - def run_polling(self, *bots: Bot, **kwargs: Any) -> None: + def run_polling( + self, + *bots: Bot, + polling_timeout: int = 30, + handle_as_tasks: bool = True, + backoff_config: BackoffConfig = DEFAULT_BACKOFF_CONFIG, + allowed_updates: Optional[List[str]] = None, + **kwargs: Any, + ) -> None: """ Run many bots with polling - :param bots: - :param kwargs: + :param bots: Bot instances + :param polling_timeout: Poling timeout + :param backoff_config: + :param handle_as_tasks: Run task for each event and no wait result + :param allowed_updates: List of the update types you want your bot to receive + :param kwargs: contextual data :return: """ try: - return asyncio.run(self.start_polling(*bots, **kwargs)) + return asyncio.run( + self.start_polling( + *bots, + **kwargs, + polling_timeout=polling_timeout, + handle_as_tasks=handle_as_tasks, + backoff_config=backoff_config, + allowed_updates=allowed_updates, + ) + ) except (KeyboardInterrupt, SystemExit): # pragma: no cover # Allow to graceful shutdown pass - - def current_state(self, chat_id: int, user_id: int) -> FSMContext: - return cast(FSMContext, self.fsm.resolve_context(chat_id=chat_id, user_id=user_id)) diff --git a/aiogram/dispatcher/event/bases.py b/aiogram/dispatcher/event/bases.py index cb5fb2cf..8e5937ec 100644 --- a/aiogram/dispatcher/event/bases.py +++ b/aiogram/dispatcher/event/bases.py @@ -12,6 +12,7 @@ MiddlewareType = Union[ ] UNHANDLED = sentinel.UNHANDLED +REJECTED = sentinel.REJECTED class SkipHandler(Exception): diff --git a/aiogram/dispatcher/event/handler.py b/aiogram/dispatcher/event/handler.py index 63f5130b..37a9ecb7 100644 --- a/aiogram/dispatcher/event/handler.py +++ b/aiogram/dispatcher/event/handler.py @@ -32,7 +32,9 @@ class CallableMixin: if self.spec.varkw: return kwargs - return {k: v for k, v in kwargs.items() if k in self.spec.args} + return { + k: v for k, v in kwargs.items() if k in self.spec.args or k in self.spec.kwonlyargs + } async def call(self, *args: Any, **kwargs: Any) -> Any: wrapped = partial(self.callback, *args, **self._prepare_kwargs(kwargs)) diff --git a/aiogram/dispatcher/event/telegram.py b/aiogram/dispatcher/event/telegram.py index ab185043..424ffeb3 100644 --- a/aiogram/dispatcher/event/telegram.py +++ b/aiogram/dispatcher/event/telegram.py @@ -8,7 +8,7 @@ from pydantic import ValidationError from ...types import TelegramObject from ..filters.base import BaseFilter -from .bases import UNHANDLED, MiddlewareType, NextMiddlewareType, SkipHandler +from .bases import REJECTED, UNHANDLED, MiddlewareType, NextMiddlewareType, SkipHandler from .handler import CallbackType, FilterObject, FilterType, HandlerObject, HandlerType if TYPE_CHECKING: # pragma: no cover @@ -32,6 +32,24 @@ class TelegramEventObserver: self.outer_middlewares: List[MiddlewareType] = [] self.middlewares: List[MiddlewareType] = [] + # Re-used filters check method from already implemented handler object + # with dummy callback which never will be used + self._handler = HandlerObject(callback=lambda: True, filters=[]) + + def filter(self, *filters: FilterType, **bound_filters: Any) -> None: + """ + Register filter for all handlers of this event observer + + :param filters: positional filters + :param bound_filters: keyword filters + """ + resolved_filters = self.resolve_filters(bound_filters) + if self._handler.filters is None: + self._handler.filters = [] + self._handler.filters.extend( + [FilterObject(filter_) for filter_ in chain(resolved_filters, filters)] + ) + def bind_filter(self, bound_filter: Type[BaseFilter]) -> None: """ Register filter class in factory @@ -60,15 +78,19 @@ class TelegramEventObserver: yield filter_ registry.append(filter_) - def _resolve_inner_middlewares(self) -> List[MiddlewareType]: + def _resolve_middlewares(self, *, outer: bool = False) -> List[MiddlewareType]: """ - Get all inner middlewares in an tree + Get all middlewares in a tree + :param *: """ middlewares = [] + if outer: + middlewares.extend(self.outer_middlewares) + else: + for router in reversed(list(self.router.chain_head)): + observer = router.observers[self.event_name] + middlewares.extend(observer.middlewares) - for router in self.router.chain_head: - observer = router.observers[self.event_name] - middlewares.extend(observer.middlewares) return middlewares def resolve_filters(self, full_config: Dict[str, Any]) -> List[BaseFilter]: @@ -126,22 +148,30 @@ class TelegramEventObserver: middleware = functools.partial(m, middleware) return middleware + def wrap_outer_middleware( + self, callback: Any, event: TelegramObject, data: Dict[str, Any] + ) -> Any: + wrapped_outer = self._wrap_middleware(self._resolve_middlewares(outer=True), callback) + return wrapped_outer(event, data) + async def trigger(self, event: TelegramObject, **kwargs: Any) -> Any: """ Propagate event to handlers and stops propagation on first match. Handler will be called when all its filters is pass. """ - wrapped_outer = self._wrap_middleware(self.outer_middlewares, self._trigger) - return await wrapped_outer(event, kwargs) + # Check globally defined filters before any other handler will be checked + result, data = await self._handler.check(event, **kwargs) + if not result: + return REJECTED + kwargs.update(data) - async def _trigger(self, event: TelegramObject, **kwargs: Any) -> Any: for handler in self.handlers: result, data = await handler.check(event, **kwargs) if result: kwargs.update(data) try: wrapped_inner = self._wrap_middleware( - self._resolve_inner_middlewares(), handler.call + self._resolve_middlewares(), handler.call ) return await wrapped_inner(event, kwargs) except SkipHandler: @@ -150,7 +180,7 @@ class TelegramEventObserver: return UNHANDLED def __call__( - self, *args: FilterType, **bound_filters: BaseFilter + self, *args: FilterType, **bound_filters: Any ) -> Callable[[CallbackType], CallbackType]: """ Decorator for registering event handlers diff --git a/aiogram/dispatcher/filters/callback_data.py b/aiogram/dispatcher/filters/callback_data.py new file mode 100644 index 00000000..4a1cd392 --- /dev/null +++ b/aiogram/dispatcher/filters/callback_data.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from decimal import Decimal +from enum import Enum +from fractions import Fraction +from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Type, TypeVar, Union +from uuid import UUID + +from magic_filter import MagicFilter +from pydantic import BaseModel + +from aiogram.dispatcher.filters import BaseFilter +from aiogram.types import CallbackQuery + +T = TypeVar("T", bound="CallbackData") + +MAX_CALLBACK_LENGTH: int = 64 + + +class CallbackDataException(Exception): + pass + + +class CallbackData(BaseModel): + if TYPE_CHECKING: # pragma: no cover + sep: str + prefix: str + + def __init_subclass__(cls, **kwargs: Any) -> None: + if "prefix" not in kwargs: + raise ValueError( + f"prefix required, usage example: " + f"`class {cls.__name__}(CallbackData, prefix='my_callback'): ...`" + ) + cls.sep = kwargs.pop("sep", ":") + cls.prefix = kwargs.pop("prefix") + if cls.sep in cls.prefix: + raise ValueError( + f"Separator symbol {cls.sep!r} can not be used inside prefix {cls.prefix!r}" + ) + + def _encode_value(self, key: str, value: Any) -> str: + if value is None: + return "" + if isinstance(value, Enum): + return str(value.value) + if isinstance(value, (int, str, float, Decimal, Fraction, UUID)): + return str(value) + raise ValueError( + f"Attribute {key}={value!r} of type {type(value).__name__!r}" + f" can not be packed to callback data" + ) + + def pack(self) -> str: + result = [self.prefix] + for key, value in self.dict().items(): + encoded = self._encode_value(key, value) + if self.sep in encoded: + raise ValueError( + f"Separator symbol {self.sep!r} can not be used in value {key}={encoded!r}" + ) + result.append(encoded) + callback_data = self.sep.join(result) + if len(callback_data.encode()) > MAX_CALLBACK_LENGTH: + raise ValueError( + f"Resulted callback data is too long! len({callback_data!r}.encode()) > {MAX_CALLBACK_LENGTH}" + ) + return callback_data + + @classmethod + def unpack(cls: Type[T], value: str) -> T: + prefix, *parts = value.split(cls.sep) + names = cls.__fields__.keys() + if len(parts) != len(names): + raise TypeError( + f"Callback data {cls.__name__!r} takes {len(names)} arguments but {len(parts)} were given" + ) + if prefix != cls.prefix: + raise ValueError(f"Bad prefix ({prefix!r} != {cls.prefix!r})") + payload = {} + for k, v in zip(names, parts): # type: str, Optional[str] + if field := cls.__fields__.get(k): + if v == "" and not field.required: + v = None + payload[k] = v + return cls(**payload) + + @classmethod + def filter(cls, rule: Optional[MagicFilter] = None) -> CallbackQueryFilter: + return CallbackQueryFilter(callback_data=cls, rule=rule) + + class Config: + use_enum_values = True + + +class CallbackQueryFilter(BaseFilter): + callback_data: Type[CallbackData] + rule: Optional[MagicFilter] = None + + async def __call__(self, query: CallbackQuery) -> Union[Literal[False], Dict[str, Any]]: + if not isinstance(query, CallbackQuery) or not query.data: + return False + try: + callback_data = self.callback_data.unpack(query.data) + except (TypeError, ValueError): + return False + + if self.rule is None or self.rule.resolve(callback_data): + return {"callback_data": callback_data} + return False + + class Config: + arbitrary_types_allowed = True diff --git a/aiogram/dispatcher/filters/command.py b/aiogram/dispatcher/filters/command.py index 899b09be..0e46c1ec 100644 --- a/aiogram/dispatcher/filters/command.py +++ b/aiogram/dispatcher/filters/command.py @@ -1,18 +1,24 @@ from __future__ import annotations import re -from dataclasses import dataclass, field -from typing import Any, Dict, Match, Optional, Pattern, Sequence, Union, cast +from dataclasses import dataclass, field, replace +from typing import Any, Dict, Match, Optional, Pattern, Sequence, Tuple, Union, cast -from pydantic import validator +from magic_filter import MagicFilter +from pydantic import Field, validator from aiogram import Bot from aiogram.dispatcher.filters import BaseFilter from aiogram.types import Message +from aiogram.utils.deep_linking import decode_payload CommandPatterType = Union[str, re.Pattern] +class CommandException(Exception): + pass + + class Command(BaseFilter): """ This filter can be helpful for handling commands from the text messages. @@ -29,6 +35,8 @@ class Command(BaseFilter): """Ignore case (Does not work with regexp, use flags instead)""" commands_ignore_mention: bool = False """Ignore bot mention. By default bot can not handle commands intended for other bots""" + command_magic: Optional[MagicFilter] = None + """Validate command object via Magic filter after all checks done""" @validator("commands", always=True) def _validate_commands( @@ -39,12 +47,54 @@ class Command(BaseFilter): return value async def __call__(self, message: Message, bot: Bot) -> Union[bool, Dict[str, Any]]: - if not message.text: + text = message.text or message.caption + if not text: return False - return await self.parse_command(text=message.text, bot=bot) + try: + command = await self.parse_command(text=text, bot=bot) + except CommandException: + return False + return {"command": command} - async def parse_command(self, text: str, bot: Bot) -> Union[bool, Dict[str, CommandObject]]: + def extract_command(self, text: str) -> CommandObject: + # First step: separate command with arguments + # "/command@mention arg1 arg2" -> "/command@mention", ["arg1 arg2"] + try: + full_command, *args = text.split(maxsplit=1) + except ValueError: + raise CommandException("not enough values to unpack") + + # Separate command into valuable parts + # "/command@mention" -> "/", ("command", "@", "mention") + prefix, (command, _, mention) = full_command[0], full_command[1:].partition("@") + return CommandObject( + prefix=prefix, command=command, mention=mention, args=args[0] if args else None + ) + + def validate_prefix(self, command: CommandObject) -> None: + if command.prefix not in self.commands_prefix: + raise CommandException("Invalid command prefix") + + async def validate_mention(self, bot: Bot, command: CommandObject) -> None: + if command.mention and not self.commands_ignore_mention: + me = await bot.me() + if me.username and command.mention.lower() != me.username.lower(): + raise CommandException("Mention did not match") + + def validate_command(self, command: CommandObject) -> CommandObject: + for allowed_command in cast(Sequence[CommandPatterType], self.commands): + # Command can be presented as regexp pattern or raw string + # then need to validate that in different ways + if isinstance(allowed_command, Pattern): # Regexp + result = allowed_command.match(command.command) + if result: + return replace(command, regexp_match=result) + elif command.command == allowed_command: # String + return command + raise CommandException("Command did not match pattern") + + async def parse_command(self, text: str, bot: Bot) -> CommandObject: """ Extract command from the text and validate @@ -52,56 +102,18 @@ class Command(BaseFilter): :param bot: :return: """ - if not text.strip(): - return False + command = self.extract_command(text) + self.validate_prefix(command=command) + await self.validate_mention(bot=bot, command=command) + command = self.validate_command(command) + self.do_magic(command=command) + return command - # First step: separate command with arguments - # "/command@mention arg1 arg2" -> "/command@mention", ["arg1 arg2"] - full_command, *args = text.split(maxsplit=1) - - # Separate command into valuable parts - # "/command@mention" -> "/", ("command", "@", "mention") - prefix, (command, _, mention) = full_command[0], full_command[1:].partition("@") - - # Validate prefixes - if prefix not in self.commands_prefix: - return False - - # Validate mention - if mention and not self.commands_ignore_mention: - me = await bot.me() - if me.username and mention.lower() != me.username.lower(): - return False - - # Validate command - for allowed_command in cast(Sequence[CommandPatterType], self.commands): - # Command can be presented as regexp pattern or raw string - # then need to validate that in different ways - if isinstance(allowed_command, Pattern): # Regexp - result = allowed_command.match(command) - if result: - return { - "command": CommandObject( - prefix=prefix, - command=command, - mention=mention, - args=args[0] if args else None, - match=result, - ) - } - - elif command == allowed_command: # String - return { - "command": CommandObject( - prefix=prefix, - command=command, - mention=mention, - args=args[0] if args else None, - match=None, - ) - } - - return False + def do_magic(self, command: CommandObject) -> None: + if not self.command_magic: + return + if not self.command_magic.resolve(command): + raise CommandException("Rejected via magic filter") class Config: arbitrary_types_allowed = True @@ -122,7 +134,7 @@ class CommandObject: """Mention (if available)""" args: Optional[str] = field(repr=False, default=None) """Command argument""" - match: Optional[Match[str]] = field(repr=False, default=None) + regexp_match: Optional[Match[str]] = field(repr=False, default=None) """Will be presented match result if the command is presented as regexp in filter""" @property @@ -143,3 +155,40 @@ class CommandObject: if self.args: line += " " + self.args return line + + +class CommandStart(Command): + commands: Tuple[str] = Field(("start",), const=True) + commands_prefix: str = Field("/", const=True) + deep_link: bool = False + deep_link_encoded: bool = False + + async def parse_command(self, text: str, bot: Bot) -> CommandObject: + """ + Extract command from the text and validate + + :param text: + :param bot: + :return: + """ + command = self.extract_command(text) + self.validate_prefix(command=command) + await self.validate_mention(bot=bot, command=command) + command = self.validate_command(command) + command = self.validate_deeplink(command=command) + self.do_magic(command=command) + return command + + def validate_deeplink(self, command: CommandObject) -> CommandObject: + if not self.deep_link: + return command + if not command.args: + raise CommandException("Deep-link was missing") + args = command.args + if self.deep_link_encoded: + try: + args = decode_payload(args) + except UnicodeDecodeError as e: + raise CommandException(f"Failed to decode Base64: {e}") + return replace(command, args=args) + return command diff --git a/aiogram/dispatcher/filters/exception.py b/aiogram/dispatcher/filters/exception.py index f46cd739..f4a077f8 100644 --- a/aiogram/dispatcher/filters/exception.py +++ b/aiogram/dispatcher/filters/exception.py @@ -26,20 +26,20 @@ class ExceptionMessageFilter(BaseFilter): Allow to match exception by message """ - match: Union[str, Pattern[str]] + pattern: Union[str, Pattern[str]] """Regexp pattern""" class Config: arbitrary_types_allowed = True - @validator("match") + @validator("pattern") def _validate_match(cls, value: Union[str, Pattern[str]]) -> Union[str, Pattern[str]]: if isinstance(value, str): return re.compile(value) return value async def __call__(self, exception: Exception) -> Union[bool, Dict[str, Any]]: - pattern = cast(Pattern[str], self.match) + pattern = cast(Pattern[str], self.pattern) result = pattern.match(str(exception)) if not result: return False diff --git a/aiogram/dispatcher/fsm/context.py b/aiogram/dispatcher/fsm/context.py index 78ed480b..dc4e4030 100644 --- a/aiogram/dispatcher/fsm/context.py +++ b/aiogram/dispatcher/fsm/context.py @@ -1,25 +1,35 @@ from typing import Any, Dict, Optional +from aiogram import Bot from aiogram.dispatcher.fsm.storage.base import BaseStorage, StateType class FSMContext: - def __init__(self, storage: BaseStorage, chat_id: int, user_id: int) -> None: + def __init__(self, bot: Bot, storage: BaseStorage, chat_id: int, user_id: int) -> None: + self.bot = bot self.storage = storage self.chat_id = chat_id self.user_id = user_id async def set_state(self, state: StateType = None) -> None: - await self.storage.set_state(chat_id=self.chat_id, user_id=self.user_id, state=state) + await self.storage.set_state( + bot=self.bot, chat_id=self.chat_id, user_id=self.user_id, state=state + ) async def get_state(self) -> Optional[str]: - return await self.storage.get_state(chat_id=self.chat_id, user_id=self.user_id) + return await self.storage.get_state( + bot=self.bot, chat_id=self.chat_id, user_id=self.user_id + ) async def set_data(self, data: Dict[str, Any]) -> None: - await self.storage.set_data(chat_id=self.chat_id, user_id=self.user_id, data=data) + await self.storage.set_data( + bot=self.bot, chat_id=self.chat_id, user_id=self.user_id, data=data + ) async def get_data(self) -> Dict[str, Any]: - return await self.storage.get_data(chat_id=self.chat_id, user_id=self.user_id) + return await self.storage.get_data( + bot=self.bot, chat_id=self.chat_id, user_id=self.user_id + ) async def update_data( self, data: Optional[Dict[str, Any]] = None, **kwargs: Any @@ -27,7 +37,7 @@ class FSMContext: if data: kwargs.update(data) return await self.storage.update_data( - chat_id=self.chat_id, user_id=self.user_id, data=kwargs + bot=self.bot, chat_id=self.chat_id, user_id=self.user_id, data=kwargs ) async def clear(self) -> None: diff --git a/aiogram/dispatcher/fsm/middleware.py b/aiogram/dispatcher/fsm/middleware.py index d3d5d8c2..734c5825 100644 --- a/aiogram/dispatcher/fsm/middleware.py +++ b/aiogram/dispatcher/fsm/middleware.py @@ -1,5 +1,6 @@ -from typing import Any, Awaitable, Callable, Dict, Optional +from typing import Any, Awaitable, Callable, Dict, Optional, cast +from aiogram import Bot from aiogram.dispatcher.fsm.context import FSMContext from aiogram.dispatcher.fsm.storage.base import BaseStorage from aiogram.dispatcher.fsm.strategy import FSMStrategy, apply_strategy @@ -24,24 +25,27 @@ class FSMContextMiddleware(BaseMiddleware[Update]): event: Update, data: Dict[str, Any], ) -> Any: - context = self.resolve_event_context(data) + bot: Bot = cast(Bot, data["bot"]) + context = self.resolve_event_context(bot, data) data["fsm_storage"] = self.storage if context: data.update({"state": context, "raw_state": await context.get_state()}) - if self.isolate_events: - async with self.storage.lock(): - return await handler(event, data) + if self.isolate_events: + async with self.storage.lock( + bot=bot, chat_id=context.chat_id, user_id=context.user_id + ): + return await handler(event, data) return await handler(event, data) - def resolve_event_context(self, data: Dict[str, Any]) -> Optional[FSMContext]: + def resolve_event_context(self, bot: Bot, data: Dict[str, Any]) -> Optional[FSMContext]: user = data.get("event_from_user") chat = data.get("event_chat") chat_id = chat.id if chat else None user_id = user.id if user else None - return self.resolve_context(chat_id=chat_id, user_id=user_id) + return self.resolve_context(bot=bot, chat_id=chat_id, user_id=user_id) def resolve_context( - self, chat_id: Optional[int], user_id: Optional[int] + self, bot: Bot, chat_id: Optional[int], user_id: Optional[int] ) -> Optional[FSMContext]: if chat_id is None: chat_id = user_id @@ -50,8 +54,8 @@ class FSMContextMiddleware(BaseMiddleware[Update]): chat_id, user_id = apply_strategy( chat_id=chat_id, user_id=user_id, strategy=self.strategy ) - return self.get_context(chat_id=chat_id, user_id=user_id) + return self.get_context(bot=bot, chat_id=chat_id, user_id=user_id) return None - def get_context(self, chat_id: int, user_id: int) -> FSMContext: - return FSMContext(storage=self.storage, chat_id=chat_id, user_id=user_id) + def get_context(self, bot: Bot, chat_id: int, user_id: int) -> FSMContext: + return FSMContext(bot=bot, storage=self.storage, chat_id=chat_id, user_id=user_id) diff --git a/aiogram/dispatcher/fsm/state.py b/aiogram/dispatcher/fsm/state.py index a04d5516..f33932e3 100644 --- a/aiogram/dispatcher/fsm/state.py +++ b/aiogram/dispatcher/fsm/state.py @@ -111,8 +111,8 @@ class StatesGroupMeta(type): return item in cls.__all_states_names__ if isinstance(item, State): return item in cls.__all_states__ - # if isinstance(item, StatesGroup): - # return item in cls.__all_childs__ + if isinstance(item, StatesGroupMeta): + return item in cls.__all_childs__ return False def __str__(self) -> str: @@ -129,8 +129,11 @@ class StatesGroup(metaclass=StatesGroupMeta): return cls return cls.__parent__.get_root() - # def __call__(cls, event: TelegramObject, raw_state: Optional[str] = None) -> bool: - # return raw_state in cls.__all_states_names__ + def __call__(cls, event: TelegramObject, raw_state: Optional[str] = None) -> bool: + return raw_state in type(cls).__all_states_names__ + + def __str__(self) -> str: + return f"StatesGroup {type(self).__full_group_name__}" default_state = State() diff --git a/aiogram/dispatcher/fsm/storage/base.py b/aiogram/dispatcher/fsm/storage/base.py index 36cebb31..42826915 100644 --- a/aiogram/dispatcher/fsm/storage/base.py +++ b/aiogram/dispatcher/fsm/storage/base.py @@ -2,6 +2,7 @@ from abc import ABC, abstractmethod from contextlib import asynccontextmanager from typing import Any, AsyncGenerator, Dict, Optional, Union +from aiogram import Bot from aiogram.dispatcher.fsm.state import State StateType = Optional[Union[str, State]] @@ -10,33 +11,43 @@ StateType = Optional[Union[str, State]] class BaseStorage(ABC): @abstractmethod @asynccontextmanager - async def lock(self) -> AsyncGenerator[None, None]: # pragma: no cover + async def lock( + self, bot: Bot, chat_id: int, user_id: int + ) -> AsyncGenerator[None, None]: # pragma: no cover yield None @abstractmethod async def set_state( - self, chat_id: int, user_id: int, state: StateType = None + self, bot: Bot, chat_id: int, user_id: int, state: StateType = None ) -> None: # pragma: no cover pass @abstractmethod - async def get_state(self, chat_id: int, user_id: int) -> Optional[str]: # pragma: no cover + async def get_state( + self, bot: Bot, chat_id: int, user_id: int + ) -> Optional[str]: # pragma: no cover pass @abstractmethod async def set_data( - self, chat_id: int, user_id: int, data: Dict[str, Any] + self, bot: Bot, chat_id: int, user_id: int, data: Dict[str, Any] ) -> None: # pragma: no cover pass @abstractmethod - async def get_data(self, chat_id: int, user_id: int) -> Dict[str, Any]: # pragma: no cover + async def get_data( + self, bot: Bot, chat_id: int, user_id: int + ) -> Dict[str, Any]: # pragma: no cover pass async def update_data( - self, chat_id: int, user_id: int, data: Dict[str, Any] + self, bot: Bot, chat_id: int, user_id: int, data: Dict[str, Any] ) -> Dict[str, Any]: - current_data = await self.get_data(chat_id=chat_id, user_id=user_id) + current_data = await self.get_data(bot=bot, chat_id=chat_id, user_id=user_id) current_data.update(data) - await self.set_data(chat_id=chat_id, user_id=user_id, data=current_data) + await self.set_data(bot=bot, chat_id=chat_id, user_id=user_id, data=current_data) return current_data.copy() + + @abstractmethod + async def close(self) -> None: # pragma: no cover + pass diff --git a/aiogram/dispatcher/fsm/storage/memory.py b/aiogram/dispatcher/fsm/storage/memory.py index 46b6d60b..3e82d306 100644 --- a/aiogram/dispatcher/fsm/storage/memory.py +++ b/aiogram/dispatcher/fsm/storage/memory.py @@ -4,6 +4,7 @@ from contextlib import asynccontextmanager from dataclasses import dataclass, field from typing import Any, AsyncGenerator, DefaultDict, Dict, Optional +from aiogram import Bot from aiogram.dispatcher.fsm.state import State from aiogram.dispatcher.fsm.storage.base import BaseStorage, StateType @@ -12,28 +13,35 @@ from aiogram.dispatcher.fsm.storage.base import BaseStorage, StateType class MemoryStorageRecord: data: Dict[str, Any] = field(default_factory=dict) state: Optional[str] = None + lock: Lock = field(default_factory=Lock) class MemoryStorage(BaseStorage): def __init__(self) -> None: - self.storage: DefaultDict[int, DefaultDict[int, MemoryStorageRecord]] = defaultdict( - lambda: defaultdict(MemoryStorageRecord) - ) - self._lock = Lock() + self.storage: DefaultDict[ + Bot, DefaultDict[int, DefaultDict[int, MemoryStorageRecord]] + ] = defaultdict(lambda: defaultdict(lambda: defaultdict(MemoryStorageRecord))) + + async def close(self) -> None: + pass @asynccontextmanager - async def lock(self) -> AsyncGenerator[None, None]: - async with self._lock: + async def lock(self, bot: Bot, chat_id: int, user_id: int) -> AsyncGenerator[None, None]: + async with self.storage[bot][chat_id][user_id].lock: yield None - async def set_state(self, chat_id: int, user_id: int, state: StateType = None) -> None: - self.storage[chat_id][user_id].state = state.state if isinstance(state, State) else state + async def set_state( + self, bot: Bot, chat_id: int, user_id: int, state: StateType = None + ) -> None: + self.storage[bot][chat_id][user_id].state = ( + state.state if isinstance(state, State) else state + ) - async def get_state(self, chat_id: int, user_id: int) -> Optional[str]: - return self.storage[chat_id][user_id].state + async def get_state(self, bot: Bot, chat_id: int, user_id: int) -> Optional[str]: + return self.storage[bot][chat_id][user_id].state - async def set_data(self, chat_id: int, user_id: int, data: Dict[str, Any]) -> None: - self.storage[chat_id][user_id].data = data.copy() + async def set_data(self, bot: Bot, chat_id: int, user_id: int, data: Dict[str, Any]) -> None: + self.storage[bot][chat_id][user_id].data = data.copy() - async def get_data(self, chat_id: int, user_id: int) -> Dict[str, Any]: - return self.storage[chat_id][user_id].data.copy() + async def get_data(self, bot: Bot, chat_id: int, user_id: int) -> Dict[str, Any]: + return self.storage[bot][chat_id][user_id].data.copy() diff --git a/aiogram/dispatcher/fsm/storage/redis.py b/aiogram/dispatcher/fsm/storage/redis.py new file mode 100644 index 00000000..145aae22 --- /dev/null +++ b/aiogram/dispatcher/fsm/storage/redis.py @@ -0,0 +1,129 @@ +from contextlib import asynccontextmanager +from typing import Any, AsyncGenerator, Callable, Dict, Optional, Union, cast + +from aioredis import ConnectionPool, Redis + +from aiogram import Bot +from aiogram.dispatcher.fsm.state import State +from aiogram.dispatcher.fsm.storage.base import BaseStorage, StateType + +PrefixFactoryType = Callable[[Bot], str] +STATE_KEY = "state" +STATE_DATA_KEY = "data" +STATE_LOCK_KEY = "lock" + +DEFAULT_REDIS_LOCK_KWARGS = {"timeout": 60} + + +class RedisStorage(BaseStorage): + def __init__( + self, + redis: Redis, + prefix: str = "fsm", + prefix_bot: Union[bool, PrefixFactoryType, Dict[int, str]] = False, + state_ttl: Optional[int] = None, + data_ttl: Optional[int] = None, + lock_kwargs: Optional[Dict[str, Any]] = None, + ) -> None: + if lock_kwargs is None: + lock_kwargs = DEFAULT_REDIS_LOCK_KWARGS + self.redis = redis + self.prefix = prefix + self.prefix_bot = prefix_bot + self.state_ttl = state_ttl + self.data_ttl = data_ttl + self.lock_kwargs = lock_kwargs + + @classmethod + def from_url( + cls, url: str, connection_kwargs: Optional[Dict[str, Any]] = None, **kwargs: Any + ) -> "RedisStorage": + if connection_kwargs is None: + connection_kwargs = {} + pool = ConnectionPool.from_url(url, **connection_kwargs) + redis = Redis(connection_pool=pool) + return cls(redis=redis, **kwargs) + + async def close(self) -> None: + await self.redis.close() # type: ignore + + def generate_key(self, bot: Bot, *parts: Any) -> str: + prefix_parts = [self.prefix] + if self.prefix_bot: + if isinstance(self.prefix_bot, dict): + prefix_parts.append(self.prefix_bot[bot.id]) + elif callable(self.prefix_bot): + prefix_parts.append(self.prefix_bot(bot)) + else: + prefix_parts.append(str(bot.id)) + prefix_parts.extend(parts) + return ":".join(map(str, prefix_parts)) + + @asynccontextmanager + async def lock( + self, bot: Bot, chat_id: int, user_id: int, state_lock_key: str = STATE_LOCK_KEY + ) -> AsyncGenerator[None, None]: + key = self.generate_key(bot, chat_id, user_id, state_lock_key) + async with self.redis.lock(name=key, **self.lock_kwargs): + yield None + + async def set_state( + self, + bot: Bot, + chat_id: int, + user_id: int, + state: StateType = None, + state_key: str = STATE_KEY, + ) -> None: + key = self.generate_key(bot, chat_id, user_id, state_key) + if state is None: + await self.redis.delete(key) + else: + await self.redis.set( + key, + state.state if isinstance(state, State) else state, # type: ignore[arg-type] + ex=self.state_ttl, # type: ignore[arg-type] + ) + + async def get_state( + self, + bot: Bot, + chat_id: int, + user_id: int, + state_key: str = STATE_KEY, + ) -> Optional[str]: + key = self.generate_key(bot, chat_id, user_id, state_key) + value = await self.redis.get(key) + if isinstance(value, bytes): + return value.decode("utf-8") + return cast(Optional[str], value) + + async def set_data( + self, + bot: Bot, + chat_id: int, + user_id: int, + data: Dict[str, Any], + state_data_key: str = STATE_DATA_KEY, + ) -> None: + key = self.generate_key(bot, chat_id, user_id, state_data_key) + if not data: + await self.redis.delete(key) + return + json_data = bot.session.json_dumps(data) + await self.redis.set(key, json_data, ex=self.data_ttl) # type: ignore[arg-type] + + async def get_data( + self, + bot: Bot, + chat_id: int, + user_id: int, + state_data_key: str = STATE_DATA_KEY, + ) -> Dict[str, Any]: + key = self.generate_key(bot, chat_id, user_id, state_data_key) + value = await self.redis.get(key) + if value is None: + return {} + if isinstance(value, bytes): + value = value.decode("utf-8") + return cast(Dict[str, Any], bot.session.json_loads(value)) diff --git a/aiogram/dispatcher/router.py b/aiogram/dispatcher/router.py index 2e659c7e..b776bcdf 100644 --- a/aiogram/dispatcher/router.py +++ b/aiogram/dispatcher/router.py @@ -3,8 +3,10 @@ from __future__ import annotations import warnings from typing import Any, Dict, Generator, List, Optional, Union +from ..types import TelegramObject from ..utils.imports import import_module from ..utils.warnings import CodeHasNoEffect +from .event.bases import REJECTED, UNHANDLED from .event.event import EventObserver from .event.telegram import TelegramEventObserver from .filters import BUILTIN_FILTERS @@ -19,16 +21,17 @@ class Router: - By observer method - :obj:`router..register(handler, )` - By decorator - :obj:`@router.()` - """ - def __init__(self, use_builtin_filters: bool = True) -> None: + def __init__(self, use_builtin_filters: bool = True, name: Optional[str] = None) -> None: """ :param use_builtin_filters: `aiogram` has many builtin filters and you can controll automatic registration of this filters in factory + :param name: Optional router name, can be useful for debugging """ self.use_builtin_filters = use_builtin_filters + self.name = name or hex(id(self)) self._parent_router: Optional[Router] = None self.sub_routers: List[Router] = [] @@ -82,6 +85,43 @@ class Router: for builtin_filter in BUILTIN_FILTERS.get(name, ()): observer.bind_filter(builtin_filter) + def __str__(self) -> str: + return f"{type(self).__name__} {self.name!r}" + + def __repr__(self) -> str: + return f"<{self}>" + + async def propagate_event(self, update_type: str, event: TelegramObject, **kwargs: Any) -> Any: + kwargs.update(event_router=self) + observer = self.observers[update_type] + + async def _wrapped(telegram_event: TelegramObject, **data: Any) -> Any: + return await self._propagate_event( + observer=observer, update_type=update_type, event=telegram_event, **data + ) + + return await observer.wrap_outer_middleware(_wrapped, event=event, data=kwargs) + + async def _propagate_event( + self, + observer: TelegramEventObserver, + update_type: str, + event: TelegramObject, + **kwargs: Any, + ) -> Any: + response = await observer.trigger(event, **kwargs) + if response is REJECTED: + return UNHANDLED + if response is not UNHANDLED: + return response + + for router in self.sub_routers: + response = await router.propagate_event(update_type=update_type, event=event, **kwargs) + if response is not UNHANDLED: + break + + return response + @property def chain_head(self) -> Generator[Router, None, None]: router: Optional[Router] = self @@ -118,9 +158,7 @@ class Router: :param router: """ if not isinstance(router, Router): - raise ValueError( - f"router should be instance of Router not {type(router).__class__.__name__}" - ) + raise ValueError(f"router should be instance of Router not {type(router).__name__!r}") if self._parent_router: raise RuntimeError(f"Router is already attached to {self._parent_router!r}") if self == router: @@ -133,7 +171,7 @@ class Router: if not self.use_builtin_filters and parent.use_builtin_filters: warnings.warn( - f"{self.__class__.__name__}(use_builtin_filters=False) has no effect" + f"{type(self).__name__}(use_builtin_filters=False) has no effect" f" for router {self} in due to builtin filters is already registered" f" in parent router", CodeHasNoEffect, diff --git a/aiogram/methods/__init__.py b/aiogram/methods/__init__.py index df602bef..54b59e74 100644 --- a/aiogram/methods/__init__.py +++ b/aiogram/methods/__init__.py @@ -3,6 +3,7 @@ from .answer_callback_query import AnswerCallbackQuery from .answer_inline_query import AnswerInlineQuery from .answer_pre_checkout_query import AnswerPreCheckoutQuery from .answer_shipping_query import AnswerShippingQuery +from .ban_chat_member import BanChatMember from .base import Request, Response, TelegramMethod from .close import Close from .copy_message import CopyMessage @@ -11,6 +12,7 @@ from .create_new_sticker_set import CreateNewStickerSet from .delete_chat_photo import DeleteChatPhoto from .delete_chat_sticker_set import DeleteChatStickerSet from .delete_message import DeleteMessage +from .delete_my_commands import DeleteMyCommands from .delete_sticker_from_set import DeleteStickerFromSet from .delete_webhook import DeleteWebhook from .edit_chat_invite_link import EditChatInviteLink @@ -24,6 +26,7 @@ from .forward_message import ForwardMessage from .get_chat import GetChat from .get_chat_administrators import GetChatAdministrators from .get_chat_member import GetChatMember +from .get_chat_member_count import GetChatMemberCount from .get_chat_members_count import GetChatMembersCount from .get_file import GetFile from .get_game_high_scores import GetGameHighScores @@ -109,6 +112,7 @@ __all__ = ( "SendChatAction", "GetUserProfilePhotos", "GetFile", + "BanChatMember", "KickChatMember", "UnbanChatMember", "RestrictChatMember", @@ -129,12 +133,14 @@ __all__ = ( "LeaveChat", "GetChat", "GetChatAdministrators", + "GetChatMemberCount", "GetChatMembersCount", "GetChatMember", "SetChatStickerSet", "DeleteChatStickerSet", "AnswerCallbackQuery", "SetMyCommands", + "DeleteMyCommands", "GetMyCommands", "EditMessageText", "EditMessageCaption", diff --git a/aiogram/methods/answer_inline_query.py b/aiogram/methods/answer_inline_query.py index 95c9b66d..78511403 100644 --- a/aiogram/methods/answer_inline_query.py +++ b/aiogram/methods/answer_inline_query.py @@ -37,5 +37,12 @@ class AnswerInlineQuery(TelegramMethod[bool]): def build_request(self, bot: Bot) -> Request: data: Dict[str, Any] = self.dict() - prepare_parse_mode(bot, data["results"]) + + input_message_contents = [] + for result in data["results"]: + input_message_content = result.get("input_message_content", None) + if input_message_content is not None: + input_message_contents.append(input_message_content) + + prepare_parse_mode(bot, data["results"] + input_message_contents) return Request(method="answerInlineQuery", data=data) diff --git a/aiogram/methods/ban_chat_member.py b/aiogram/methods/ban_chat_member.py new file mode 100644 index 00000000..457ca68b --- /dev/null +++ b/aiogram/methods/ban_chat_member.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import datetime +from typing import TYPE_CHECKING, Any, Dict, Optional, Union + +from .base import Request, TelegramMethod + +if TYPE_CHECKING: # pragma: no cover + from ..client.bot import Bot + + +class BanChatMember(TelegramMethod[bool]): + """ + Use this method to ban a user in a group, a supergroup or a channel. In the case of supergroups and channels, the user will not be able to return to the chat on their own using invite links, etc., unless `unbanned `_ first. The bot must be an administrator in the chat for this to work and must have the appropriate admin rights. Returns :code:`True` on success. + + Source: https://core.telegram.org/bots/api#banchatmember + """ + + __returning__ = bool + + chat_id: Union[int, str] + """Unique identifier for the target group or username of the target supergroup or channel (in the format :code:`@channelusername`)""" + user_id: int + """Unique identifier of the target user""" + until_date: Optional[Union[datetime.datetime, datetime.timedelta, int]] = None + """Date when the user will be unbanned, unix time. If user is banned for more than 366 days or less than 30 seconds from the current time they are considered to be banned forever. Applied for supergroups and channels only.""" + revoke_messages: Optional[bool] = None + """Pass :code:`True` to delete all messages from the chat for the user that is being removed. If :code:`False`, the user will be able to see messages in the group that were sent before the user was removed. Always :code:`True` for supergroups and channels.""" + + def build_request(self, bot: Bot) -> Request: + data: Dict[str, Any] = self.dict() + + return Request(method="banChatMember", data=data) diff --git a/aiogram/methods/base.py b/aiogram/methods/base.py index ce73a35b..334beac0 100644 --- a/aiogram/methods/base.py +++ b/aiogram/methods/base.py @@ -12,7 +12,7 @@ from ..types import UNSET, InputFile, ResponseParameters if TYPE_CHECKING: # pragma: no cover from ..client.bot import Bot -T = TypeVar("T") +TelegramType = TypeVar("TelegramType", bound=Any) class Request(BaseModel): @@ -31,14 +31,15 @@ class Request(BaseModel): } -class Response(ResponseParameters, GenericModel, Generic[T]): +class Response(GenericModel, Generic[TelegramType]): ok: bool - result: Optional[T] = None + result: Optional[TelegramType] = None description: Optional[str] = None error_code: Optional[int] = None + parameters: Optional[ResponseParameters] = None -class TelegramMethod(abc.ABC, BaseModel, Generic[T]): +class TelegramMethod(abc.ABC, BaseModel, Generic[TelegramType]): class Config(BaseConfig): # use_enum_values = True extra = Extra.allow @@ -76,14 +77,14 @@ class TelegramMethod(abc.ABC, BaseModel, Generic[T]): return super().dict(exclude=exclude, **kwargs) - def build_response(self, data: Dict[str, Any]) -> Response[T]: + def build_response(self, data: Dict[str, Any]) -> Response[TelegramType]: # noinspection PyTypeChecker return Response[self.__returning__](**data) # type: ignore - async def emit(self, bot: Bot) -> T: + async def emit(self, bot: Bot) -> TelegramType: return await bot(self) - def __await__(self) -> Generator[Any, None, T]: + def __await__(self) -> Generator[Any, None, TelegramType]: from aiogram.client.bot import Bot bot = Bot.get_current(no_error=False) diff --git a/aiogram/methods/delete_my_commands.py b/aiogram/methods/delete_my_commands.py new file mode 100644 index 00000000..b0b9ebaa --- /dev/null +++ b/aiogram/methods/delete_my_commands.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict, Optional + +from ..types import BotCommandScope +from .base import Request, TelegramMethod + +if TYPE_CHECKING: # pragma: no cover + from ..client.bot import Bot + + +class DeleteMyCommands(TelegramMethod[bool]): + """ + Use this method to delete the list of the bot's commands for the given scope and user language. After deletion, `higher level commands `_ will be shown to affected users. Returns :code:`True` on success. + + Source: https://core.telegram.org/bots/api#deletemycommands + """ + + __returning__ = bool + + scope: Optional[BotCommandScope] = None + """A JSON-serialized object, describing scope of users for which the commands are relevant. Defaults to :class:`aiogram.types.bot_command_scope_default.BotCommandScopeDefault`.""" + language_code: Optional[str] = None + """A two-letter ISO 639-1 language code. If empty, commands will be applied to all users from the given scope, for whose language there are no dedicated commands""" + + def build_request(self, bot: Bot) -> Request: + data: Dict[str, Any] = self.dict() + + return Request(method="deleteMyCommands", data=data) diff --git a/aiogram/methods/get_chat_administrators.py b/aiogram/methods/get_chat_administrators.py index 961fb959..9ca31884 100644 --- a/aiogram/methods/get_chat_administrators.py +++ b/aiogram/methods/get_chat_administrators.py @@ -2,21 +2,50 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any, Dict, List, Union -from ..types import ChatMember +from ..types import ( + ChatMemberAdministrator, + ChatMemberBanned, + ChatMemberLeft, + ChatMemberMember, + ChatMemberOwner, + ChatMemberRestricted, +) from .base import Request, TelegramMethod if TYPE_CHECKING: # pragma: no cover from ..client.bot import Bot -class GetChatAdministrators(TelegramMethod[List[ChatMember]]): +class GetChatAdministrators( + TelegramMethod[ + List[ + Union[ + ChatMemberOwner, + ChatMemberAdministrator, + ChatMemberMember, + ChatMemberRestricted, + ChatMemberLeft, + ChatMemberBanned, + ] + ] + ] +): """ Use this method to get a list of administrators in a chat. On success, returns an Array of :class:`aiogram.types.chat_member.ChatMember` objects that contains information about all chat administrators except other bots. If the chat is a group or a supergroup and no administrators were appointed, only the creator will be returned. Source: https://core.telegram.org/bots/api#getchatadministrators """ - __returning__ = List[ChatMember] + __returning__ = List[ + Union[ + ChatMemberOwner, + ChatMemberAdministrator, + ChatMemberMember, + ChatMemberRestricted, + ChatMemberLeft, + ChatMemberBanned, + ] + ] chat_id: Union[int, str] """Unique identifier for the target chat or username of the target supergroup or channel (in the format :code:`@channelusername`)""" diff --git a/aiogram/methods/get_chat_member.py b/aiogram/methods/get_chat_member.py index cb21e1ff..60d508bf 100644 --- a/aiogram/methods/get_chat_member.py +++ b/aiogram/methods/get_chat_member.py @@ -2,21 +2,46 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any, Dict, Union -from ..types import ChatMember +from ..types import ( + ChatMemberAdministrator, + ChatMemberBanned, + ChatMemberLeft, + ChatMemberMember, + ChatMemberOwner, + ChatMemberRestricted, +) from .base import Request, TelegramMethod if TYPE_CHECKING: # pragma: no cover from ..client.bot import Bot -class GetChatMember(TelegramMethod[ChatMember]): +class GetChatMember( + TelegramMethod[ + Union[ + ChatMemberOwner, + ChatMemberAdministrator, + ChatMemberMember, + ChatMemberRestricted, + ChatMemberLeft, + ChatMemberBanned, + ] + ] +): """ Use this method to get information about a member of a chat. Returns a :class:`aiogram.types.chat_member.ChatMember` object on success. Source: https://core.telegram.org/bots/api#getchatmember """ - __returning__ = ChatMember + __returning__ = Union[ + ChatMemberOwner, + ChatMemberAdministrator, + ChatMemberMember, + ChatMemberRestricted, + ChatMemberLeft, + ChatMemberBanned, + ] chat_id: Union[int, str] """Unique identifier for the target chat or username of the target supergroup or channel (in the format :code:`@channelusername`)""" diff --git a/aiogram/methods/get_chat_member_count.py b/aiogram/methods/get_chat_member_count.py new file mode 100644 index 00000000..b6bd67a4 --- /dev/null +++ b/aiogram/methods/get_chat_member_count.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict, Union + +from .base import Request, TelegramMethod + +if TYPE_CHECKING: # pragma: no cover + from ..client.bot import Bot + + +class GetChatMemberCount(TelegramMethod[int]): + """ + Use this method to get the number of members in a chat. Returns *Int* on success. + + Source: https://core.telegram.org/bots/api#getchatmembercount + """ + + __returning__ = int + + chat_id: Union[int, str] + """Unique identifier for the target chat or username of the target supergroup or channel (in the format :code:`@channelusername`)""" + + def build_request(self, bot: Bot) -> Request: + data: Dict[str, Any] = self.dict() + + return Request(method="getChatMemberCount", data=data) diff --git a/aiogram/methods/get_chat_members_count.py b/aiogram/methods/get_chat_members_count.py index 2ec638f4..cec4929d 100644 --- a/aiogram/methods/get_chat_members_count.py +++ b/aiogram/methods/get_chat_members_count.py @@ -10,9 +10,13 @@ if TYPE_CHECKING: # pragma: no cover class GetChatMembersCount(TelegramMethod[int]): """ + .. warning: + + Renamed from :code:`getChatMembersCount` in 5.3 bot API version and can be removed in near future + Use this method to get the number of members in a chat. Returns *Int* on success. - Source: https://core.telegram.org/bots/api#getchatmemberscount + Source: https://core.telegram.org/bots/api#getchatmembercount """ __returning__ = int diff --git a/aiogram/methods/get_my_commands.py b/aiogram/methods/get_my_commands.py index 19adb9bd..2e4e683d 100644 --- a/aiogram/methods/get_my_commands.py +++ b/aiogram/methods/get_my_commands.py @@ -1,8 +1,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Dict, List +from typing import TYPE_CHECKING, Any, Dict, List, Optional -from ..types import BotCommand +from ..types import BotCommand, BotCommandScope from .base import Request, TelegramMethod if TYPE_CHECKING: # pragma: no cover @@ -11,13 +11,18 @@ if TYPE_CHECKING: # pragma: no cover class GetMyCommands(TelegramMethod[List[BotCommand]]): """ - Use this method to get the current list of the bot's commands. Requires no parameters. Returns Array of :class:`aiogram.types.bot_command.BotCommand` on success. + Use this method to get the current list of the bot's commands for the given scope and user language. Returns Array of :class:`aiogram.types.bot_command.BotCommand` on success. If commands aren't set, an empty list is returned. Source: https://core.telegram.org/bots/api#getmycommands """ __returning__ = List[BotCommand] + scope: Optional[BotCommandScope] = None + """A JSON-serialized object, describing scope of users. Defaults to :class:`aiogram.types.bot_command_scope_default.BotCommandScopeDefault`.""" + language_code: Optional[str] = None + """A two-letter ISO 639-1 language code or an empty string""" + def build_request(self, bot: Bot) -> Request: data: Dict[str, Any] = self.dict() diff --git a/aiogram/methods/kick_chat_member.py b/aiogram/methods/kick_chat_member.py index 11ed6bba..e854ff11 100644 --- a/aiogram/methods/kick_chat_member.py +++ b/aiogram/methods/kick_chat_member.py @@ -11,9 +11,13 @@ if TYPE_CHECKING: # pragma: no cover class KickChatMember(TelegramMethod[bool]): """ - Use this method to kick a user from a group, a supergroup or a channel. In the case of supergroups and channels, the user will not be able to return to the chat on their own using invite links, etc., unless `unbanned `_ first. The bot must be an administrator in the chat for this to work and must have the appropriate admin rights. Returns :code:`True` on success. + .. warning: - Source: https://core.telegram.org/bots/api#kickchatmember + Renamed from :code:`kickChatMember` in 5.3 bot API version and can be removed in near future + + Use this method to ban a user in a group, a supergroup or a channel. In the case of supergroups and channels, the user will not be able to return to the chat on their own using invite links, etc., unless `unbanned `_ first. The bot must be an administrator in the chat for this to work and must have the appropriate admin rights. Returns :code:`True` on success. + + Source: https://core.telegram.org/bots/api#banchatmember """ __returning__ = bool diff --git a/aiogram/methods/set_my_commands.py b/aiogram/methods/set_my_commands.py index a739b228..451ce509 100644 --- a/aiogram/methods/set_my_commands.py +++ b/aiogram/methods/set_my_commands.py @@ -1,8 +1,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Dict, List +from typing import TYPE_CHECKING, Any, Dict, List, Optional -from ..types import BotCommand +from ..types import BotCommand, BotCommandScope from .base import Request, TelegramMethod if TYPE_CHECKING: # pragma: no cover @@ -11,7 +11,7 @@ if TYPE_CHECKING: # pragma: no cover class SetMyCommands(TelegramMethod[bool]): """ - Use this method to change the list of the bot's commands. Returns :code:`True` on success. + Use this method to change the list of the bot's commands. See `https://core.telegram.org/bots#commands `_`https://core.telegram.org/bots#commands `_ for more details about bot commands. Returns :code:`True` on success. Source: https://core.telegram.org/bots/api#setmycommands """ @@ -20,6 +20,10 @@ class SetMyCommands(TelegramMethod[bool]): commands: List[BotCommand] """A JSON-serialized list of bot commands to be set as the list of the bot's commands. At most 100 commands can be specified.""" + scope: Optional[BotCommandScope] = None + """A JSON-serialized object, describing scope of users for which the commands are relevant. Defaults to :class:`aiogram.types.bot_command_scope_default.BotCommandScopeDefault`.""" + language_code: Optional[str] = None + """A two-letter ISO 639-1 language code. If empty, commands will be applied to all users from the given scope, for whose language there are no dedicated commands""" def build_request(self, bot: Bot) -> Request: data: Dict[str, Any] = self.dict() diff --git a/aiogram/methods/unban_chat_member.py b/aiogram/methods/unban_chat_member.py index cae62f8e..993335cf 100644 --- a/aiogram/methods/unban_chat_member.py +++ b/aiogram/methods/unban_chat_member.py @@ -10,7 +10,7 @@ if TYPE_CHECKING: # pragma: no cover class UnbanChatMember(TelegramMethod[bool]): """ - Use this method to unban a previously kicked user in a supergroup or channel. The user will **not** return to the group or channel automatically, but will be able to join via link, etc. The bot must be an administrator for this to work. By default, this method guarantees that after the call the user is not a member of the chat, but will be able to join it. So if the user is a member of the chat they will also be **removed** from the chat. If you don't want this, use the parameter *only_if_banned*. Returns :code:`True` on success. + Use this method to unban a previously banned user in a supergroup or channel. The user will **not** return to the group or channel automatically, but will be able to join via link, etc. The bot must be an administrator for this to work. By default, this method guarantees that after the call the user is not a member of the chat, but will be able to join it. So if the user is a member of the chat they will also be **removed** from the chat. If you don't want this, use the parameter *only_if_banned*. Returns :code:`True` on success. Source: https://core.telegram.org/bots/api#unbanchatmember """ diff --git a/aiogram/types/__init__.py b/aiogram/types/__init__.py index eb4c2c55..bfa7d6f8 100644 --- a/aiogram/types/__init__.py +++ b/aiogram/types/__init__.py @@ -2,12 +2,26 @@ from .animation import Animation from .audio import Audio from .base import UNSET, TelegramObject from .bot_command import BotCommand +from .bot_command_scope import BotCommandScope +from .bot_command_scope_all_chat_administrators import BotCommandScopeAllChatAdministrators +from .bot_command_scope_all_group_chats import BotCommandScopeAllGroupChats +from .bot_command_scope_all_private_chats import BotCommandScopeAllPrivateChats +from .bot_command_scope_chat import BotCommandScopeChat +from .bot_command_scope_chat_administrators import BotCommandScopeChatAdministrators +from .bot_command_scope_chat_member import BotCommandScopeChatMember +from .bot_command_scope_default import BotCommandScopeDefault from .callback_game import CallbackGame from .callback_query import CallbackQuery from .chat import Chat from .chat_invite_link import ChatInviteLink from .chat_location import ChatLocation from .chat_member import ChatMember +from .chat_member_administrator import ChatMemberAdministrator +from .chat_member_banned import ChatMemberBanned +from .chat_member_left import ChatMemberLeft +from .chat_member_member import ChatMemberMember +from .chat_member_owner import ChatMemberOwner +from .chat_member_restricted import ChatMemberRestricted from .chat_member_updated import ChatMemberUpdated from .chat_permissions import ChatPermissions from .chat_photo import ChatPhoto @@ -160,10 +174,24 @@ __all__ = ( "ChatPhoto", "ChatInviteLink", "ChatMember", + "ChatMemberOwner", + "ChatMemberAdministrator", + "ChatMemberMember", + "ChatMemberRestricted", + "ChatMemberLeft", + "ChatMemberBanned", "ChatMemberUpdated", "ChatPermissions", "ChatLocation", "BotCommand", + "BotCommandScope", + "BotCommandScopeDefault", + "BotCommandScopeAllPrivateChats", + "BotCommandScopeAllGroupChats", + "BotCommandScopeAllChatAdministrators", + "BotCommandScopeChat", + "BotCommandScopeChatAdministrators", + "BotCommandScopeChatMember", "ResponseParameters", "InputMedia", "InputMediaPhoto", diff --git a/aiogram/types/bot_command_scope.py b/aiogram/types/bot_command_scope.py new file mode 100644 index 00000000..dfb1d528 --- /dev/null +++ b/aiogram/types/bot_command_scope.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from .base import TelegramObject + + +class BotCommandScope(TelegramObject): + """ + This object represents the scope to which bot commands are applied. Currently, the following 7 scopes are supported: + + - :class:`aiogram.types.bot_command_scope_default.BotCommandScopeDefault` + - :class:`aiogram.types.bot_command_scope_all_private_chats.BotCommandScopeAllPrivateChats` + - :class:`aiogram.types.bot_command_scope_all_group_chats.BotCommandScopeAllGroupChats` + - :class:`aiogram.types.bot_command_scope_all_chat_administrators.BotCommandScopeAllChatAdministrators` + - :class:`aiogram.types.bot_command_scope_chat.BotCommandScopeChat` + - :class:`aiogram.types.bot_command_scope_chat_administrators.BotCommandScopeChatAdministrators` + - :class:`aiogram.types.bot_command_scope_chat_member.BotCommandScopeChatMember` + + Source: https://core.telegram.org/bots/api#botcommandscope + """ diff --git a/aiogram/types/bot_command_scope_all_chat_administrators.py b/aiogram/types/bot_command_scope_all_chat_administrators.py new file mode 100644 index 00000000..25a35cbf --- /dev/null +++ b/aiogram/types/bot_command_scope_all_chat_administrators.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from pydantic import Field + +from .bot_command_scope import BotCommandScope + + +class BotCommandScopeAllChatAdministrators(BotCommandScope): + """ + Represents the `scope `_ of bot commands, covering all group and supergroup chat administrators. + + Source: https://core.telegram.org/bots/api#botcommandscopeallchatadministrators + """ + + type: str = Field("all_chat_administrators", const=True) + """Scope type, must be *all_chat_administrators*""" diff --git a/aiogram/types/bot_command_scope_all_group_chats.py b/aiogram/types/bot_command_scope_all_group_chats.py new file mode 100644 index 00000000..00e2984e --- /dev/null +++ b/aiogram/types/bot_command_scope_all_group_chats.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from pydantic import Field + +from .bot_command_scope import BotCommandScope + + +class BotCommandScopeAllGroupChats(BotCommandScope): + """ + Represents the `scope `_ of bot commands, covering all group and supergroup chats. + + Source: https://core.telegram.org/bots/api#botcommandscopeallgroupchats + """ + + type: str = Field("all_group_chats", const=True) + """Scope type, must be *all_group_chats*""" diff --git a/aiogram/types/bot_command_scope_all_private_chats.py b/aiogram/types/bot_command_scope_all_private_chats.py new file mode 100644 index 00000000..debc3baf --- /dev/null +++ b/aiogram/types/bot_command_scope_all_private_chats.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from pydantic import Field + +from .bot_command_scope import BotCommandScope + + +class BotCommandScopeAllPrivateChats(BotCommandScope): + """ + Represents the `scope `_ of bot commands, covering all private chats. + + Source: https://core.telegram.org/bots/api#botcommandscopeallprivatechats + """ + + type: str = Field("all_private_chats", const=True) + """Scope type, must be *all_private_chats*""" diff --git a/aiogram/types/bot_command_scope_chat.py b/aiogram/types/bot_command_scope_chat.py new file mode 100644 index 00000000..5d89c046 --- /dev/null +++ b/aiogram/types/bot_command_scope_chat.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from typing import Union + +from pydantic import Field + +from .bot_command_scope import BotCommandScope + + +class BotCommandScopeChat(BotCommandScope): + """ + Represents the `scope `_ of bot commands, covering a specific chat. + + Source: https://core.telegram.org/bots/api#botcommandscopechat + """ + + type: str = Field("chat", const=True) + """Scope type, must be *chat*""" + chat_id: Union[int, str] + """Unique identifier for the target chat or username of the target supergroup (in the format :code:`@supergroupusername`)""" diff --git a/aiogram/types/bot_command_scope_chat_administrators.py b/aiogram/types/bot_command_scope_chat_administrators.py new file mode 100644 index 00000000..152eab13 --- /dev/null +++ b/aiogram/types/bot_command_scope_chat_administrators.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from typing import Union + +from pydantic import Field + +from .bot_command_scope import BotCommandScope + + +class BotCommandScopeChatAdministrators(BotCommandScope): + """ + Represents the `scope `_ of bot commands, covering all administrators of a specific group or supergroup chat. + + Source: https://core.telegram.org/bots/api#botcommandscopechatadministrators + """ + + type: str = Field("chat_administrators", const=True) + """Scope type, must be *chat_administrators*""" + chat_id: Union[int, str] + """Unique identifier for the target chat or username of the target supergroup (in the format :code:`@supergroupusername`)""" diff --git a/aiogram/types/bot_command_scope_chat_member.py b/aiogram/types/bot_command_scope_chat_member.py new file mode 100644 index 00000000..e69ff642 --- /dev/null +++ b/aiogram/types/bot_command_scope_chat_member.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from typing import Union + +from pydantic import Field + +from .bot_command_scope import BotCommandScope + + +class BotCommandScopeChatMember(BotCommandScope): + """ + Represents the `scope `_ of bot commands, covering a specific member of a group or supergroup chat. + + Source: https://core.telegram.org/bots/api#botcommandscopechatmember + """ + + type: str = Field("chat_member", const=True) + """Scope type, must be *chat_member*""" + chat_id: Union[int, str] + """Unique identifier for the target chat or username of the target supergroup (in the format :code:`@supergroupusername`)""" + user_id: int + """Unique identifier of the target user""" diff --git a/aiogram/types/bot_command_scope_default.py b/aiogram/types/bot_command_scope_default.py new file mode 100644 index 00000000..8cf1a1d5 --- /dev/null +++ b/aiogram/types/bot_command_scope_default.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from pydantic import Field + +from .bot_command_scope import BotCommandScope + + +class BotCommandScopeDefault(BotCommandScope): + """ + Represents the default `scope `_ of bot commands. Default commands are used if no commands with a `narrower scope `_ are specified for the user. + + Source: https://core.telegram.org/bots/api#botcommandscopedefault + """ + + type: str = Field("default", const=True) + """Scope type, must be *default*""" diff --git a/aiogram/types/chat.py b/aiogram/types/chat.py index 7d735ac3..4c0db8c9 100644 --- a/aiogram/types/chat.py +++ b/aiogram/types/chat.py @@ -54,3 +54,19 @@ class Chat(TelegramObject): """*Optional*. Unique identifier for the linked chat, i.e. the discussion group identifier for a channel and vice versa; for supergroups and channel chats. This identifier may be greater than 32 bits and some programming languages may have difficulty/silent defects in interpreting it. But it is smaller than 52 bits, so a signed 64 bit integer or double-precision float type are safe for storing this identifier. Returned only in :class:`aiogram.methods.get_chat.GetChat`.""" location: Optional[ChatLocation] = None """*Optional*. For supergroups, the location to which the supergroup is connected. Returned only in :class:`aiogram.methods.get_chat.GetChat`.""" + + @property + def shifted_id(self) -> int: + """ + Returns shifted chat ID (positive and without "-100" prefix). + Mostly used for private links like t.me/c/chat_id/message_id + + Currently supergroup/channel IDs have 10-digit ID after "-100" prefix removed. + However, these IDs might become 11-digit in future. So, first we remove "-100" + prefix and count remaining number length. Then we multiple + -1 * 10 ^ (number_length + 2) + Finally, self.id is substracted from that number + """ + short_id = str(self.id).replace("-100", "") + shift = int(-1 * pow(10, len(short_id) + 2)) + return shift - self.id diff --git a/aiogram/types/chat_member.py b/aiogram/types/chat_member.py index 4a1abc8b..018bebda 100644 --- a/aiogram/types/chat_member.py +++ b/aiogram/types/chat_member.py @@ -1,87 +1,18 @@ from __future__ import annotations -import datetime -from typing import TYPE_CHECKING, Optional, Union - -from aiogram.utils import helper - from .base import TelegramObject -if TYPE_CHECKING: # pragma: no cover - from .user import User - class ChatMember(TelegramObject): """ - This object contains information about one member of a chat. + This object contains information about one member of a chat. Currently, the following 6 types of chat members are supported: + + - :class:`aiogram.types.chat_member_owner.ChatMemberOwner` + - :class:`aiogram.types.chat_member_administrator.ChatMemberAdministrator` + - :class:`aiogram.types.chat_member_member.ChatMemberMember` + - :class:`aiogram.types.chat_member_restricted.ChatMemberRestricted` + - :class:`aiogram.types.chat_member_left.ChatMemberLeft` + - :class:`aiogram.types.chat_member_banned.ChatMemberBanned` Source: https://core.telegram.org/bots/api#chatmember """ - - user: User - """Information about the user""" - status: str - """The member's status in the chat. Can be 'creator', 'administrator', 'member', 'restricted', 'left' or 'kicked'""" - custom_title: Optional[str] = None - """*Optional*. Owner and administrators only. Custom title for this user""" - is_anonymous: Optional[bool] = None - """*Optional*. Owner and administrators only. True, if the user's presence in the chat is hidden""" - can_be_edited: Optional[bool] = None - """*Optional*. Administrators only. True, if the bot is allowed to edit administrator privileges of that user""" - can_manage_chat: Optional[bool] = None - """*Optional*. Administrators only. True, if the administrator can access the chat event log, chat statistics, message statistics in channels, see channel members, see anonymous administrators in supergroups and ignore slow mode. Implied by any other administrator privilege""" - can_post_messages: Optional[bool] = None - """*Optional*. Administrators only. True, if the administrator can post in the channel; channels only""" - can_edit_messages: Optional[bool] = None - """*Optional*. Administrators only. True, if the administrator can edit messages of other users and can pin messages; channels only""" - can_delete_messages: Optional[bool] = None - """*Optional*. Administrators only. True, if the administrator can delete messages of other users""" - can_manage_voice_chats: Optional[bool] = None - """*Optional*. Administrators only. True, if the administrator can manage voice chats""" - can_restrict_members: Optional[bool] = None - """*Optional*. Administrators only. True, if the administrator can restrict, ban or unban chat members""" - can_promote_members: Optional[bool] = None - """*Optional*. Administrators only. True, if the administrator can add new administrators with a subset of their own privileges or demote administrators that he has promoted, directly or indirectly (promoted by administrators that were appointed by the user)""" - can_change_info: Optional[bool] = None - """*Optional*. Administrators and restricted only. True, if the user is allowed to change the chat title, photo and other settings""" - can_invite_users: Optional[bool] = None - """*Optional*. Administrators and restricted only. True, if the user is allowed to invite new users to the chat""" - can_pin_messages: Optional[bool] = None - """*Optional*. Administrators and restricted only. True, if the user is allowed to pin messages; groups and supergroups only""" - is_member: Optional[bool] = None - """*Optional*. Restricted only. True, if the user is a member of the chat at the moment of the request""" - can_send_messages: Optional[bool] = None - """*Optional*. Restricted only. True, if the user is allowed to send text messages, contacts, locations and venues""" - can_send_media_messages: Optional[bool] = None - """*Optional*. Restricted only. True, if the user is allowed to send audios, documents, photos, videos, video notes and voice notes""" - can_send_polls: Optional[bool] = None - """*Optional*. Restricted only. True, if the user is allowed to send polls""" - can_send_other_messages: Optional[bool] = None - """*Optional*. Restricted only. True, if the user is allowed to send animations, games, stickers and use inline bots""" - can_add_web_page_previews: Optional[bool] = None - """*Optional*. Restricted only. True, if the user is allowed to add web page previews to their messages""" - until_date: Optional[Union[datetime.datetime, datetime.timedelta, int]] = None - """*Optional*. Restricted and kicked only. Date when restrictions will be lifted for this user; unix time""" - - @property - def is_chat_admin(self) -> bool: - return self.status in {ChatMemberStatus.CREATOR, ChatMemberStatus.ADMINISTRATOR} - - @property - def is_chat_member(self) -> bool: - return self.status not in {ChatMemberStatus.LEFT, ChatMemberStatus.KICKED} - - -class ChatMemberStatus(helper.Helper): - """ - Chat member status - """ - - mode = helper.HelperMode.lowercase - - CREATOR = helper.Item() # creator - ADMINISTRATOR = helper.Item() # administrator - MEMBER = helper.Item() # member - RESTRICTED = helper.Item() # restricted - LEFT = helper.Item() # left - KICKED = helper.Item() # kicked diff --git a/aiogram/types/chat_member_administrator.py b/aiogram/types/chat_member_administrator.py new file mode 100644 index 00000000..f25818c2 --- /dev/null +++ b/aiogram/types/chat_member_administrator.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from pydantic import Field + +from .chat_member import ChatMember + +if TYPE_CHECKING: # pragma: no cover + from .user import User + + +class ChatMemberAdministrator(ChatMember): + """ + Represents a `chat member `_ that has some additional privileges. + + Source: https://core.telegram.org/bots/api#chatmemberadministrator + """ + + status: str = Field("administrator", const=True) + """The member's status in the chat, always 'administrator'""" + user: User + """Information about the user""" + can_be_edited: bool + """True, if the bot is allowed to edit administrator privileges of that user""" + is_anonymous: bool + """True, if the user's presence in the chat is hidden""" + can_manage_chat: bool + """True, if the administrator can access the chat event log, chat statistics, message statistics in channels, see channel members, see anonymous administrators in supergroups and ignore slow mode. Implied by any other administrator privilege""" + can_delete_messages: bool + """True, if the administrator can delete messages of other users""" + can_manage_voice_chats: bool + """True, if the administrator can manage voice chats""" + can_restrict_members: bool + """True, if the administrator can restrict, ban or unban chat members""" + can_promote_members: bool + """True, if the administrator can add new administrators with a subset of their own privileges or demote administrators that he has promoted, directly or indirectly (promoted by administrators that were appointed by the user)""" + can_change_info: bool + """True, if the user is allowed to change the chat title, photo and other settings""" + can_invite_users: bool + """True, if the user is allowed to invite new users to the chat""" + can_post_messages: Optional[bool] = None + """*Optional*. True, if the administrator can post in the channel; channels only""" + can_edit_messages: Optional[bool] = None + """*Optional*. True, if the administrator can edit messages of other users and can pin messages; channels only""" + can_pin_messages: Optional[bool] = None + """*Optional*. True, if the user is allowed to pin messages; groups and supergroups only""" + custom_title: Optional[str] = None + """*Optional*. Custom title for this user""" diff --git a/aiogram/types/chat_member_banned.py b/aiogram/types/chat_member_banned.py new file mode 100644 index 00000000..3b828247 --- /dev/null +++ b/aiogram/types/chat_member_banned.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import datetime +from typing import TYPE_CHECKING, Union + +from pydantic import Field + +from .chat_member import ChatMember + +if TYPE_CHECKING: # pragma: no cover + from .user import User + + +class ChatMemberBanned(ChatMember): + """ + Represents a `chat member `_ that was banned in the chat and can't return to the chat or view chat messages. + + Source: https://core.telegram.org/bots/api#chatmemberbanned + """ + + status: str = Field("kicked", const=True) + """The member's status in the chat, always 'kicked'""" + user: User + """Information about the user""" + until_date: Union[datetime.datetime, datetime.timedelta, int] + """Date when restrictions will be lifted for this user; unix time""" diff --git a/aiogram/types/chat_member_left.py b/aiogram/types/chat_member_left.py new file mode 100644 index 00000000..d95cd320 --- /dev/null +++ b/aiogram/types/chat_member_left.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pydantic import Field + +from .chat_member import ChatMember + +if TYPE_CHECKING: # pragma: no cover + from .user import User + + +class ChatMemberLeft(ChatMember): + """ + Represents a `chat member `_ that isn't currently a member of the chat, but may join it themselves. + + Source: https://core.telegram.org/bots/api#chatmemberleft + """ + + status: str = Field("left", const=True) + """The member's status in the chat, always 'left'""" + user: User + """Information about the user""" diff --git a/aiogram/types/chat_member_member.py b/aiogram/types/chat_member_member.py new file mode 100644 index 00000000..2c55ea62 --- /dev/null +++ b/aiogram/types/chat_member_member.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pydantic import Field + +from .chat_member import ChatMember + +if TYPE_CHECKING: # pragma: no cover + from .user import User + + +class ChatMemberMember(ChatMember): + """ + Represents a `chat member `_ that has no additional privileges or restrictions. + + Source: https://core.telegram.org/bots/api#chatmembermember + """ + + status: str = Field("member", const=True) + """The member's status in the chat, always 'member'""" + user: User + """Information about the user""" diff --git a/aiogram/types/chat_member_owner.py b/aiogram/types/chat_member_owner.py new file mode 100644 index 00000000..dcc766a5 --- /dev/null +++ b/aiogram/types/chat_member_owner.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from pydantic import Field + +from .chat_member import ChatMember + +if TYPE_CHECKING: # pragma: no cover + from .user import User + + +class ChatMemberOwner(ChatMember): + """ + Represents a `chat member `_ that owns the chat and has all administrator privileges. + + Source: https://core.telegram.org/bots/api#chatmemberowner + """ + + status: str = Field("creator", const=True) + """The member's status in the chat, always 'creator'""" + user: User + """Information about the user""" + is_anonymous: bool + """True, if the user's presence in the chat is hidden""" + custom_title: Optional[str] = None + """*Optional*. Custom title for this user""" diff --git a/aiogram/types/chat_member_restricted.py b/aiogram/types/chat_member_restricted.py new file mode 100644 index 00000000..6860b8d0 --- /dev/null +++ b/aiogram/types/chat_member_restricted.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import datetime +from typing import TYPE_CHECKING, Union + +from pydantic import Field + +from .chat_member import ChatMember + +if TYPE_CHECKING: # pragma: no cover + from .user import User + + +class ChatMemberRestricted(ChatMember): + """ + Represents a `chat member `_ that is under certain restrictions in the chat. Supergroups only. + + Source: https://core.telegram.org/bots/api#chatmemberrestricted + """ + + status: str = Field("restricted", const=True) + """The member's status in the chat, always 'restricted'""" + user: User + """Information about the user""" + is_member: bool + """True, if the user is a member of the chat at the moment of the request""" + can_change_info: bool + """True, if the user is allowed to change the chat title, photo and other settings""" + can_invite_users: bool + """True, if the user is allowed to invite new users to the chat""" + can_pin_messages: bool + """True, if the user is allowed to pin messages; groups and supergroups only""" + can_send_messages: bool + """True, if the user is allowed to send text messages, contacts, locations and venues""" + can_send_media_messages: bool + """True, if the user is allowed to send audios, documents, photos, videos, video notes and voice notes""" + can_send_polls: bool + """True, if the user is allowed to send polls""" + can_send_other_messages: bool + """True, if the user is allowed to send animations, games, stickers and use inline bots""" + can_add_web_page_previews: bool + """True, if the user is allowed to add web page previews to their messages""" + until_date: Union[datetime.datetime, datetime.timedelta, int] + """Date when restrictions will be lifted for this user; unix time""" diff --git a/aiogram/types/chat_member_updated.py b/aiogram/types/chat_member_updated.py index e14a9e88..e7a2ce92 100644 --- a/aiogram/types/chat_member_updated.py +++ b/aiogram/types/chat_member_updated.py @@ -1,7 +1,7 @@ from __future__ import annotations import datetime -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, Union from pydantic import Field @@ -10,7 +10,12 @@ from .base import TelegramObject if TYPE_CHECKING: # pragma: no cover from .chat import Chat from .chat_invite_link import ChatInviteLink - from .chat_member import ChatMember + from .chat_member_administrator import ChatMemberAdministrator + from .chat_member_banned import ChatMemberBanned + from .chat_member_left import ChatMemberLeft + from .chat_member_member import ChatMemberMember + from .chat_member_owner import ChatMemberOwner + from .chat_member_restricted import ChatMemberRestricted from .user import User @@ -27,9 +32,23 @@ class ChatMemberUpdated(TelegramObject): """Performer of the action, which resulted in the change""" date: datetime.datetime """Date the change was done in Unix time""" - old_chat_member: ChatMember + old_chat_member: Union[ + ChatMemberOwner, + ChatMemberAdministrator, + ChatMemberMember, + ChatMemberRestricted, + ChatMemberLeft, + ChatMemberBanned, + ] """Previous information about the chat member""" - new_chat_member: ChatMember + new_chat_member: Union[ + ChatMemberOwner, + ChatMemberAdministrator, + ChatMemberMember, + ChatMemberRestricted, + ChatMemberLeft, + ChatMemberBanned, + ] """New information about the chat member""" invite_link: Optional[ChatInviteLink] = None """*Optional*. Chat invite link, which was used by the user to join the chat; for joining by invite link events only.""" diff --git a/aiogram/types/downloadable.py b/aiogram/types/downloadable.py index 48525f65..be808293 100644 --- a/aiogram/types/downloadable.py +++ b/aiogram/types/downloadable.py @@ -1,7 +1,4 @@ -try: - from typing import Protocol -except ImportError: # pragma: no cover - from typing_extensions import Protocol # type: ignore +from typing import Protocol class Downloadable(Protocol): diff --git a/aiogram/types/force_reply.py b/aiogram/types/force_reply.py index 9c29d130..b69ad94c 100644 --- a/aiogram/types/force_reply.py +++ b/aiogram/types/force_reply.py @@ -21,5 +21,7 @@ class ForceReply(MutableTelegramObject): force_reply: bool """Shows reply interface to the user, as if they manually selected the bot's message and tapped 'Reply'""" + input_field_placeholder: Optional[str] = None + """*Optional*. The placeholder to be shown in the input field when the reply is active; 1-64 characters""" selective: Optional[bool] = None """*Optional*. Use this parameter if you want to force reply from specific users only. Targets: 1) users that are @mentioned in the *text* of the :class:`aiogram.types.message.Message` object; 2) if the bot's message is a reply (has *reply_to_message_id*), sender of the original message.""" diff --git a/aiogram/types/message.py b/aiogram/types/message.py index d476c099..1f2fd996 100644 --- a/aiogram/types/message.py +++ b/aiogram/types/message.py @@ -12,6 +12,10 @@ from .base import UNSET, TelegramObject if TYPE_CHECKING: # pragma: no cover from ..methods import ( CopyMessage, + DeleteMessage, + EditMessageCaption, + EditMessageReplyMarkup, + EditMessageText, SendAnimation, SendAudio, SendContact, @@ -265,6 +269,7 @@ class Message(TelegramObject): caption: Optional[str] = None, parse_mode: Optional[str] = UNSET, disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, reply_markup: Optional[ Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None, @@ -280,6 +285,7 @@ class Message(TelegramObject): :param caption: :param parse_mode: :param disable_notification: + :param allow_sending_without_reply: :param reply_markup: :return: """ @@ -296,6 +302,7 @@ class Message(TelegramObject): parse_mode=parse_mode, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) @@ -353,6 +360,7 @@ class Message(TelegramObject): title: Optional[str] = None, thumb: Optional[Union[InputFile, str]] = None, disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, reply_markup: Optional[ Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None, @@ -368,6 +376,7 @@ class Message(TelegramObject): :param title: :param thumb: :param disable_notification: + :param allow_sending_without_reply: :param reply_markup: :return: """ @@ -384,6 +393,7 @@ class Message(TelegramObject): thumb=thumb, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) @@ -438,6 +448,7 @@ class Message(TelegramObject): last_name: Optional[str] = None, vcard: Optional[str] = None, disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, reply_markup: Optional[ Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None, @@ -450,6 +461,7 @@ class Message(TelegramObject): :param last_name: :param vcard: :param disable_notification: + :param allow_sending_without_reply: :param reply_markup: :return: """ @@ -463,6 +475,7 @@ class Message(TelegramObject): vcard=vcard, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) @@ -508,6 +521,7 @@ class Message(TelegramObject): caption: Optional[str] = None, parse_mode: Optional[str] = UNSET, disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, reply_markup: Optional[ Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None, @@ -520,6 +534,7 @@ class Message(TelegramObject): :param caption: :param parse_mode: :param disable_notification: + :param allow_sending_without_reply: :param reply_markup: :return: """ @@ -533,6 +548,7 @@ class Message(TelegramObject): parse_mode=parse_mode, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) @@ -575,6 +591,7 @@ class Message(TelegramObject): self, game_short_name: str, disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, reply_markup: Optional[InlineKeyboardMarkup] = None, ) -> SendGame: """ @@ -582,6 +599,7 @@ class Message(TelegramObject): :param game_short_name: :param disable_notification: + :param allow_sending_without_reply: :param reply_markup: :return: """ @@ -592,6 +610,7 @@ class Message(TelegramObject): game_short_name=game_short_name, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) @@ -641,6 +660,7 @@ class Message(TelegramObject): send_email_to_provider: Optional[bool] = None, is_flexible: Optional[bool] = None, disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, reply_markup: Optional[InlineKeyboardMarkup] = None, ) -> SendInvoice: """ @@ -666,6 +686,7 @@ class Message(TelegramObject): :param send_email_to_provider: :param is_flexible: :param disable_notification: + :param allow_sending_without_reply: :param reply_markup: :return: """ @@ -694,6 +715,7 @@ class Message(TelegramObject): is_flexible=is_flexible, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) @@ -781,6 +803,7 @@ class Message(TelegramObject): longitude: float, live_period: Optional[int] = None, disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, reply_markup: Optional[ Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None, @@ -792,6 +815,7 @@ class Message(TelegramObject): :param longitude: :param live_period: :param disable_notification: + :param allow_sending_without_reply: :param reply_markup: :return: """ @@ -804,6 +828,7 @@ class Message(TelegramObject): live_period=live_period, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) @@ -843,12 +868,14 @@ class Message(TelegramObject): self, media: List[Union[InputMediaPhoto, InputMediaVideo]], disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, ) -> SendMediaGroup: """ Reply with media group :param media: :param disable_notification: + :param allow_sending_without_reply: :return: """ from ..methods import SendMediaGroup @@ -858,6 +885,7 @@ class Message(TelegramObject): media=media, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, ) def answer_media_group( @@ -887,6 +915,7 @@ class Message(TelegramObject): parse_mode: Optional[str] = UNSET, disable_web_page_preview: Optional[bool] = None, disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, reply_markup: Optional[ Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None, @@ -898,6 +927,7 @@ class Message(TelegramObject): :param parse_mode: :param disable_web_page_preview: :param disable_notification: + :param allow_sending_without_reply: :param reply_markup: :return: """ @@ -910,6 +940,7 @@ class Message(TelegramObject): disable_web_page_preview=disable_web_page_preview, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) @@ -951,6 +982,7 @@ class Message(TelegramObject): caption: Optional[str] = None, parse_mode: Optional[str] = UNSET, disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, reply_markup: Optional[ Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None, @@ -962,6 +994,7 @@ class Message(TelegramObject): :param caption: :param parse_mode: :param disable_notification: + :param allow_sending_without_reply: :param reply_markup: :return: """ @@ -974,6 +1007,7 @@ class Message(TelegramObject): parse_mode=parse_mode, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) @@ -1023,6 +1057,7 @@ class Message(TelegramObject): close_date: Optional[Union[datetime.datetime, datetime.timedelta, int]] = None, is_closed: Optional[bool] = None, disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, reply_markup: Optional[ Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None, @@ -1042,6 +1077,7 @@ class Message(TelegramObject): :param close_date: :param is_closed: :param disable_notification: + :param allow_sending_without_reply: :param reply_markup: :return: """ @@ -1062,6 +1098,7 @@ class Message(TelegramObject): is_closed=is_closed, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) @@ -1125,6 +1162,7 @@ class Message(TelegramObject): self, emoji: Optional[str] = None, disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, reply_markup: Optional[ Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None, @@ -1134,6 +1172,7 @@ class Message(TelegramObject): :param emoji: :param disable_notification: + :param allow_sending_without_reply: :param reply_markup: :return: """ @@ -1144,6 +1183,7 @@ class Message(TelegramObject): emoji=emoji, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) @@ -1177,6 +1217,7 @@ class Message(TelegramObject): self, sticker: Union[InputFile, str], disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, reply_markup: Optional[ Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None, @@ -1186,6 +1227,7 @@ class Message(TelegramObject): :param sticker: :param disable_notification: + :param allow_sending_without_reply: :param reply_markup: :return: """ @@ -1196,6 +1238,7 @@ class Message(TelegramObject): sticker=sticker, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) @@ -1234,6 +1277,7 @@ class Message(TelegramObject): foursquare_id: Optional[str] = None, foursquare_type: Optional[str] = None, disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, reply_markup: Optional[ Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None, @@ -1248,6 +1292,7 @@ class Message(TelegramObject): :param foursquare_id: :param foursquare_type: :param disable_notification: + :param allow_sending_without_reply: :param reply_markup: :return: """ @@ -1263,6 +1308,7 @@ class Message(TelegramObject): foursquare_type=foursquare_type, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) @@ -1318,6 +1364,7 @@ class Message(TelegramObject): parse_mode: Optional[str] = UNSET, supports_streaming: Optional[bool] = None, disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, reply_markup: Optional[ Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None, @@ -1334,6 +1381,7 @@ class Message(TelegramObject): :param parse_mode: :param supports_streaming: :param disable_notification: + :param allow_sending_without_reply: :param reply_markup: :return: """ @@ -1351,6 +1399,7 @@ class Message(TelegramObject): supports_streaming=supports_streaming, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) @@ -1408,6 +1457,7 @@ class Message(TelegramObject): length: Optional[int] = None, thumb: Optional[Union[InputFile, str]] = None, disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, reply_markup: Optional[ Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None, @@ -1420,6 +1470,7 @@ class Message(TelegramObject): :param length: :param thumb: :param disable_notification: + :param allow_sending_without_reply: :param reply_markup: :return: """ @@ -1433,6 +1484,7 @@ class Message(TelegramObject): thumb=thumb, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) @@ -1478,6 +1530,7 @@ class Message(TelegramObject): parse_mode: Optional[str] = UNSET, duration: Optional[int] = None, disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, reply_markup: Optional[ Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None, @@ -1490,6 +1543,7 @@ class Message(TelegramObject): :param parse_mode: :param duration: :param disable_notification: + :param allow_sending_without_reply: :param reply_markup: :return: """ @@ -1503,6 +1557,7 @@ class Message(TelegramObject): duration=duration, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) @@ -1714,6 +1769,83 @@ class Message(TelegramObject): reply_markup=reply_markup, ) + def edit_text( + self, + text: str, + parse_mode: Optional[str] = UNSET, + entities: Optional[List[MessageEntity]] = None, + disable_web_page_preview: Optional[bool] = None, + reply_markup: Optional[InlineKeyboardMarkup] = None, + ) -> EditMessageText: + from ..methods import EditMessageText + + return EditMessageText( + chat_id=self.chat.id, + message_id=self.message_id, + text=text, + parse_mode=parse_mode, + entities=entities, + disable_web_page_preview=disable_web_page_preview, + reply_markup=reply_markup, + ) + + def edit_reply_markup( + self, + reply_markup: Optional[InlineKeyboardMarkup] = None, + ) -> EditMessageReplyMarkup: + from ..methods import EditMessageReplyMarkup + + return EditMessageReplyMarkup( + chat_id=self.chat.id, + message_id=self.message_id, + reply_markup=reply_markup, + ) + + def delete_reply_markup(self) -> EditMessageReplyMarkup: + return self.edit_reply_markup(reply_markup=None) + + def edit_caption( + self, + caption: str, + parse_mode: Optional[str] = UNSET, + caption_entities: Optional[List[MessageEntity]] = None, + reply_markup: Optional[InlineKeyboardMarkup] = None, + ) -> EditMessageCaption: + from ..methods import EditMessageCaption + + return EditMessageCaption( + chat_id=self.chat.id, + message_id=self.message_id, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, + reply_markup=reply_markup, + ) + + def delete(self) -> DeleteMessage: + from ..methods import DeleteMessage + + return DeleteMessage(chat_id=self.chat.id, message_id=self.message_id) + + def get_url(self, force_private: bool = False) -> Optional[str]: + """ + Returns message URL. Cannot be used in private (one-to-one) chats. + If chat has a username, returns URL like https://t.me/username/message_id + Otherwise (or if {force_private} flag is set), returns https://t.me/c/shifted_chat_id/message_id + + :param force_private: if set, a private URL is returned even for a public chat + :return: string with full message URL + """ + if self.chat.type in ("private", "group"): + return None + + if not self.chat.username or force_private: + chat_value = f"c/{self.chat.shifted_id}" + else: + chat_value = self.chat.username + + return f"https://t.me/{chat_value}/{self.message_id}" + class ContentType(helper.Helper): mode = helper.HelperMode.snake_case diff --git a/aiogram/types/message_auto_delete_timer_changed.py b/aiogram/types/message_auto_delete_timer_changed.py index ab62baab..ed5a251f 100644 --- a/aiogram/types/message_auto_delete_timer_changed.py +++ b/aiogram/types/message_auto_delete_timer_changed.py @@ -1,12 +1,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from .base import TelegramObject -if TYPE_CHECKING: # pragma: no cover - pass - class MessageAutoDeleteTimerChanged(TelegramObject): """ diff --git a/aiogram/types/reply_keyboard_markup.py b/aiogram/types/reply_keyboard_markup.py index 33c364d0..dfbd46ed 100644 --- a/aiogram/types/reply_keyboard_markup.py +++ b/aiogram/types/reply_keyboard_markup.py @@ -21,5 +21,7 @@ class ReplyKeyboardMarkup(MutableTelegramObject): """*Optional*. Requests clients to resize the keyboard vertically for optimal fit (e.g., make the keyboard smaller if there are just two rows of buttons). Defaults to *false*, in which case the custom keyboard is always of the same height as the app's standard keyboard.""" one_time_keyboard: Optional[bool] = None """*Optional*. Requests clients to hide the keyboard as soon as it's been used. The keyboard will still be available, but clients will automatically display the usual letter-keyboard in the chat – the user can press a special button in the input field to see the custom keyboard again. Defaults to *false*.""" + input_field_placeholder: Optional[str] = None + """*Optional*. The placeholder to be shown in the input field when the keyboard is active; 1-64 characters""" selective: Optional[bool] = None """*Optional*. Use this parameter if you want to show the keyboard to specific users only. Targets: 1) users that are @mentioned in the *text* of the :class:`aiogram.types.message.Message` object; 2) if the bot's message is a reply (has *reply_to_message_id*), sender of the original message.""" diff --git a/aiogram/types/voice_chat_ended.py b/aiogram/types/voice_chat_ended.py index cd8290d8..2b1ae161 100644 --- a/aiogram/types/voice_chat_ended.py +++ b/aiogram/types/voice_chat_ended.py @@ -1,12 +1,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from .base import TelegramObject -if TYPE_CHECKING: # pragma: no cover - pass - class VoiceChatEnded(TelegramObject): """ diff --git a/aiogram/types/voice_chat_scheduled.py b/aiogram/types/voice_chat_scheduled.py index 8aa8eb97..37c6c7bd 100644 --- a/aiogram/types/voice_chat_scheduled.py +++ b/aiogram/types/voice_chat_scheduled.py @@ -1,12 +1,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from .base import TelegramObject -if TYPE_CHECKING: # pragma: no cover - pass - class VoiceChatScheduled(TelegramObject): """ diff --git a/aiogram/types/voice_chat_started.py b/aiogram/types/voice_chat_started.py index 68b72cf3..6ad45263 100644 --- a/aiogram/types/voice_chat_started.py +++ b/aiogram/types/voice_chat_started.py @@ -1,12 +1,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from .base import TelegramObject -if TYPE_CHECKING: # pragma: no cover - pass - class VoiceChatStarted(TelegramObject): """ diff --git a/aiogram/utils/auth_widget.py b/aiogram/utils/auth_widget.py new file mode 100644 index 00000000..a67afe65 --- /dev/null +++ b/aiogram/utils/auth_widget.py @@ -0,0 +1,34 @@ +import hashlib +import hmac +from typing import Any, Dict + + +def check_signature(token: str, hash: str, **kwargs: Any) -> bool: + """ + Generate hexadecimal representation + of the HMAC-SHA-256 signature of the data-check-string + with the SHA256 hash of the bot's token used as a secret key + + :param token: + :param hash: + :param kwargs: all params received on auth + :return: + """ + secret = hashlib.sha256(token.encode("utf-8")) + check_string = "\n".join(map(lambda k: f"{k}={kwargs[k]}", sorted(kwargs))) + hmac_string = hmac.new( + secret.digest(), check_string.encode("utf-8"), digestmod=hashlib.sha256 + ).hexdigest() + return hmac_string == hash + + +def check_integrity(token: str, data: Dict[str, Any]) -> bool: + """ + Verify the authentication and the integrity + of the data received on user's auth + + :param token: Bot's token + :param data: all data that came on auth + :return: + """ + return check_signature(token, **data) diff --git a/aiogram/utils/backoff.py b/aiogram/utils/backoff.py new file mode 100644 index 00000000..f0b1b578 --- /dev/null +++ b/aiogram/utils/backoff.py @@ -0,0 +1,80 @@ +import asyncio +import time +from dataclasses import dataclass +from random import normalvariate + + +@dataclass(frozen=True) +class BackoffConfig: + min_delay: float + max_delay: float + factor: float + jitter: float + + def __post_init__(self) -> None: + if self.max_delay <= self.min_delay: + raise ValueError("`max_delay` should be greater than `min_delay`") + if self.factor <= 1: + raise ValueError("`factor` should be greater than 1") + + +class Backoff: + def __init__(self, config: BackoffConfig) -> None: + self.config = config + self._next_delay = config.min_delay + self._current_delay = 0.0 + self._counter = 0 + + def __iter__(self) -> "Backoff": + return self + + @property + def min_delay(self) -> float: + return self.config.min_delay + + @property + def max_delay(self) -> float: + return self.config.max_delay + + @property + def factor(self) -> float: + return self.config.factor + + @property + def jitter(self) -> float: + return self.config.jitter + + @property + def next_delay(self) -> float: + return self._next_delay + + @property + def current_delay(self) -> float: + return self._current_delay + + @property + def counter(self) -> int: + return self._counter + + def sleep(self) -> None: + time.sleep(next(self)) + + async def asleep(self) -> None: + await asyncio.sleep(next(self)) + + def _calculate_next(self, value: float) -> float: + return normalvariate(min(value * self.factor, self.max_delay), self.jitter) + + def __next__(self) -> float: + self._current_delay = self._next_delay + self._next_delay = self._calculate_next(self._next_delay) + self._counter += 1 + return self._current_delay + + def reset(self) -> None: + self._current_delay = 0.0 + self._counter = 0 + self._next_delay = self.min_delay + + def __str__(self) -> str: + return f"Backoff(tryings={self._counter}, current_delay={self._current_delay}, next_delay={self._next_delay})" diff --git a/aiogram/utils/deep_linking.py b/aiogram/utils/deep_linking.py new file mode 100644 index 00000000..caac2c26 --- /dev/null +++ b/aiogram/utils/deep_linking.py @@ -0,0 +1,131 @@ +""" +Deep linking + +Telegram bots have a deep linking mechanism, that allows for passing +additional parameters to the bot on startup. It could be a command that +launches the bot — or an auth token to connect the user's Telegram +account to their account on some external service. + +You can read detailed description in the source: +https://core.telegram.org/bots#deep-linking + +We have add some utils to get deep links more handy. + +Basic link example: + + .. code-block:: python + + from aiogram.utils.deep_linking import get_start_link + link = await get_start_link('foo') + + # result: 'https://t.me/MyBot?start=foo' + +Encoded link example: + + .. code-block:: python + + from aiogram.utils.deep_linking import get_start_link + + link = await get_start_link('foo', encode=True) + # result: 'https://t.me/MyBot?start=Zm9v' + +Decode it back example: + .. code-block:: python + + from aiogram.utils.deep_linking import decode_payload + from aiogram.types import Message + + @dp.message_handler(commands=["start"]) + async def handler(message: Message): + args = message.get_args() + payload = decode_payload(args) + await message.answer(f"Your payload: {payload}") + +""" +import re +from base64 import urlsafe_b64decode, urlsafe_b64encode +from typing import Literal, cast + +from aiogram import Bot +from aiogram.utils.link import create_telegram_link + +BAD_PATTERN = re.compile(r"[^_A-z0-9-]") + + +async def create_start_link(bot: Bot, payload: str, encode: bool = False) -> str: + """ + Create 'start' deep link with your payload. + + If you need to encode payload or pass special characters - + set encode as True + + :param bot: bot instance + :param payload: args passed with /start + :param encode: encode payload with base64url + :return: link + """ + username = (await bot.me()).username + return create_deep_link(username=username, link_type="start", payload=payload, encode=encode) + + +async def create_startgroup_link(bot: Bot, payload: str, encode: bool = False) -> str: + """ + Create 'startgroup' deep link with your payload. + + If you need to encode payload or pass special characters - + set encode as True + + :param bot: bot instance + :param payload: args passed with /start + :param encode: encode payload with base64url + :return: link + """ + username = (await bot.me()).username + return create_deep_link( + username=username, link_type="startgroup", payload=payload, encode=encode + ) + + +def create_deep_link( + username: str, link_type: Literal["start", "startgroup"], payload: str, encode: bool = False +) -> str: + """ + Create deep link. + + :param username: + :param link_type: `start` or `startgroup` + :param payload: any string-convertible data + :param encode: pass True to encode the payload + :return: deeplink + """ + if not isinstance(payload, str): + payload = str(payload) + + if encode: + payload = encode_payload(payload) + + if re.search(BAD_PATTERN, payload): + raise ValueError( + "Wrong payload! Only A-Z, a-z, 0-9, _ and - are allowed. " + "Pass `encode=True` or encode payload manually." + ) + + if len(payload) > 64: + raise ValueError("Payload must be up to 64 characters long.") + + return create_telegram_link(username, **{cast(str, link_type): payload}) + + +def encode_payload(payload: str) -> str: + """Encode payload with URL-safe base64url.""" + payload = str(payload) + bytes_payload: bytes = urlsafe_b64encode(payload.encode()) + str_payload = bytes_payload.decode() + return str_payload.replace("=", "") + + +def decode_payload(payload: str) -> str: + """Decode payload with URL-safe base64url.""" + payload += "=" * (4 - len(payload) % 4) + result: bytes = urlsafe_b64decode(payload) + return result.decode() diff --git a/aiogram/utils/exceptions.py b/aiogram/utils/exceptions.py deleted file mode 100644 index 3efe7032..00000000 --- a/aiogram/utils/exceptions.py +++ /dev/null @@ -1,563 +0,0 @@ -""" -- TelegramAPIError - - ValidationError - - Throttled - - BadRequest - - MessageError - - MessageNotModified - - MessageToForwardNotFound - - MessageToDeleteNotFound - - MessageIdentifierNotSpecified - - MessageTextIsEmpty - - MessageCantBeEdited - - MessageCantBeDeleted - - MessageToEditNotFound - - MessageToReplyNotFound - - ToMuchMessages - - PollError - - PollCantBeStopped - - PollHasAlreadyClosed - - PollsCantBeSentToPrivateChats - - PollSizeError - - PollMustHaveMoreOptions - - PollCantHaveMoreOptions - - PollsOptionsLengthTooLong - - PollOptionsMustBeNonEmpty - - PollQuestionMustBeNonEmpty - - MessageWithPollNotFound (with MessageError) - - MessageIsNotAPoll (with MessageError) - - ObjectExpectedAsReplyMarkup - - InlineKeyboardExpected - - ChatNotFound - - ChatDescriptionIsNotModified - - InvalidQueryID - - InvalidPeerID - - InvalidHTTPUrlContent - - ButtonURLInvalid - - URLHostIsEmpty - - StartParamInvalid - - ButtonDataInvalid - - WrongFileIdentifier - - GroupDeactivated - - BadWebhook - - WebhookRequireHTTPS - - BadWebhookPort - - BadWebhookAddrInfo - - BadWebhookNoAddressAssociatedWithHostname - - NotFound - - MethodNotKnown - - PhotoAsInputFileRequired - - InvalidStickersSet - - NoStickerInRequest - - ChatAdminRequired - - NeedAdministratorRightsInTheChannel - - MethodNotAvailableInPrivateChats - - CantDemoteChatCreator - - CantRestrictSelf - - NotEnoughRightsToRestrict - - PhotoDimensions - - UnavailableMembers - - TypeOfFileMismatch - - WrongRemoteFileIdSpecified - - PaymentProviderInvalid - - CurrencyTotalAmountInvalid - - CantParseUrl - - UnsupportedUrlProtocol - - CantParseEntities - - ResultIdDuplicate - - ConflictError - - TerminatedByOtherGetUpdates - - CantGetUpdates - - Unauthorized - - BotKicked - - BotBlocked - - UserDeactivated - - CantInitiateConversation - - CantTalkWithBots - - NetworkError - - RetryAfter - - MigrateToChat - - RestartingTelegram - -- AIOGramWarning - - TimeoutWarning -""" - - -class TelegramAPIError(Exception): - pass - - -# _PREFIXES = ["error: ", "[error]: ", "bad request: ", "conflict: ", "not found: "] -# - -# def _clean_message(text): -# for prefix in _PREFIXES: -# if text.startswith(prefix): -# text = text[len(prefix) :] -# return (text[0].upper() + text[1:]).strip() -# - - -# -# -# class _MatchErrorMixin: -# match = "" -# text = None -# -# __subclasses = [] -# -# def __init_subclass__(cls, **kwargs): -# super(_MatchErrorMixin, cls).__init_subclass__(**kwargs) -# # cls.match = cls.match.lower() if cls.match else '' -# if not hasattr(cls, f"_{cls.__name__}__group"): -# cls.__subclasses.append(cls) -# -# @classmethod -# def check(cls, message) -> bool: -# """ -# Compare pattern with message -# -# :param message: always must be in lowercase -# :return: bool -# """ -# return cls.match.lower() in message -# -# @classmethod -# def detect(cls, description): -# description = description.lower() -# for err in cls.__subclasses: -# if err is cls: -# continue -# if err.check(description): -# raise err(cls.text or description) -# raise cls(description) -# -# -# class AIOGramWarning(Warning): -# pass -# -# -# class TimeoutWarning(AIOGramWarning): -# pass -# -# -# class FSMStorageWarning(AIOGramWarning): -# pass -# -# -# class ValidationError(TelegramAPIError): -# pass -# -# -# class BadRequest(TelegramAPIError, _MatchErrorMixin): -# __group = True -# -# -# class MessageError(BadRequest): -# __group = True -# -# -# class MessageNotModified(MessageError): -# """ -# Will be raised when you try to set new text is equals to current text. -# """ -# -# match = "message is not modified" -# -# -# class MessageToForwardNotFound(MessageError): -# """ -# Will be raised when you try to forward very old or deleted or unknown message. -# """ -# -# match = "message to forward not found" -# -# -# class MessageToDeleteNotFound(MessageError): -# """ -# Will be raised when you try to delete very old or deleted or unknown message. -# """ -# -# match = "message to delete not found" -# -# -# class MessageToReplyNotFound(MessageError): -# """ -# Will be raised when you try to reply to very old or deleted or unknown message. -# """ -# -# match = "message to reply not found" -# -# -# class MessageIdentifierNotSpecified(MessageError): -# match = "message identifier is not specified" -# -# -# class MessageTextIsEmpty(MessageError): -# match = "Message text is empty" -# -# -# class MessageCantBeEdited(MessageError): -# match = "message can't be edited" -# -# -# class MessageCantBeDeleted(MessageError): -# match = "message can't be deleted" -# -# -# class MessageToEditNotFound(MessageError): -# match = "message to edit not found" -# -# -# class MessageIsTooLong(MessageError): -# match = "message is too long" -# -# -# class ToMuchMessages(MessageError): -# """ -# Will be raised when you try to send media group with more than 10 items. -# """ -# -# match = "Too much messages to send as an album" -# -# -# class ObjectExpectedAsReplyMarkup(BadRequest): -# match = "object expected as reply markup" -# -# -# class InlineKeyboardExpected(BadRequest): -# match = "inline keyboard expected" -# -# -# class PollError(BadRequest): -# __group = True -# -# -# class PollCantBeStopped(PollError): -# match = "poll can't be stopped" -# -# -# class PollHasAlreadyBeenClosed(PollError): -# match = "poll has already been closed" -# -# -# class PollsCantBeSentToPrivateChats(PollError): -# match = "polls can't be sent to private chats" -# -# -# class PollSizeError(PollError): -# __group = True -# -# -# class PollMustHaveMoreOptions(PollSizeError): -# match = "poll must have at least 2 option" -# -# -# class PollCantHaveMoreOptions(PollSizeError): -# match = "poll can't have more than 10 options" -# -# -# class PollOptionsMustBeNonEmpty(PollSizeError): -# match = "poll options must be non-empty" -# -# -# class PollQuestionMustBeNonEmpty(PollSizeError): -# match = "poll question must be non-empty" -# -# -# class PollOptionsLengthTooLong(PollSizeError): -# match = "poll options length must not exceed 100" -# -# -# class PollQuestionLengthTooLong(PollSizeError): -# match = "poll question length must not exceed 255" -# -# -# class MessageWithPollNotFound(PollError, MessageError): -# """ -# Will be raised when you try to stop poll with message without poll -# """ -# -# match = "message with poll to stop not found" -# -# -# class MessageIsNotAPoll(PollError, MessageError): -# """ -# Will be raised when you try to stop poll with message without poll -# """ -# -# match = "message is not a poll" -# -# -# class ChatNotFound(BadRequest): -# match = "chat not found" -# -# -# class ChatIdIsEmpty(BadRequest): -# match = "chat_id is empty" -# -# -# class InvalidUserId(BadRequest): -# match = "user_id_invalid" -# text = "Invalid user id" -# -# -# class ChatDescriptionIsNotModified(BadRequest): -# match = "chat description is not modified" -# -# -# class InvalidQueryID(BadRequest): -# match = "query is too old and response timeout expired or query id is invalid" -# -# -# class InvalidPeerID(BadRequest): -# match = "PEER_ID_INVALID" -# text = "Invalid peer ID" -# -# -# class InvalidHTTPUrlContent(BadRequest): -# match = "Failed to get HTTP URL content" -# -# -# class ButtonURLInvalid(BadRequest): -# match = "BUTTON_URL_INVALID" -# text = "Button URL invalid" -# -# -# class URLHostIsEmpty(BadRequest): -# match = "URL host is empty" -# -# -# class StartParamInvalid(BadRequest): -# match = "START_PARAM_INVALID" -# text = "Start param invalid" -# -# -# class ButtonDataInvalid(BadRequest): -# match = "BUTTON_DATA_INVALID" -# text = "Button data invalid" -# -# -# class WrongFileIdentifier(BadRequest): -# match = "wrong file identifier/HTTP URL specified" -# -# -# class GroupDeactivated(BadRequest): -# match = "group is deactivated" -# -# -# class PhotoAsInputFileRequired(BadRequest): -# """ -# Will be raised when you try to set chat photo from file ID. -# """ -# -# match = "Photo should be uploaded as an InputFile" -# -# -# class InvalidStickersSet(BadRequest): -# match = "STICKERSET_INVALID" -# text = "Stickers set is invalid" -# -# -# class NoStickerInRequest(BadRequest): -# match = "there is no sticker in the request" -# -# -# class ChatAdminRequired(BadRequest): -# match = "CHAT_ADMIN_REQUIRED" -# text = "Admin permissions is required!" -# -# -# class NeedAdministratorRightsInTheChannel(BadRequest): -# match = "need administrator rights in the channel chat" -# text = "Admin permissions is required!" -# -# -# class NotEnoughRightsToPinMessage(BadRequest): -# match = "not enough rights to pin a message" -# -# -# class MethodNotAvailableInPrivateChats(BadRequest): -# match = "method is available only for supergroups and channel" -# -# -# class CantDemoteChatCreator(BadRequest): -# match = "can't demote chat creator" -# -# -# class CantRestrictSelf(BadRequest): -# match = "can't restrict self" -# text = "Admin can't restrict self." -# -# -# class NotEnoughRightsToRestrict(BadRequest): -# match = "not enough rights to restrict/unrestrict chat member" -# -# -# class PhotoDimensions(BadRequest): -# match = "PHOTO_INVALID_DIMENSIONS" -# text = "Invalid photo dimensions" -# -# -# class UnavailableMembers(BadRequest): -# match = "supergroup members are unavailable" -# -# -# class TypeOfFileMismatch(BadRequest): -# match = "type of file mismatch" -# -# -# class WrongRemoteFileIdSpecified(BadRequest): -# match = "wrong remote file id specified" -# -# -# class PaymentProviderInvalid(BadRequest): -# match = "PAYMENT_PROVIDER_INVALID" -# text = "payment provider invalid" -# -# -# class CurrencyTotalAmountInvalid(BadRequest): -# match = "currency_total_amount_invalid" -# text = "currency total amount invalid" -# -# -# class BadWebhook(BadRequest): -# __group = True -# -# -# class WebhookRequireHTTPS(BadWebhook): -# match = "HTTPS url must be provided for webhook" -# text = "bad webhook: " + match -# -# -# class BadWebhookPort(BadWebhook): -# match = "Webhook can be set up only on ports 80, 88, 443 or 8443" -# text = "bad webhook: " + match -# -# -# class BadWebhookAddrInfo(BadWebhook): -# match = "getaddrinfo: Temporary failure in name resolution" -# text = "bad webhook: " + match -# -# -# class BadWebhookNoAddressAssociatedWithHostname(BadWebhook): -# match = "failed to resolve host: no address associated with hostname" -# -# -# class CantParseUrl(BadRequest): -# match = "can't parse URL" -# -# -# class UnsupportedUrlProtocol(BadRequest): -# match = "unsupported URL protocol" -# -# -# class CantParseEntities(BadRequest): -# match = "can't parse entities" -# -# -# class ResultIdDuplicate(BadRequest): -# match = "result_id_duplicate" -# text = "Result ID duplicate" -# -# -# class BotDomainInvalid(BadRequest): -# match = "bot_domain_invalid" -# text = "Invalid bot domain" -# -# -# class NotFound(TelegramAPIError, _MatchErrorMixin): -# __group = True -# -# -# class MethodNotKnown(NotFound): -# match = "method not found" -# -# -# class ConflictError(TelegramAPIError, _MatchErrorMixin): -# __group = True -# -# -# class TerminatedByOtherGetUpdates(ConflictError): -# match = "terminated by other getUpdates request" -# text = ( -# "Terminated by other getUpdates request; " -# "Make sure that only one bot instance is running" -# ) -# -# -# class CantGetUpdates(ConflictError): -# match = "can't use getUpdates method while webhook is active" -# -# -# class Unauthorized(TelegramAPIError, _MatchErrorMixin): -# __group = True -# -# -# class BotKicked(Unauthorized): -# match = "bot was kicked from a chat" -# -# -# class BotBlocked(Unauthorized): -# match = "bot was blocked by the user" -# -# -# class UserDeactivated(Unauthorized): -# match = "user is deactivated" -# -# -# class CantInitiateConversation(Unauthorized): -# match = "bot can't initiate conversation with a user" -# -# -# class CantTalkWithBots(Unauthorized): -# match = "bot can't send messages to bots" -# -# -# class NetworkError(TelegramAPIError): -# pass -# -# -# class RestartingTelegram(TelegramAPIError): -# def __init__(self): -# super(RestartingTelegram, self).__init__( -# "The Telegram Bot API service is restarting. Wait few second." -# ) -# -# -# class RetryAfter(TelegramAPIError): -# def __init__(self, retry_after): -# super(RetryAfter, self).__init__( -# f"Flood control exceeded. Retry in {retry_after} seconds." -# ) -# self.timeout = retry_after -# -# -# class MigrateToChat(TelegramAPIError): -# def __init__(self, chat_id): -# super(MigrateToChat, self).__init__( -# f"The group has been migrated to a supergroup. New id: {chat_id}." -# ) -# self.migrate_to_chat_id = chat_id -# -# -# class Throttled(TelegramAPIError): -# def __init__(self, **kwargs): -# from ..dispatcher.storage import DELTA, EXCEEDED_COUNT, KEY, LAST_CALL, RATE_LIMIT, RESULT -# -# self.key = kwargs.pop(KEY, "") -# self.called_at = kwargs.pop(LAST_CALL, time.time()) -# self.rate = kwargs.pop(RATE_LIMIT, None) -# self.result = kwargs.pop(RESULT, False) -# self.exceeded_count = kwargs.pop(EXCEEDED_COUNT, 0) -# self.delta = kwargs.pop(DELTA, 0) -# self.user = kwargs.pop("user", None) -# self.chat = kwargs.pop("chat", None) -# -# def __str__(self): -# return ( -# f"Rate limit exceeded! (Limit: {self.rate} s, " -# f"exceeded: {self.exceeded_count}, " -# f"time delta: {round(self.delta, 3)} s)" -# ) diff --git a/aiogram/utils/exceptions/__init__.py b/aiogram/utils/exceptions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/aiogram/utils/exceptions/bad_request.py b/aiogram/utils/exceptions/bad_request.py new file mode 100644 index 00000000..de1a0e2d --- /dev/null +++ b/aiogram/utils/exceptions/bad_request.py @@ -0,0 +1,5 @@ +from aiogram.utils.exceptions.base import TelegramAPIError + + +class BadRequest(TelegramAPIError): + pass diff --git a/aiogram/utils/exceptions/base.py b/aiogram/utils/exceptions/base.py new file mode 100644 index 00000000..fefd3db8 --- /dev/null +++ b/aiogram/utils/exceptions/base.py @@ -0,0 +1,27 @@ +from typing import Optional, TypeVar + +from aiogram.methods import TelegramMethod +from aiogram.methods.base import TelegramType + +ErrorType = TypeVar("ErrorType") + + +class TelegramAPIError(Exception): + url: Optional[str] = None + + def __init__( + self, + method: TelegramMethod[TelegramType], + message: str, + ) -> None: + self.method = method + self.message = message + + def render_description(self) -> str: + return self.message + + def __str__(self) -> str: + message = [self.render_description()] + if self.url: + message.append(f"(background on this error at: {self.url})") + return "\n".join(message) diff --git a/aiogram/utils/exceptions/conflict.py b/aiogram/utils/exceptions/conflict.py new file mode 100644 index 00000000..965b328c --- /dev/null +++ b/aiogram/utils/exceptions/conflict.py @@ -0,0 +1,5 @@ +from aiogram.utils.exceptions.base import TelegramAPIError + + +class ConflictError(TelegramAPIError): + pass diff --git a/aiogram/utils/exceptions/exceptions.py b/aiogram/utils/exceptions/exceptions.py new file mode 100644 index 00000000..de1a0e2d --- /dev/null +++ b/aiogram/utils/exceptions/exceptions.py @@ -0,0 +1,5 @@ +from aiogram.utils.exceptions.base import TelegramAPIError + + +class BadRequest(TelegramAPIError): + pass diff --git a/aiogram/utils/exceptions/network.py b/aiogram/utils/exceptions/network.py new file mode 100644 index 00000000..b802ce69 --- /dev/null +++ b/aiogram/utils/exceptions/network.py @@ -0,0 +1,9 @@ +from aiogram.utils.exceptions.base import TelegramAPIError + + +class NetworkError(TelegramAPIError): + pass + + +class EntityTooLarge(NetworkError): + url = "https://core.telegram.org/bots/api#sending-files" diff --git a/aiogram/utils/exceptions/not_found.py b/aiogram/utils/exceptions/not_found.py new file mode 100644 index 00000000..6fa87a06 --- /dev/null +++ b/aiogram/utils/exceptions/not_found.py @@ -0,0 +1,5 @@ +from aiogram.utils.exceptions.base import TelegramAPIError + + +class NotFound(TelegramAPIError): + pass diff --git a/aiogram/utils/exceptions/server.py b/aiogram/utils/exceptions/server.py new file mode 100644 index 00000000..c45c9f01 --- /dev/null +++ b/aiogram/utils/exceptions/server.py @@ -0,0 +1,9 @@ +from aiogram.utils.exceptions.base import TelegramAPIError + + +class ServerError(TelegramAPIError): + pass + + +class RestartingTelegram(ServerError): + pass diff --git a/aiogram/utils/exceptions/special.py b/aiogram/utils/exceptions/special.py new file mode 100644 index 00000000..d2044ec2 --- /dev/null +++ b/aiogram/utils/exceptions/special.py @@ -0,0 +1,44 @@ +from aiogram.methods import TelegramMethod +from aiogram.methods.base import TelegramType +from aiogram.utils.exceptions.base import TelegramAPIError + + +class RetryAfter(TelegramAPIError): + url = "https://core.telegram.org/bots/faq#my-bot-is-hitting-limits-how-do-i-avoid-this" + + def __init__( + self, + method: TelegramMethod[TelegramType], + message: str, + retry_after: int, + ) -> None: + super().__init__(method=method, message=message) + self.retry_after = retry_after + + def render_description(self) -> str: + description = f"Flood control exceeded on method {type(self.method).__name__!r}" + if chat_id := getattr(self.method, "chat_id", None): + description += f" in chat {chat_id}" + description += f". Retry in {self.retry_after} seconds." + return description + + +class MigrateToChat(TelegramAPIError): + url = "https://core.telegram.org/bots/api#responseparameters" + + def __init__( + self, + method: TelegramMethod[TelegramType], + message: str, + migrate_to_chat_id: int, + ) -> None: + super().__init__(method=method, message=message) + self.migrate_to_chat_id = migrate_to_chat_id + + def render_description(self) -> str: + description = ( + f"The group has been migrated to a supergroup with id {self.migrate_to_chat_id}" + ) + if chat_id := getattr(self.method, "chat_id", None): + description += f" from {chat_id}" + return description diff --git a/aiogram/utils/exceptions/unauthorized.py b/aiogram/utils/exceptions/unauthorized.py new file mode 100644 index 00000000..789574a5 --- /dev/null +++ b/aiogram/utils/exceptions/unauthorized.py @@ -0,0 +1,5 @@ +from aiogram.utils.exceptions.base import TelegramAPIError + + +class UnauthorizedError(TelegramAPIError): + pass diff --git a/aiogram/utils/handlers_in_use.py b/aiogram/utils/handlers_in_use.py new file mode 100644 index 00000000..c1816476 --- /dev/null +++ b/aiogram/utils/handlers_in_use.py @@ -0,0 +1,28 @@ +from itertools import chain +from typing import List, cast + +from aiogram.dispatcher.dispatcher import Dispatcher +from aiogram.dispatcher.router import Router + +INTERNAL_HANDLERS = [ + "update", + "error", +] + + +def get_handlers_in_use( + dispatcher: Dispatcher, handlers_to_skip: List[str] = INTERNAL_HANDLERS +) -> List[str]: + handlers_in_use: List[str] = [] + + for router in [dispatcher.sub_routers, dispatcher]: + if isinstance(router, list): + if router: + handlers_in_use.extend(chain(*list(map(get_handlers_in_use, router)))) + else: + router = cast(Router, router) + for update_name, observer in router.observers.items(): + if observer.handlers and update_name not in [*handlers_to_skip, *handlers_in_use]: + handlers_in_use.append(update_name) + + return handlers_in_use diff --git a/aiogram/utils/markup.py b/aiogram/utils/keyboard.py similarity index 67% rename from aiogram/utils/markup.py rename to aiogram/utils/keyboard.py index 32169104..a8e31c17 100644 --- a/aiogram/utils/markup.py +++ b/aiogram/utils/keyboard.py @@ -1,8 +1,31 @@ +from __future__ import annotations + from itertools import chain from itertools import cycle as repeat_all -from typing import Any, Generator, Generic, Iterable, List, Optional, Type, TypeVar +from typing import ( + TYPE_CHECKING, + Any, + Generator, + Generic, + Iterable, + List, + Optional, + Type, + TypeVar, + Union, + no_type_check, +) -from aiogram.types import InlineKeyboardButton, KeyboardButton +from aiogram.dispatcher.filters.callback_data import CallbackData +from aiogram.types import ( + CallbackGame, + InlineKeyboardButton, + InlineKeyboardMarkup, + KeyboardButton, + KeyboardButtonPollType, + LoginUrl, + ReplyKeyboardMarkup, +) ButtonType = TypeVar("ButtonType", InlineKeyboardButton, KeyboardButton) T = TypeVar("T") @@ -11,7 +34,7 @@ MIN_WIDTH = 1 MAX_BUTTONS = 100 -class MarkupConstructor(Generic[ButtonType]): +class KeyboardBuilder(Generic[ButtonType]): def __init__( self, button_type: Type[ButtonType], markup: Optional[List[List[ButtonType]]] = None ) -> None: @@ -106,9 +129,9 @@ class MarkupConstructor(Generic[ButtonType]): raise ValueError(f"Row size {size} are not allowed") return size - def copy(self: "MarkupConstructor[ButtonType]") -> "MarkupConstructor[ButtonType]": + def copy(self: "KeyboardBuilder[ButtonType]") -> "KeyboardBuilder[ButtonType]": """ - Make full copy of current constructor with markup + Make full copy of current builder with markup :return: """ @@ -120,15 +143,15 @@ class MarkupConstructor(Generic[ButtonType]): .. code-block:: python - >>> constructor = MarkupConstructor(button_type=InlineKeyboardButton) - >>> ... # Add buttons to constructor - >>> markup = InlineKeyboardMarkup(inline_keyboard=constructor.export()) + >>> builder = KeyboardBuilder(button_type=InlineKeyboardButton) + >>> ... # Add buttons to builder + >>> markup = InlineKeyboardMarkup(inline_keyboard=builder.export()) :return: """ return self._markup.copy() - def add(self, *buttons: ButtonType) -> "MarkupConstructor[ButtonType]": + def add(self, *buttons: ButtonType) -> "KeyboardBuilder[ButtonType]": """ Add one or many buttons to markup. @@ -153,7 +176,7 @@ class MarkupConstructor(Generic[ButtonType]): self._markup = markup return self - def row(self, *buttons: ButtonType, width: int = MAX_WIDTH) -> "MarkupConstructor[ButtonType]": + def row(self, *buttons: ButtonType, width: int = MAX_WIDTH) -> "KeyboardBuilder[ButtonType]": """ Add row to markup @@ -170,7 +193,7 @@ class MarkupConstructor(Generic[ButtonType]): ) return self - def adjust(self, *sizes: int, repeat: bool = False) -> "MarkupConstructor[ButtonType]": + def adjust(self, *sizes: int, repeat: bool = False) -> "KeyboardBuilder[ButtonType]": """ Adjust previously added buttons to specific row sizes. @@ -202,10 +225,17 @@ class MarkupConstructor(Generic[ButtonType]): self._markup = markup return self - def button(self, **kwargs: Any) -> "MarkupConstructor[ButtonType]": + def button(self, **kwargs: Any) -> "KeyboardBuilder[ButtonType]": + if isinstance(callback_data := kwargs.get("callback_data", None), CallbackData): + kwargs["callback_data"] = callback_data.pack() button = self._button_type(**kwargs) return self.add(button) + def as_markup(self, **kwargs: Any) -> Union[InlineKeyboardMarkup, ReplyKeyboardMarkup]: + if self._button_type is ReplyKeyboardMarkup: + return ReplyKeyboardMarkup(keyboard=self.export(), **kwargs) + return InlineKeyboardMarkup(inline_keyboard=self.export()) + def repeat_last(items: Iterable[T]) -> Generator[T, None, None]: items_iter = iter(items) @@ -222,3 +252,49 @@ def repeat_last(items: Iterable[T]) -> Generator[T, None, None]: except StopIteration: finished = True yield value + + +class InlineKeyboardBuilder(KeyboardBuilder[InlineKeyboardButton]): + if TYPE_CHECKING: # pragma: no cover + + @no_type_check + def button( + self, + text: str, + url: Optional[str] = None, + login_url: Optional[LoginUrl] = None, + callback_data: Optional[Union[str, CallbackData]] = None, + switch_inline_query: Optional[str] = None, + switch_inline_query_current_chat: Optional[str] = None, + callback_game: Optional[CallbackGame] = None, + pay: Optional[bool] = None, + **kwargs: Any, + ) -> "KeyboardBuilder[InlineKeyboardButton]": + ... + + def as_markup(self, **kwargs: Any) -> InlineKeyboardMarkup: + ... + + def __init__(self) -> None: + super().__init__(InlineKeyboardButton) + + +class ReplyKeyboardBuilder(KeyboardBuilder[KeyboardButton]): + if TYPE_CHECKING: # pragma: no cover + + @no_type_check + def button( + self, + text: str, + request_contact: Optional[bool] = None, + request_location: Optional[bool] = None, + request_poll: Optional[KeyboardButtonPollType] = None, + **kwargs: Any, + ) -> "KeyboardBuilder[KeyboardButton]": + ... + + def as_markup(self, **kwargs: Any) -> ReplyKeyboardMarkup: + ... + + def __init__(self) -> None: + super().__init__(KeyboardButton) diff --git a/aiogram/utils/link.py b/aiogram/utils/link.py new file mode 100644 index 00000000..87d402e2 --- /dev/null +++ b/aiogram/utils/link.py @@ -0,0 +1,18 @@ +from typing import Any +from urllib.parse import urlencode, urljoin + + +def create_tg_link(link: str, **kwargs: Any) -> str: + url = f"tg://{link}" + if kwargs: + query = urlencode(kwargs) + url += f"?{query}" + return url + + +def create_telegram_link(uri: str, **kwargs: Any) -> str: + url = urljoin("https://t.me", uri) + if kwargs: + query = urlencode(query=kwargs) + url += f"?{query}" + return url diff --git a/aiogram/utils/text_decorations.py b/aiogram/utils/text_decorations.py index a41e481f..23c9c2a7 100644 --- a/aiogram/utils/text_decorations.py +++ b/aiogram/utils/text_decorations.py @@ -183,7 +183,7 @@ class MarkdownDecoration(TextDecoration): return f"`{value}`" def pre(self, value: str) -> str: - return f"```{value}```" + return f"```\n{value}\n```" def pre_language(self, value: str, language: str) -> str: return f"```{language}\n{value}\n```" diff --git a/codecov.yaml b/codecov.yaml index cdf02d42..082f0672 100644 --- a/codecov.yaml +++ b/codecov.yaml @@ -21,4 +21,4 @@ comment: require_changes: no branches: - dev-3.x - after_n_builds: 8 + after_n_builds: 6 diff --git a/docs/api/methods/ban_chat_member.rst b/docs/api/methods/ban_chat_member.rst new file mode 100644 index 00000000..0f8edf8b --- /dev/null +++ b/docs/api/methods/ban_chat_member.rst @@ -0,0 +1,51 @@ +############# +banChatMember +############# + +Returns: :obj:`bool` + +.. automodule:: aiogram.methods.ban_chat_member + :members: + :member-order: bysource + :undoc-members: True + + +Usage +===== + +As bot method +------------- + +.. code-block:: + + result: bool = await bot.ban_chat_member(...) + + +Method as object +---------------- + +Imports: + +- :code:`from aiogram.methods.ban_chat_member import BanChatMember` +- alias: :code:`from aiogram.methods import BanChatMember` + +In handlers with current bot +---------------------------- + +.. code-block:: python + + result: bool = await BanChatMember(...) + +With specific bot +~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + result: bool = await bot(BanChatMember(...)) + +As reply into Webhook in handler +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + return BanChatMember(...) diff --git a/docs/api/methods/delete_my_commands.rst b/docs/api/methods/delete_my_commands.rst new file mode 100644 index 00000000..5077f68c --- /dev/null +++ b/docs/api/methods/delete_my_commands.rst @@ -0,0 +1,51 @@ +################ +deleteMyCommands +################ + +Returns: :obj:`bool` + +.. automodule:: aiogram.methods.delete_my_commands + :members: + :member-order: bysource + :undoc-members: True + + +Usage +===== + +As bot method +------------- + +.. code-block:: + + result: bool = await bot.delete_my_commands(...) + + +Method as object +---------------- + +Imports: + +- :code:`from aiogram.methods.delete_my_commands import DeleteMyCommands` +- alias: :code:`from aiogram.methods import DeleteMyCommands` + +In handlers with current bot +---------------------------- + +.. code-block:: python + + result: bool = await DeleteMyCommands(...) + +With specific bot +~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + result: bool = await bot(DeleteMyCommands(...)) + +As reply into Webhook in handler +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + return DeleteMyCommands(...) diff --git a/docs/api/methods/get_chat_member_count.rst b/docs/api/methods/get_chat_member_count.rst new file mode 100644 index 00000000..2bee4cd9 --- /dev/null +++ b/docs/api/methods/get_chat_member_count.rst @@ -0,0 +1,44 @@ +################## +getChatMemberCount +################## + +Returns: :obj:`int` + +.. automodule:: aiogram.methods.get_chat_member_count + :members: + :member-order: bysource + :undoc-members: True + + +Usage +===== + +As bot method +------------- + +.. code-block:: + + result: int = await bot.get_chat_member_count(...) + + +Method as object +---------------- + +Imports: + +- :code:`from aiogram.methods.get_chat_member_count import GetChatMemberCount` +- alias: :code:`from aiogram.methods import GetChatMemberCount` + +In handlers with current bot +---------------------------- + +.. code-block:: python + + result: int = await GetChatMemberCount(...) + +With specific bot +~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + result: int = await bot(GetChatMemberCount(...)) diff --git a/docs/api/methods/index.rst b/docs/api/methods/index.rst index 94c08366..22fbff3e 100644 --- a/docs/api/methods/index.rst +++ b/docs/api/methods/index.rst @@ -48,6 +48,7 @@ Available methods send_chat_action get_user_profile_photos get_file + ban_chat_member kick_chat_member unban_chat_member restrict_chat_member @@ -68,12 +69,14 @@ Available methods leave_chat get_chat get_chat_administrators + get_chat_member_count get_chat_members_count get_chat_member set_chat_sticker_set delete_chat_sticker_set answer_callback_query set_my_commands + delete_my_commands get_my_commands Updating messages diff --git a/docs/api/types/bot_command_scope.rst b/docs/api/types/bot_command_scope.rst new file mode 100644 index 00000000..fa89f3ab --- /dev/null +++ b/docs/api/types/bot_command_scope.rst @@ -0,0 +1,9 @@ +############### +BotCommandScope +############### + + +.. automodule:: aiogram.types.bot_command_scope + :members: + :member-order: bysource + :undoc-members: True diff --git a/docs/api/types/bot_command_scope_all_chat_administrators.rst b/docs/api/types/bot_command_scope_all_chat_administrators.rst new file mode 100644 index 00000000..cfde1f73 --- /dev/null +++ b/docs/api/types/bot_command_scope_all_chat_administrators.rst @@ -0,0 +1,9 @@ +#################################### +BotCommandScopeAllChatAdministrators +#################################### + + +.. automodule:: aiogram.types.bot_command_scope_all_chat_administrators + :members: + :member-order: bysource + :undoc-members: True diff --git a/docs/api/types/bot_command_scope_all_group_chats.rst b/docs/api/types/bot_command_scope_all_group_chats.rst new file mode 100644 index 00000000..2fe3ec7a --- /dev/null +++ b/docs/api/types/bot_command_scope_all_group_chats.rst @@ -0,0 +1,9 @@ +############################ +BotCommandScopeAllGroupChats +############################ + + +.. automodule:: aiogram.types.bot_command_scope_all_group_chats + :members: + :member-order: bysource + :undoc-members: True diff --git a/docs/api/types/bot_command_scope_all_private_chats.rst b/docs/api/types/bot_command_scope_all_private_chats.rst new file mode 100644 index 00000000..4c018322 --- /dev/null +++ b/docs/api/types/bot_command_scope_all_private_chats.rst @@ -0,0 +1,9 @@ +############################## +BotCommandScopeAllPrivateChats +############################## + + +.. automodule:: aiogram.types.bot_command_scope_all_private_chats + :members: + :member-order: bysource + :undoc-members: True diff --git a/docs/api/types/bot_command_scope_chat.rst b/docs/api/types/bot_command_scope_chat.rst new file mode 100644 index 00000000..ee7900fc --- /dev/null +++ b/docs/api/types/bot_command_scope_chat.rst @@ -0,0 +1,9 @@ +################### +BotCommandScopeChat +################### + + +.. automodule:: aiogram.types.bot_command_scope_chat + :members: + :member-order: bysource + :undoc-members: True diff --git a/docs/api/types/bot_command_scope_chat_administrators.rst b/docs/api/types/bot_command_scope_chat_administrators.rst new file mode 100644 index 00000000..76e72c45 --- /dev/null +++ b/docs/api/types/bot_command_scope_chat_administrators.rst @@ -0,0 +1,9 @@ +################################# +BotCommandScopeChatAdministrators +################################# + + +.. automodule:: aiogram.types.bot_command_scope_chat_administrators + :members: + :member-order: bysource + :undoc-members: True diff --git a/docs/api/types/bot_command_scope_chat_member.rst b/docs/api/types/bot_command_scope_chat_member.rst new file mode 100644 index 00000000..60a76fa1 --- /dev/null +++ b/docs/api/types/bot_command_scope_chat_member.rst @@ -0,0 +1,9 @@ +######################### +BotCommandScopeChatMember +######################### + + +.. automodule:: aiogram.types.bot_command_scope_chat_member + :members: + :member-order: bysource + :undoc-members: True diff --git a/docs/api/types/bot_command_scope_default.rst b/docs/api/types/bot_command_scope_default.rst new file mode 100644 index 00000000..fe97331b --- /dev/null +++ b/docs/api/types/bot_command_scope_default.rst @@ -0,0 +1,9 @@ +###################### +BotCommandScopeDefault +###################### + + +.. automodule:: aiogram.types.bot_command_scope_default + :members: + :member-order: bysource + :undoc-members: True diff --git a/docs/api/types/chat_member_administrator.rst b/docs/api/types/chat_member_administrator.rst new file mode 100644 index 00000000..55302054 --- /dev/null +++ b/docs/api/types/chat_member_administrator.rst @@ -0,0 +1,9 @@ +####################### +ChatMemberAdministrator +####################### + + +.. automodule:: aiogram.types.chat_member_administrator + :members: + :member-order: bysource + :undoc-members: True diff --git a/docs/api/types/chat_member_banned.rst b/docs/api/types/chat_member_banned.rst new file mode 100644 index 00000000..31570bec --- /dev/null +++ b/docs/api/types/chat_member_banned.rst @@ -0,0 +1,9 @@ +################ +ChatMemberBanned +################ + + +.. automodule:: aiogram.types.chat_member_banned + :members: + :member-order: bysource + :undoc-members: True diff --git a/docs/api/types/chat_member_left.rst b/docs/api/types/chat_member_left.rst new file mode 100644 index 00000000..52a4dd9d --- /dev/null +++ b/docs/api/types/chat_member_left.rst @@ -0,0 +1,9 @@ +############## +ChatMemberLeft +############## + + +.. automodule:: aiogram.types.chat_member_left + :members: + :member-order: bysource + :undoc-members: True diff --git a/docs/api/types/chat_member_member.rst b/docs/api/types/chat_member_member.rst new file mode 100644 index 00000000..8f884af9 --- /dev/null +++ b/docs/api/types/chat_member_member.rst @@ -0,0 +1,9 @@ +################ +ChatMemberMember +################ + + +.. automodule:: aiogram.types.chat_member_member + :members: + :member-order: bysource + :undoc-members: True diff --git a/docs/api/types/chat_member_owner.rst b/docs/api/types/chat_member_owner.rst new file mode 100644 index 00000000..09eee65c --- /dev/null +++ b/docs/api/types/chat_member_owner.rst @@ -0,0 +1,9 @@ +############### +ChatMemberOwner +############### + + +.. automodule:: aiogram.types.chat_member_owner + :members: + :member-order: bysource + :undoc-members: True diff --git a/docs/api/types/chat_member_restricted.rst b/docs/api/types/chat_member_restricted.rst new file mode 100644 index 00000000..dcc3db58 --- /dev/null +++ b/docs/api/types/chat_member_restricted.rst @@ -0,0 +1,9 @@ +#################### +ChatMemberRestricted +#################### + + +.. automodule:: aiogram.types.chat_member_restricted + :members: + :member-order: bysource + :undoc-members: True diff --git a/docs/api/types/index.rst b/docs/api/types/index.rst index 2e3b3812..2309261b 100644 --- a/docs/api/types/index.rst +++ b/docs/api/types/index.rst @@ -60,10 +60,24 @@ Available types chat_photo chat_invite_link chat_member + chat_member_owner + chat_member_administrator + chat_member_member + chat_member_restricted + chat_member_left + chat_member_banned chat_member_updated chat_permissions chat_location bot_command + bot_command_scope + bot_command_scope_default + bot_command_scope_all_private_chats + bot_command_scope_all_group_chats + bot_command_scope_all_chat_administrators + bot_command_scope_chat + bot_command_scope_chat_administrators + bot_command_scope_chat_member response_parameters input_media input_media_photo diff --git a/docs/changelog.rst b/docs/changelog.rst new file mode 100644 index 00000000..ddb848d5 --- /dev/null +++ b/docs/changelog.rst @@ -0,0 +1,5 @@ +.. _aiogram_changes: + +.. include:: ../CHANGES.rst + +.. include:: ../HISTORY.rst diff --git a/docs/dispatcher/finite_state_machine/storages.rst b/docs/dispatcher/finite_state_machine/storages.rst new file mode 100644 index 00000000..f2fefe41 --- /dev/null +++ b/docs/dispatcher/finite_state_machine/storages.rst @@ -0,0 +1,23 @@ +######## +Storages +######## + +Storages out of the box +======================= + +MemoryStorage +------------- + +.. autoclass:: aiogram.dispatcher.fsm.storage.memory.MemoryStorage + :members: __init__, from_url + :member-order: bysource + +RedisStorage +------------ + +.. autoclass:: aiogram.dispatcher.fsm.storage.redis.RedisStorage + :members: __init__, from_url + :member-order: bysource + +Writing own storages +==================== diff --git a/docs/index.rst b/docs/index.rst index e6d2b918..0016a02e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -13,7 +13,7 @@ aiogram :target: https://pypi.python.org/pypi/aiogram :alt: Supported python versions -.. image:: https://img.shields.io/badge/Telegram%20Bot%20API-5.2-blue.svg?logo=telegram +.. image:: https://img.shields.io/badge/Telegram%20Bot%20API-5.3-blue.svg?logo=telegram :target: https://core.telegram.org/bots/api :alt: Telegram Bot API @@ -42,7 +42,7 @@ aiogram :alt: Codecov **aiogram** modern and fully asynchronous framework for -`Telegram Bot API `_ written in Python 3.7 with +`Telegram Bot API `_ written in Python 3.8 with `asyncio `_ and `aiohttp `_. @@ -93,3 +93,4 @@ Contents install api/index dispatcher/index + changelog diff --git a/docs/install.rst b/docs/install.rst index a5a1f8ee..7dbf3cf8 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -5,34 +5,29 @@ Installation Stable (2.x) ============ -Using PIP +From PyPI --------- .. code-block:: bash pip install -U aiogram -Using AUR ---------- - -*aiogram* 2.x is also available in Arch Linux Repository, so you can install this framework -on any Arch-based distribution like Arch Linux, Antergos, Manjaro, etc. - -To do this, just use pacman to install the *python-aiogram* package: +From Arch Linux Repository +-------------------------- .. code-block:: bash - $ pacman -S python-aiogram + pacman -S python-aiogram Development build (3.x) ======================= -From test PyPi index +From PyPI ----------------------- .. code-block:: bash - pip install -U --extra-index-url https://test.pypi.org/simple/ --pre aiogram + pip install -U --pre aiogram From GitHub ----------- @@ -40,3 +35,10 @@ From GitHub .. code-block:: bash pip install https://github.com/aiogram/aiogram/archive/refs/heads/dev-3.x.zip + +From AUR +-------- + +.. code-block:: bash + + yay -S python-aiogram3 diff --git a/examples/echo_bot.py b/examples/echo_bot.py index 2ab06c78..f5689c3d 100644 --- a/examples/echo_bot.py +++ b/examples/echo_bot.py @@ -1,39 +1,46 @@ +import logging from typing import Any from aiogram import Bot, Dispatcher, types -from aiogram.dispatcher.handler import MessageHandler +from aiogram.types import Message TOKEN = "42:TOKEN" dp = Dispatcher() +logger = logging.getLogger(__name__) -@dp.message(commands=["start"]) -class MyHandler(MessageHandler): + +@dp.message(commands={"start"}) +async def command_start_handler(message: Message) -> None: """ This handler receive messages with `/start` command - - Usage of Class-based handlers """ - - async def handle(self) -> Any: - await self.event.answer(f"Hello, {self.from_user.full_name}!") + # Most of event objects has an aliases for API methods to be called in event context + # For example if you want to answer to incoming message you can use `message.answer(...)` alias + # and the target chat will be passed to :ref:`aiogram.methods.send_message.SendMessage` method automatically + # or call API method directly via Bot instance: `bot.send_message(chat_id=message.chat.id, ...)` + await message.answer(f"Hello, {message.from_user.full_name}!") -@dp.message(content_types=[types.ContentType.ANY]) -async def echo_handler(message: types.Message, bot: Bot) -> Any: +@dp.message() +async def echo_handler(message: types.Message) -> Any: """ Handler will forward received message back to the sender - Usage of Function-based handlers + By default message handler will handle all message types (like text, photo, sticker and etc.) """ - - await bot.forward_message( - from_chat_id=message.chat.id, chat_id=message.chat.id, message_id=message.message_id - ) + try: + # Send copy of the received message + await message.send_copy(chat_id=message.chat.id) + except TypeError: + # But not all the types is supported to be copied so need to handle it + await message.answer("Nice try!") def main() -> None: + # Initialize Bot instance with an default parse mode which will be passed to all API calls bot = Bot(TOKEN, parse_mode="HTML") + # And the run events dispatching dp.run_polling(bot) diff --git a/examples/finite_state_machine.py b/examples/finite_state_machine.py new file mode 100644 index 00000000..65266b64 --- /dev/null +++ b/examples/finite_state_machine.py @@ -0,0 +1,111 @@ +import asyncio +import logging +import sys +from os import getenv + +from aiogram import Bot, Dispatcher, F +from aiogram.dispatcher.filters import Command +from aiogram.dispatcher.fsm.context import FSMContext +from aiogram.dispatcher.fsm.state import State, StatesGroup +from aiogram.types import Message, ReplyKeyboardRemove, ReplyKeyboardMarkup, KeyboardButton +from aiogram.utils.markdown import hbold +from aiogram.utils.markup import KeyboardConstructor + +GENDERS = ["Male", "Female", "Helicopter", "Other"] + +dp = Dispatcher() + + +# States +class Form(StatesGroup): + name = State() # Will be represented in storage as 'Form:name' + age = State() # Will be represented in storage as 'Form:age' + gender = State() # Will be represented in storage as 'Form:gender' + + +@dp.message(Command(commands=["start"])) +async def cmd_start(message: Message, state: FSMContext): + """ + Conversation's entry point + """ + # Set state + await state.set_state(Form.name) + await message.answer("Hi there! What's your name?") + + +@dp.message(Command(commands=["cancel"])) +@dp.message(F.text.lower() == "cancel") +async def cancel_handler(message: Message, state: FSMContext): + """ + Allow user to cancel any action + """ + current_state = await state.get_state() + if current_state is None: + return + + logging.info("Cancelling state %r", current_state) + # Cancel state and inform user about it + await state.clear() + # And remove keyboard (just in case) + await message.answer("Cancelled.", reply_markup=ReplyKeyboardRemove()) + + +@dp.message(Form.name) +async def process_name(message: Message, state: FSMContext): + """ + Process user name + """ + await state.update_data(name=message.text) + await state.set_state(Form.age) + await message.answer("How old are you?") + + +# Check age. Age gotta be digit +@dp.message(Form.age, ~F.text.isdigit()) +async def process_age_invalid(message: Message): + """ + If age is invalid + """ + return await message.answer("Age gotta be a number.\nHow old are you? (digits only)") + + +@dp.message(Form.age) +async def process_age(message: Message, state: FSMContext): + # Update state and data + await state.set_state(Form.gender) + await state.update_data(age=int(message.text)) + + # Configure ReplyKeyboardMarkup + constructor = KeyboardConstructor(KeyboardButton) + constructor.add(*(KeyboardButton(text=text) for text in GENDERS)).adjust(2) + markup = ReplyKeyboardMarkup( + resize_keyboard=True, selective=True, keyboard=constructor.export() + ) + await message.reply("What is your gender?", reply_markup=markup) + + +@dp.message(Form.gender) +async def process_gender(message: Message, state: FSMContext): + data = await state.update_data(gender=message.text) + await state.clear() + + # And send message + await message.answer( + ( + f'Hi, nice to meet you, {hbold(data["name"])}\n' + f'Age: {hbold(data["age"])}\n' + f'Gender: {hbold(data["gender"])}\n' + ), + reply_markup=ReplyKeyboardRemove(), + ) + + +async def main(): + bot = Bot(token=getenv("TELEGRAM_TOKEN"), parse_mode="HTML") + + await dp.start_polling(bot) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, stream=sys.stdout) + asyncio.run(main()) diff --git a/examples/specify_updates.py b/examples/specify_updates.py new file mode 100644 index 00000000..33fdd093 --- /dev/null +++ b/examples/specify_updates.py @@ -0,0 +1,87 @@ +from aiogram.types.inline_keyboard_button import InlineKeyboardButton +from aiogram.types.inline_keyboard_markup import InlineKeyboardMarkup +from aiogram.dispatcher.router import Router +from aiogram.utils.handlers_in_use import get_handlers_in_use +import logging + +from aiogram import Bot, Dispatcher +from aiogram.types import Message, ChatMemberUpdated, CallbackQuery + +TOKEN = "6wo" +dp = Dispatcher() + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + + +@dp.message(commands={"start"}) +async def command_start_handler(message: Message) -> None: + """ + This handler receive messages with `/start` command + """ + + await message.answer( + f"Hello, {message.from_user.full_name}!", + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text="Tap me, bro", callback_data="*")]] + ), + ) + + +@dp.chat_member() +async def chat_member_update(chat_member: ChatMemberUpdated, bot: Bot) -> None: + await bot.send_message( + chat_member.chat.id, + "Member {chat_member.from_user.id} was changed " + + f"from {chat_member.old_chat_member.is_chat_member} to {chat_member.new_chat_member.is_chat_member}", + ) + + +# this router will use only callback_query updates +sub_router = Router() + + +@sub_router.callback_query() +async def callback_tap_me(callback_query: CallbackQuery) -> None: + await callback_query.answer("Yeah good, now i'm fine") + + +# this router will use only edited_message updates +sub_sub_router = Router() + + +@sub_sub_router.edited_message() +async def callback_tap_me(edited_message: Message) -> None: + await edited_message.reply("Message was edited, big brother watch you") + + +# this router will use only my_chat_member updates +deep_dark_router = Router() + + +@deep_dark_router.my_chat_member() +async def my_chat_member_change(chat_member: ChatMemberUpdated, bot: Bot) -> None: + await bot.send_message( + chat_member.chat.id, + "Member was changed from " + + f"{chat_member.old_chat_member.is_chat_member} to {chat_member.new_chat_member.is_chat_member}", + ) + + +def main() -> None: + # Initialize Bot instance with an default parse mode which will be passed to all API calls + bot = Bot(TOKEN, parse_mode="HTML") + + sub_router.include_router(deep_dark_router) + + dp.include_router(sub_router) + dp.include_router(sub_sub_router) + + useful_updates = get_handlers_in_use(dp) + + # And the run events dispatching + dp.run_polling(bot, allowed_updates=useful_updates) + + +if __name__ == "__main__": + main() diff --git a/mypy.ini b/mypy.ini index afe61218..a75c96cb 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,6 +1,6 @@ [mypy] ;plugins = pydantic.mypy -python_version = 3.7 +python_version = 3.8 show_error_codes = True show_error_context = True pretty = True @@ -29,3 +29,6 @@ ignore_missing_imports = True [mypy-uvloop] ignore_missing_imports = True + +[mypy-aioredis] +ignore_missing_imports = True diff --git a/poetry.lock b/poetry.lock index 86a831d1..7f29ff0d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -38,6 +38,21 @@ aiohttp = ">=2.3.2" attrs = ">=19.2.0" python-socks = {version = ">=1.0.1", extras = ["asyncio"]} +[[package]] +name = "aioredis" +version = "2.0.0" +description = "asyncio (PEP 3156) Redis support" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +async-timeout = "*" +typing-extensions = "*" + +[package.extras] +hiredis = ["hiredis (>=1.0)"] + [[package]] name = "alabaster" version = "0.7.12" @@ -108,17 +123,17 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "attrs" -version = "20.3.0" +version = "21.2.0" description = "Classes Without Boilerplate" category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [package.extras] -dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"] -docs = ["furo", "sphinx", "zope.interface"] -tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] -tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"] +docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"] [[package]] name = "babel" @@ -156,7 +171,7 @@ lxml = ["lxml"] [[package]] name = "black" -version = "21.4b2" +version = "21.6b0" description = "The uncompromising code formatter." category = "dev" optional = false @@ -169,17 +184,16 @@ mypy-extensions = ">=0.4.3" pathspec = ">=0.8.1,<1" regex = ">=2020.1.8" toml = ">=0.10.1" -typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\""} -typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} [package.extras] colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] +d = ["aiohttp (>=3.6.0)", "aiohttp-cors (>=0.4.0)"] python2 = ["typed-ast (>=1.4.2)"] +uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "cfgv" -version = "3.2.0" +version = "3.3.0" description = "Validate configuration and produce human readable error messages." category = "dev" optional = false @@ -195,11 +209,25 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "click" -version = "7.1.2" +version = "8.0.1" description = "Composable command line interface toolkit" category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.6" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "click-default-group" +version = "1.2.2" +description = "Extends click.Group to invoke a command without explicit subcommand name" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +click = "*" [[package]] name = "colorama" @@ -222,15 +250,27 @@ toml = ["toml"] [[package]] name = "decorator" -version = "5.0.7" +version = "5.0.9" description = "Decorators for Humans" category = "dev" optional = false python-versions = ">=3.5" +[[package]] +name = "diagrams" +version = "0.20.0" +description = "Diagram as Code" +category = "dev" +optional = false +python-versions = ">=3.6,<4.0" + +[package.dependencies] +graphviz = ">=0.13.2,<0.17.0" +jinja2 = ">=2.10,<3.0" + [[package]] name = "distlib" -version = "0.3.1" +version = "0.3.2" description = "Distribution utilities" category = "dev" optional = false @@ -254,14 +294,13 @@ python-versions = "*" [[package]] name = "flake8" -version = "3.9.1" +version = "3.9.2" description = "the modular source code checker: pep8 pyflakes and co" category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [package.dependencies] -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} mccabe = ">=0.6.0,<0.7.0" pycodestyle = ">=2.7.0,<2.8.0" pyflakes = ">=2.3.0,<2.4.0" @@ -282,23 +321,36 @@ pygments = ">=2.2.0" [[package]] name = "furo" -version = "2020.12.30b24" +version = "2021.6.18b36" description = "A clean customisable Sphinx documentation theme." category = "main" optional = false -python-versions = ">=3.5" +python-versions = ">=3.6" [package.dependencies] beautifulsoup4 = "*" -sphinx = ">=3.0,<4.0" +sphinx = ">=3.0,<5.0" [package.extras] -doc = ["myst-parser", "sphinx-copybutton", "sphinx-inline-tabs"] +doc = ["myst-parser", "sphinx-copybutton", "sphinx-inline-tabs", "docutils (!=0.17)"] test = ["pytest", "pytest-cov", "pytest-xdist"] +[[package]] +name = "graphviz" +version = "0.16" +description = "Simple Python interface for Graphviz" +category = "dev" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*" + +[package.extras] +dev = ["tox (>=3)", "flake8", "pep8-naming", "wheel", "twine"] +docs = ["sphinx (>=1.8)", "sphinx-rtd-theme"] +test = ["mock (>=3)", "pytest (>=4)", "pytest-mock (>=2)", "pytest-cov"] + [[package]] name = "identify" -version = "2.2.3" +version = "2.2.10" description = "File identification library for Python" category = "dev" optional = false @@ -309,11 +361,11 @@ license = ["editdistance-s"] [[package]] name = "idna" -version = "3.1" +version = "3.2" description = "Internationalized Domain Names in Applications (IDNA)" category = "main" optional = false -python-versions = ">=3.4" +python-versions = ">=3.5" [[package]] name = "imagesize" @@ -325,20 +377,30 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "importlib-metadata" -version = "4.0.0" +version = "4.5.0" description = "Read metadata from Python packages" category = "dev" optional = false python-versions = ">=3.6" [package.dependencies] -typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} zipp = ">=0.5" [package.extras] docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] +[[package]] +name = "incremental" +version = "21.3.0" +description = "A small library that versions your Python projects." +category = "dev" +optional = false +python-versions = "*" + +[package.extras] +scripts = ["click (>=6.0)", "twisted (>=16.4.0)"] + [[package]] name = "iniconfig" version = "1.1.1" @@ -349,7 +411,7 @@ python-versions = "*" [[package]] name = "ipython" -version = "7.22.0" +version = "7.24.1" description = "IPython: Productive Interactive Computing" category = "dev" optional = false @@ -361,6 +423,7 @@ backcall = "*" colorama = {version = "*", markers = "sys_platform == \"win32\""} decorator = "*" jedi = ">=0.16" +matplotlib-inline = "*" pexpect = {version = ">4.3", markers = "sys_platform != \"win32\""} pickleshare = "*" prompt-toolkit = ">=2.0.0,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.1.0" @@ -368,7 +431,7 @@ pygments = "*" traitlets = ">=4.2" [package.extras] -all = ["Sphinx (>=1.3)", "ipykernel", "ipyparallel", "ipywidgets", "nbconvert", "nbformat", "nose (>=0.10.1)", "notebook", "numpy (>=1.16)", "pygments", "qtconsole", "requests", "testpath"] +all = ["Sphinx (>=1.3)", "ipykernel", "ipyparallel", "ipywidgets", "nbconvert", "nbformat", "nose (>=0.10.1)", "notebook", "numpy (>=1.17)", "pygments", "qtconsole", "requests", "testpath"] doc = ["Sphinx (>=1.3)"] kernel = ["ipykernel"] nbconvert = ["nbconvert"] @@ -376,7 +439,7 @@ nbformat = ["nbformat"] notebook = ["notebook", "ipywidgets"] parallel = ["ipyparallel"] qtconsole = ["qtconsole"] -test = ["nose (>=0.10.1)", "requests", "testpath", "pygments", "nbformat", "ipykernel", "numpy (>=1.16)"] +test = ["nose (>=0.10.1)", "requests", "testpath", "pygments", "nbformat", "ipykernel", "numpy (>=1.17)"] [[package]] name = "ipython-genutils" @@ -442,7 +505,7 @@ tornado = {version = "*", markers = "python_version > \"2.7\""} [[package]] name = "magic-filter" -version = "1.0.0a1" +version = "1.0.0" description = "This package provides magic filter based on dynamic attribute getter" category = "main" optional = false @@ -456,9 +519,6 @@ category = "dev" optional = false python-versions = ">=3.6" -[package.dependencies] -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} - [package.extras] testing = ["coverage", "pyyaml"] @@ -475,11 +535,22 @@ markdown = "*" [[package]] name = "markupsafe" -version = "1.1.1" +version = "2.0.1" description = "Safely add untrusted strings to HTML/XML markup." category = "main" optional = false -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" +python-versions = ">=3.6" + +[[package]] +name = "matplotlib-inline" +version = "0.1.2" +description = "Inline Matplotlib backend for Jupyter" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +traitlets = "*" [[package]] name = "mccabe" @@ -587,15 +658,12 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -[package.dependencies] -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} - [package.extras] dev = ["pre-commit", "tox"] [[package]] name = "pre-commit" -version = "2.12.1" +version = "2.13.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." category = "dev" optional = false @@ -604,7 +672,6 @@ python-versions = ">=3.6.1" [package.dependencies] cfgv = ">=2.0.0" identify = ">=1.0.0" -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} nodeenv = ">=0.11.1" pyyaml = ">=5.1" toml = "*" @@ -647,7 +714,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pydantic" -version = "1.8.1" +version = "1.8.2" description = "Data validation and settings management using python 3.6 type hinting" category = "main" optional = false @@ -670,7 +737,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pygments" -version = "2.8.1" +version = "2.9.0" description = "Pygments is a syntax highlighting package written in Python." category = "main" optional = false @@ -678,7 +745,7 @@ python-versions = ">=3.5" [[package]] name = "pymdown-extensions" -version = "8.1.1" +version = "8.2" description = "Extension pack for Python Markdown." category = "dev" optional = false @@ -697,7 +764,7 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "pytest" -version = "6.2.3" +version = "6.2.4" description = "pytest: simple powerful testing with Python" category = "dev" optional = false @@ -707,7 +774,6 @@ python-versions = ">=3.6" atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} attrs = ">=19.2.0" colorama = {version = "*", markers = "sys_platform == \"win32\""} -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} iniconfig = "*" packaging = "*" pluggy = ">=0.12,<1.0.0a1" @@ -733,7 +799,7 @@ testing = ["coverage", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-cov" -version = "2.11.1" +version = "2.12.1" description = "Pytest plugin for measuring coverage." category = "dev" optional = false @@ -742,9 +808,10 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [package.dependencies] coverage = ">=5.2.1" pytest = ">=4.6" +toml = "*" [package.extras] -testing = ["fields", "hunter", "process-tests (==2.0.2)", "six", "pytest-xdist", "virtualenv"] +testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] [[package]] name = "pytest-html" @@ -758,6 +825,17 @@ python-versions = ">=3.6" pytest = ">=5.0,<6.0.0 || >6.0.0" pytest-metadata = "*" +[[package]] +name = "pytest-lazy-fixture" +version = "0.6.3" +description = "It helps to use fixtures in pytest.mark.parametrize" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +pytest = ">=3.2.5" + [[package]] name = "pytest-metadata" version = "1.11.0" @@ -771,7 +849,7 @@ pytest = ">=2.9.0" [[package]] name = "pytest-mock" -version = "3.6.0" +version = "3.6.1" description = "Thin-wrapper around the mock package for easier use with pytest" category = "dev" optional = false @@ -795,7 +873,6 @@ python-versions = ">=3.5" attrs = ">=19.0" filelock = ">=3.0" mypy = [ - {version = ">=0.500", markers = "python_version < \"3.8\""}, {version = ">=0.700", markers = "python_version >= \"3.8\" and python_version < \"3.9\""}, {version = ">=0.780", markers = "python_version >= \"3.9\""}, ] @@ -855,7 +932,7 @@ socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] [[package]] name = "six" -version = "1.15.0" +version = "1.16.0" description = "Python 2 and 3 compatibility utilities" category = "main" optional = false @@ -925,17 +1002,17 @@ test = ["pytest", "pytest-cov"] [[package]] name = "sphinx-copybutton" -version = "0.3.1" +version = "0.3.2" description = "Add a copy button to each of your code cells." category = "main" optional = false -python-versions = "*" +python-versions = ">=3.6" [package.dependencies] sphinx = ">=1.8" [package.extras] -code_style = ["flake8 (>=3.7.0,<3.8.0)", "black", "pre-commit (==1.17.0)"] +code_style = ["pre-commit (==2.12.1)"] [[package]] name = "sphinx-intl" @@ -1008,11 +1085,11 @@ test = ["pytest"] [[package]] name = "sphinxcontrib-htmlhelp" -version = "1.0.3" +version = "2.0.0" description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" category = "main" optional = false -python-versions = ">=3.5" +python-versions = ">=3.6" [package.extras] lint = ["flake8", "mypy", "docutils-stubs"] @@ -1043,7 +1120,7 @@ test = ["pytest"] [[package]] name = "sphinxcontrib-serializinghtml" -version = "1.1.4" +version = "1.1.5" description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." category = "main" optional = false @@ -1069,6 +1146,24 @@ category = "main" optional = false python-versions = ">= 3.5" +[[package]] +name = "towncrier" +version = "21.3.0" +description = "Building newsfiles for your project." +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +click = "*" +click-default-group = "*" +incremental = "*" +jinja2 = "*" +toml = "*" + +[package.extras] +dev = ["packaging"] + [[package]] name = "traitlets" version = "5.0.5" @@ -1093,7 +1188,7 @@ python-versions = "*" [[package]] name = "typing-extensions" -version = "3.7.4.3" +version = "3.10.0.0" description = "Backported and Experimental Type Hints for Python 3.5+" category = "main" optional = false @@ -1114,7 +1209,7 @@ test = ["aiohttp", "flake8 (>=3.8.4,<3.9.0)", "psutil", "pycodestyle (>=2.6.0,<2 [[package]] name = "virtualenv" -version = "20.4.3" +version = "20.4.7" description = "Virtual Python Environment builder" category = "dev" optional = false @@ -1124,7 +1219,6 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" appdirs = ">=1.4.3,<2" distlib = ">=0.3.1,<1" filelock = ">=3.0.0,<4" -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} six = ">=1.9.0,<2" [package.extras] @@ -1150,7 +1244,6 @@ python-versions = ">=3.6" [package.dependencies] idna = ">=2.0" multidict = ">=4.0" -typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} [[package]] name = "zipp" @@ -1168,11 +1261,12 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pyt docs = ["sphinx", "sphinx-intl", "sphinx-autobuild", "sphinx-copybutton", "furo", "sphinx-prompt", "Sphinx-Substitution-Extensions"] fast = [] proxy = ["aiohttp-socks"] +redis = ["aioredis"] [metadata] lock-version = "1.1" -python-versions = "^3.7" -content-hash = "9a787135a6d8ed2a395c07246db424e9961281cf7e0dc00fc08507da3081143e" +python-versions = "^3.8" +content-hash = "e8bc158e14347b3766672505f38ad9d76b1cbf6f9557565e4e56664b0f663717" [metadata.files] aiofiles = [ @@ -1222,6 +1316,10 @@ aiohttp-socks = [ {file = "aiohttp_socks-0.5.5-py3-none-any.whl", hash = "sha256:faaa25ed4dc34440ca888d23e089420f3b1918dc4ecf062c3fd9474827ad6a39"}, {file = "aiohttp_socks-0.5.5.tar.gz", hash = "sha256:2eb2059756bde34c55bb429541cbf2eba3fd53e36ac80875b461221e2858b04a"}, ] +aioredis = [ + {file = "aioredis-2.0.0-py3-none-any.whl", hash = "sha256:9921d68a3df5c5cdb0d5b49ad4fc88a4cfdd60c108325df4f0066e8410c55ffb"}, + {file = "aioredis-2.0.0.tar.gz", hash = "sha256:3a2de4b614e6a5f8e104238924294dc4e811aefbe17ddf52c04a93cbf06e67db"}, +] alabaster = [ {file = "alabaster-0.7.12-py2.py3-none-any.whl", hash = "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359"}, {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"}, @@ -1254,8 +1352,8 @@ atomicwrites = [ {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, ] attrs = [ - {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"}, - {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"}, + {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, + {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, ] babel = [ {file = "Babel-2.9.1-py2.py3-none-any.whl", hash = "sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9"}, @@ -1271,20 +1369,23 @@ beautifulsoup4 = [ {file = "beautifulsoup4-4.9.3.tar.gz", hash = "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25"}, ] black = [ - {file = "black-21.4b2-py3-none-any.whl", hash = "sha256:bff7067d8bc25eb21dcfdbc8c72f2baafd9ec6de4663241a52fb904b304d391f"}, - {file = "black-21.4b2.tar.gz", hash = "sha256:fc9bcf3b482b05c1f35f6a882c079dc01b9c7795827532f4cc43c0ec88067bbc"}, + {file = "black-21.6b0-py3-none-any.whl", hash = "sha256:dfb8c5a069012b2ab1e972e7b908f5fb42b6bbabcba0a788b86dc05067c7d9c7"}, + {file = "black-21.6b0.tar.gz", hash = "sha256:dc132348a88d103016726fe360cb9ede02cecf99b76e3660ce6c596be132ce04"}, ] cfgv = [ - {file = "cfgv-3.2.0-py2.py3-none-any.whl", hash = "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d"}, - {file = "cfgv-3.2.0.tar.gz", hash = "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1"}, + {file = "cfgv-3.3.0-py2.py3-none-any.whl", hash = "sha256:b449c9c6118fe8cca7fa5e00b9ec60ba08145d281d52164230a69211c5d597a1"}, + {file = "cfgv-3.3.0.tar.gz", hash = "sha256:9e600479b3b99e8af981ecdfc80a0296104ee610cab48a5ae4ffd0b668650eb1"}, ] chardet = [ {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"}, ] click = [ - {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, - {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, + {file = "click-8.0.1-py3-none-any.whl", hash = "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"}, + {file = "click-8.0.1.tar.gz", hash = "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a"}, +] +click-default-group = [ + {file = "click-default-group-1.2.2.tar.gz", hash = "sha256:d9560e8e8dfa44b3562fbc9425042a0fd6d21956fcc2db0077f63f34253ab904"}, ] colorama = [ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, @@ -1345,12 +1446,16 @@ coverage = [ {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, ] decorator = [ - {file = "decorator-5.0.7-py3-none-any.whl", hash = "sha256:945d84890bb20cc4a2f4a31fc4311c0c473af65ea318617f13a7257c9a58bc98"}, - {file = "decorator-5.0.7.tar.gz", hash = "sha256:6f201a6c4dac3d187352661f508b9364ec8091217442c9478f1f83c003a0f060"}, + {file = "decorator-5.0.9-py3-none-any.whl", hash = "sha256:6e5c199c16f7a9f0e3a61a4a54b3d27e7dad0dbdde92b944426cb20914376323"}, + {file = "decorator-5.0.9.tar.gz", hash = "sha256:72ecfba4320a893c53f9706bebb2d55c270c1e51a28789361aa93e4a21319ed5"}, +] +diagrams = [ + {file = "diagrams-0.20.0-py3-none-any.whl", hash = "sha256:395391663b4d3f2d3e3614797402ca99494e00baf3926f5c9e72856d34cafedd"}, + {file = "diagrams-0.20.0.tar.gz", hash = "sha256:a50743ed9274e194e7898820f69aa12868ae217003580ef9e7d0285132c9674a"}, ] distlib = [ - {file = "distlib-0.3.1-py2.py3-none-any.whl", hash = "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb"}, - {file = "distlib-0.3.1.zip", hash = "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"}, + {file = "distlib-0.3.2-py2.py3-none-any.whl", hash = "sha256:23e223426b28491b1ced97dc3bbe183027419dfc7982b4fa2f05d5f3ff10711c"}, + {file = "distlib-0.3.2.zip", hash = "sha256:106fef6dc37dd8c0e2c0a60d3fca3e77460a48907f335fa28420463a6f799736"}, ] docutils = [ {file = "docutils-0.17.1-py2.py3-none-any.whl", hash = "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61"}, @@ -1361,40 +1466,48 @@ filelock = [ {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, ] flake8 = [ - {file = "flake8-3.9.1-py2.py3-none-any.whl", hash = "sha256:3b9f848952dddccf635be78098ca75010f073bfe14d2c6bda867154bea728d2a"}, - {file = "flake8-3.9.1.tar.gz", hash = "sha256:1aa8990be1e689d96c745c5682b687ea49f2e05a443aff1f8251092b0014e378"}, + {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, + {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, ] flake8-html = [ {file = "flake8-html-0.4.1.tar.gz", hash = "sha256:2fb436cbfe1e109275bc8fb7fdd0cb00e67b3b48cfeb397309b6b2c61eeb4cb4"}, {file = "flake8_html-0.4.1-py2.py3-none-any.whl", hash = "sha256:17324eb947e7006807e4184ee26953e67baf421b3cf9e646a38bfec34eec5a94"}, ] furo = [ - {file = "furo-2020.12.30b24-py3-none-any.whl", hash = "sha256:251dadee4dee96dddf2dc9b5243b88212e16923f53397bf12bc98574918bda41"}, - {file = "furo-2020.12.30b24.tar.gz", hash = "sha256:30171899c9c06d692a778e6daf6cb2e5cbb05efc6006e1692e5e776007dc8a8c"}, + {file = "furo-2021.6.18b36-py3-none-any.whl", hash = "sha256:a4c00634afeb5896a34d141a5dffb62f20c5eca7831b78269823a8cd8b09a5e4"}, + {file = "furo-2021.6.18b36.tar.gz", hash = "sha256:46a30bc597a9067088d39d730e7d9bf6c1a1d71967e4af062f796769f66b3bdb"}, +] +graphviz = [ + {file = "graphviz-0.16-py2.py3-none-any.whl", hash = "sha256:3cad5517c961090dfc679df6402a57de62d97703e2880a1a46147bb0dc1639eb"}, + {file = "graphviz-0.16.zip", hash = "sha256:d2d25af1c199cad567ce4806f0449cb74eb30cf451fd7597251e1da099ac6e57"}, ] identify = [ - {file = "identify-2.2.3-py2.py3-none-any.whl", hash = "sha256:398cb92a7599da0b433c65301a1b62b9b1f4bb8248719b84736af6c0b22289d6"}, - {file = "identify-2.2.3.tar.gz", hash = "sha256:4537474817e0bbb8cea3e5b7504b7de6d44e3f169a90846cbc6adb0fc8294502"}, + {file = "identify-2.2.10-py2.py3-none-any.whl", hash = "sha256:18d0c531ee3dbc112fa6181f34faa179de3f57ea57ae2899754f16a7e0ff6421"}, + {file = "identify-2.2.10.tar.gz", hash = "sha256:5b41f71471bc738e7b586308c3fca172f78940195cb3bf6734c1e66fdac49306"}, ] idna = [ - {file = "idna-3.1-py3-none-any.whl", hash = "sha256:5205d03e7bcbb919cc9c19885f9920d622ca52448306f2377daede5cf3faac16"}, - {file = "idna-3.1.tar.gz", hash = "sha256:c5b02147e01ea9920e6b0a3f1f7bb833612d507592c837a6c49552768f4054e1"}, + {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"}, + {file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"}, ] imagesize = [ {file = "imagesize-1.2.0-py2.py3-none-any.whl", hash = "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1"}, {file = "imagesize-1.2.0.tar.gz", hash = "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"}, ] importlib-metadata = [ - {file = "importlib_metadata-4.0.0-py3-none-any.whl", hash = "sha256:19192b88d959336bfa6bdaaaef99aeafec179eca19c47c804e555703ee5f07ef"}, - {file = "importlib_metadata-4.0.0.tar.gz", hash = "sha256:2e881981c9748d7282b374b68e759c87745c25427b67ecf0cc67fb6637a1bff9"}, + {file = "importlib_metadata-4.5.0-py3-none-any.whl", hash = "sha256:833b26fb89d5de469b24a390e9df088d4e52e4ba33b01dc5e0e4f41b81a16c00"}, + {file = "importlib_metadata-4.5.0.tar.gz", hash = "sha256:b142cc1dd1342f31ff04bb7d022492b09920cb64fed867cd3ea6f80fe3ebd139"}, +] +incremental = [ + {file = "incremental-21.3.0-py2.py3-none-any.whl", hash = "sha256:92014aebc6a20b78a8084cdd5645eeaa7f74b8933f70fa3ada2cfbd1e3b54321"}, + {file = "incremental-21.3.0.tar.gz", hash = "sha256:02f5de5aff48f6b9f665d99d48bfc7ec03b6e3943210de7cfc88856d755d6f57"}, ] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] ipython = [ - {file = "ipython-7.22.0-py3-none-any.whl", hash = "sha256:c0ce02dfaa5f854809ab7413c601c4543846d9da81010258ecdab299b542d199"}, - {file = "ipython-7.22.0.tar.gz", hash = "sha256:9c900332d4c5a6de534b4befeeb7de44ad0cc42e8327fa41b7685abde58cec74"}, + {file = "ipython-7.24.1-py3-none-any.whl", hash = "sha256:d513e93327cf8657d6467c81f1f894adc125334ffe0e4ddd1abbb1c78d828703"}, + {file = "ipython-7.24.1.tar.gz", hash = "sha256:9bc24a99f5d19721fb8a2d1408908e9c0520a17fff2233ffe82620847f17f1b6"}, ] ipython-genutils = [ {file = "ipython_genutils-0.2.0-py2.py3-none-any.whl", hash = "sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8"}, @@ -1416,8 +1529,8 @@ livereload = [ {file = "livereload-2.6.3.tar.gz", hash = "sha256:776f2f865e59fde56490a56bcc6773b6917366bce0c267c60ee8aaf1a0959869"}, ] magic-filter = [ - {file = "magic-filter-1.0.0a1.tar.gz", hash = "sha256:af77522f1ab2a7aac6a960fb731097ada793da18f7ad96b1e29c11bd9c2d09cd"}, - {file = "magic_filter-1.0.0a1-py3-none-any.whl", hash = "sha256:ae4268493a6955887b63d1deb6f9409c063c7518d5e4bc6feb1dc1ce7ac61a0d"}, + {file = "magic-filter-1.0.0.tar.gz", hash = "sha256:6c1e8d185cd540606555a07a7c78d9c36bf0c97b9cd6e0a00da65dd38d56026f"}, + {file = "magic_filter-1.0.0-py3-none-any.whl", hash = "sha256:37f6c67144cbd087dcc1879f684b3640e13d5c73196544a5a00a6180c5edaa2e"}, ] markdown = [ {file = "Markdown-3.3.4-py3-none-any.whl", hash = "sha256:96c3ba1261de2f7547b46a00ea8463832c921d3f9d6aba3f255a6f71386db20c"}, @@ -1427,58 +1540,44 @@ markdown-include = [ {file = "markdown-include-0.6.0.tar.gz", hash = "sha256:6f5d680e36f7780c7f0f61dca53ca581bd50d1b56137ddcd6353efafa0c3e4a2"}, ] markupsafe = [ - {file = "MarkupSafe-1.1.1-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161"}, - {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"}, - {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183"}, - {file = "MarkupSafe-1.1.1-cp27-cp27m-win32.whl", hash = "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b"}, - {file = "MarkupSafe-1.1.1-cp27-cp27m-win_amd64.whl", hash = "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e"}, - {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f"}, - {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-macosx_10_6_intel.whl", hash = "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-win32.whl", hash = "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-win_amd64.whl", hash = "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-win32.whl", hash = "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-win_amd64.whl", hash = "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d53bc011414228441014aa71dbec320c66468c1030aae3a6e29778a3382d96e5"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:3b8a6499709d29c2e2399569d96719a1b21dcd94410a586a18526b143ec8470f"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:84dee80c15f1b560d55bcfe6d47b27d070b4681c699c572af2e3c7cc90a3b8e0"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:b1dba4527182c95a0db8b6060cc98ac49b9e2f5e64320e2b56e47cb2831978c7"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-win32.whl", hash = "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:bf5aa3cbcfdf57fa2ee9cd1822c862ef23037f5c832ad09cfea57fa846dec193"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:6fffc775d90dcc9aed1b89219549b329a9250d918fd0b8fa8d93d154918422e1"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:a6a744282b7718a2a62d2ed9d993cad6f5f585605ad352c11de459f4108df0a1"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:195d7d2c4fbb0ee8139a6cf67194f3973a6b3042d742ebe0a9ed36d8b6f0c07f"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:acf08ac40292838b3cbbb06cfe9b2cb9ec78fce8baca31ddb87aaac2e2dc3bc2"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:d9be0ba6c527163cbed5e0857c451fcd092ce83947944d6c14bc95441203f032"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:caabedc8323f1e93231b52fc32bdcde6db817623d33e100708d9a68e1f53b26b"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-win32.whl", hash = "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"}, - {file = "MarkupSafe-1.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d73a845f227b0bfe8a7455ee623525ee656a9e2e749e4742706d80a6065d5e2c"}, - {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:98bae9582248d6cf62321dcb52aaf5d9adf0bad3b40582925ef7c7f0ed85fceb"}, - {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:2beec1e0de6924ea551859edb9e7679da6e4870d32cb766240ce17e0a0ba2014"}, - {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:7fed13866cf14bba33e7176717346713881f56d9d2bcebab207f7a036f41b850"}, - {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:6f1e273a344928347c1290119b493a1f0303c52f5a5eae5f16d74f48c15d4a85"}, - {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:feb7b34d6325451ef96bc0e36e1a6c0c1c64bc1fbec4b854f4529e51887b1621"}, - {file = "MarkupSafe-1.1.1-cp39-cp39-win32.whl", hash = "sha256:22c178a091fc6630d0d045bdb5992d2dfe14e3259760e713c490da5323866c39"}, - {file = "MarkupSafe-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7d644ddb4dbd407d31ffb699f1d140bc35478da613b441c582aeb7c43838dd8"}, - {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"}, + {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"}, +] +matplotlib-inline = [ + {file = "matplotlib-inline-0.1.2.tar.gz", hash = "sha256:f41d5ff73c9f5385775d5c0bc13b424535c8402fe70ea8210f93e11f3683993e"}, + {file = "matplotlib_inline-0.1.2-py3-none-any.whl", hash = "sha256:5cf1176f554abb4fa98cb362aa2b55c500147e4bdbb07e3fda359143e1da0811"}, ] mccabe = [ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, @@ -1580,8 +1679,8 @@ pluggy = [ {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, ] pre-commit = [ - {file = "pre_commit-2.12.1-py2.py3-none-any.whl", hash = "sha256:70c5ec1f30406250b706eda35e868b87e3e4ba099af8787e3e8b4b01e84f4712"}, - {file = "pre_commit-2.12.1.tar.gz", hash = "sha256:900d3c7e1bf4cf0374bb2893c24c23304952181405b4d88c9c40b72bda1bb8a9"}, + {file = "pre_commit-2.13.0-py2.py3-none-any.whl", hash = "sha256:b679d0fddd5b9d6d98783ae5f10fd0c4c59954f375b70a58cbe1ce9bcf9809a4"}, + {file = "pre_commit-2.13.0.tar.gz", hash = "sha256:764972c60693dc668ba8e86eb29654ec3144501310f7198742a767bec385a378"}, ] prompt-toolkit = [ {file = "prompt_toolkit-3.0.18-py3-none-any.whl", hash = "sha256:bf00f22079f5fadc949f42ae8ff7f05702826a97059ffcc6281036ad40ac6f04"}, @@ -1600,68 +1699,72 @@ pycodestyle = [ {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, ] pydantic = [ - {file = "pydantic-1.8.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0c40162796fc8d0aa744875b60e4dc36834db9f2a25dbf9ba9664b1915a23850"}, - {file = "pydantic-1.8.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:fff29fe54ec419338c522b908154a2efabeee4f483e48990f87e189661f31ce3"}, - {file = "pydantic-1.8.1-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:fbfb608febde1afd4743c6822c19060a8dbdd3eb30f98e36061ba4973308059e"}, - {file = "pydantic-1.8.1-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:eb8ccf12295113ce0de38f80b25f736d62f0a8d87c6b88aca645f168f9c78771"}, - {file = "pydantic-1.8.1-cp36-cp36m-win_amd64.whl", hash = "sha256:20d42f1be7c7acc352b3d09b0cf505a9fab9deb93125061b376fbe1f06a5459f"}, - {file = "pydantic-1.8.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dde4ca368e82791de97c2ec019681ffb437728090c0ff0c3852708cf923e0c7d"}, - {file = "pydantic-1.8.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:3bbd023c981cbe26e6e21c8d2ce78485f85c2e77f7bab5ec15b7d2a1f491918f"}, - {file = "pydantic-1.8.1-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:830ef1a148012b640186bf4d9789a206c56071ff38f2460a32ae67ca21880eb8"}, - {file = "pydantic-1.8.1-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:fb77f7a7e111db1832ae3f8f44203691e15b1fa7e5a1cb9691d4e2659aee41c4"}, - {file = "pydantic-1.8.1-cp37-cp37m-win_amd64.whl", hash = "sha256:3bcb9d7e1f9849a6bdbd027aabb3a06414abd6068cb3b21c49427956cce5038a"}, - {file = "pydantic-1.8.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2287ebff0018eec3cc69b1d09d4b7cebf277726fa1bd96b45806283c1d808683"}, - {file = "pydantic-1.8.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:4bbc47cf7925c86a345d03b07086696ed916c7663cb76aa409edaa54546e53e2"}, - {file = "pydantic-1.8.1-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:6388ef4ef1435364c8cc9a8192238aed030595e873d8462447ccef2e17387125"}, - {file = "pydantic-1.8.1-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:dd4888b300769ecec194ca8f2699415f5f7760365ddbe243d4fd6581485fa5f0"}, - {file = "pydantic-1.8.1-cp38-cp38-win_amd64.whl", hash = "sha256:8fbb677e4e89c8ab3d450df7b1d9caed23f254072e8597c33279460eeae59b99"}, - {file = "pydantic-1.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2f2736d9a996b976cfdfe52455ad27462308c9d3d0ae21a2aa8b4cd1a78f47b9"}, - {file = "pydantic-1.8.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:3114d74329873af0a0e8004627f5389f3bb27f956b965ddd3e355fe984a1789c"}, - {file = "pydantic-1.8.1-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:258576f2d997ee4573469633592e8b99aa13bda182fcc28e875f866016c8e07e"}, - {file = "pydantic-1.8.1-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:c17a0b35c854049e67c68b48d55e026c84f35593c66d69b278b8b49e2484346f"}, - {file = "pydantic-1.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:e8bc082afef97c5fd3903d05c6f7bb3a6af9fc18631b4cc9fedeb4720efb0c58"}, - {file = "pydantic-1.8.1-py3-none-any.whl", hash = "sha256:e3f8790c47ac42549dc8b045a67b0ca371c7f66e73040d0197ce6172b385e520"}, - {file = "pydantic-1.8.1.tar.gz", hash = "sha256:26cf3cb2e68ec6c0cfcb6293e69fb3450c5fd1ace87f46b64f678b0d29eac4c3"}, + {file = "pydantic-1.8.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:05ddfd37c1720c392f4e0d43c484217b7521558302e7069ce8d318438d297739"}, + {file = "pydantic-1.8.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a7c6002203fe2c5a1b5cbb141bb85060cbff88c2d78eccbc72d97eb7022c43e4"}, + {file = "pydantic-1.8.2-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:589eb6cd6361e8ac341db97602eb7f354551482368a37f4fd086c0733548308e"}, + {file = "pydantic-1.8.2-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:10e5622224245941efc193ad1d159887872776df7a8fd592ed746aa25d071840"}, + {file = "pydantic-1.8.2-cp36-cp36m-win_amd64.whl", hash = "sha256:99a9fc39470010c45c161a1dc584997f1feb13f689ecf645f59bb4ba623e586b"}, + {file = "pydantic-1.8.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a83db7205f60c6a86f2c44a61791d993dff4b73135df1973ecd9eed5ea0bda20"}, + {file = "pydantic-1.8.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:41b542c0b3c42dc17da70554bc6f38cbc30d7066d2c2815a94499b5684582ecb"}, + {file = "pydantic-1.8.2-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:ea5cb40a3b23b3265f6325727ddfc45141b08ed665458be8c6285e7b85bd73a1"}, + {file = "pydantic-1.8.2-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:18b5ea242dd3e62dbf89b2b0ec9ba6c7b5abaf6af85b95a97b00279f65845a23"}, + {file = "pydantic-1.8.2-cp37-cp37m-win_amd64.whl", hash = "sha256:234a6c19f1c14e25e362cb05c68afb7f183eb931dd3cd4605eafff055ebbf287"}, + {file = "pydantic-1.8.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:021ea0e4133e8c824775a0cfe098677acf6fa5a3cbf9206a376eed3fc09302cd"}, + {file = "pydantic-1.8.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e710876437bc07bd414ff453ac8ec63d219e7690128d925c6e82889d674bb505"}, + {file = "pydantic-1.8.2-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:ac8eed4ca3bd3aadc58a13c2aa93cd8a884bcf21cb019f8cfecaae3b6ce3746e"}, + {file = "pydantic-1.8.2-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:4a03cbbe743e9c7247ceae6f0d8898f7a64bb65800a45cbdc52d65e370570820"}, + {file = "pydantic-1.8.2-cp38-cp38-win_amd64.whl", hash = "sha256:8621559dcf5afacf0069ed194278f35c255dc1a1385c28b32dd6c110fd6531b3"}, + {file = "pydantic-1.8.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8b223557f9510cf0bfd8b01316bf6dd281cf41826607eada99662f5e4963f316"}, + {file = "pydantic-1.8.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:244ad78eeb388a43b0c927e74d3af78008e944074b7d0f4f696ddd5b2af43c62"}, + {file = "pydantic-1.8.2-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:05ef5246a7ffd2ce12a619cbb29f3307b7c4509307b1b49f456657b43529dc6f"}, + {file = "pydantic-1.8.2-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:54cd5121383f4a461ff7644c7ca20c0419d58052db70d8791eacbbe31528916b"}, + {file = "pydantic-1.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:4be75bebf676a5f0f87937c6ddb061fa39cbea067240d98e298508c1bda6f3f3"}, + {file = "pydantic-1.8.2-py3-none-any.whl", hash = "sha256:fec866a0b59f372b7e776f2d7308511784dace622e0992a0b59ea3ccee0ae833"}, + {file = "pydantic-1.8.2.tar.gz", hash = "sha256:26464e57ccaafe72b7ad156fdaa4e9b9ef051f69e175dbbb463283000c05ab7b"}, ] pyflakes = [ {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, ] pygments = [ - {file = "Pygments-2.8.1-py3-none-any.whl", hash = "sha256:534ef71d539ae97d4c3a4cf7d6f110f214b0e687e92f9cb9d2a3b0d3101289c8"}, - {file = "Pygments-2.8.1.tar.gz", hash = "sha256:2656e1a6edcdabf4275f9a3640db59fd5de107d88e8663c5d4e9a0fa62f77f94"}, + {file = "Pygments-2.9.0-py3-none-any.whl", hash = "sha256:d66e804411278594d764fc69ec36ec13d9ae9147193a1740cd34d272ca383b8e"}, + {file = "Pygments-2.9.0.tar.gz", hash = "sha256:a18f47b506a429f6f4b9df81bb02beab9ca21d0a5fee38ed15aef65f0545519f"}, ] pymdown-extensions = [ - {file = "pymdown-extensions-8.1.1.tar.gz", hash = "sha256:632371fa3bf1b21a0e3f4063010da59b41db049f261f4c0b0872069a9b6d1735"}, - {file = "pymdown_extensions-8.1.1-py3-none-any.whl", hash = "sha256:478b2c04513fbb2db61688d5f6e9030a92fb9be14f1f383535c43f7be9dff95b"}, + {file = "pymdown-extensions-8.2.tar.gz", hash = "sha256:b6daa94aad9e1310f9c64c8b1f01e4ce82937ab7eb53bfc92876a97aca02a6f4"}, + {file = "pymdown_extensions-8.2-py3-none-any.whl", hash = "sha256:141452d8ed61165518f2c923454bf054866b85cf466feedb0eb68f04acdc2560"}, ] pyparsing = [ {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, ] pytest = [ - {file = "pytest-6.2.3-py3-none-any.whl", hash = "sha256:6ad9c7bdf517a808242b998ac20063c41532a570d088d77eec1ee12b0b5574bc"}, - {file = "pytest-6.2.3.tar.gz", hash = "sha256:671238a46e4df0f3498d1c3270e5deb9b32d25134c99b7d75370a68cfbe9b634"}, + {file = "pytest-6.2.4-py3-none-any.whl", hash = "sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890"}, + {file = "pytest-6.2.4.tar.gz", hash = "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b"}, ] pytest-asyncio = [ {file = "pytest-asyncio-0.15.1.tar.gz", hash = "sha256:2564ceb9612bbd560d19ca4b41347b54e7835c2f792c504f698e05395ed63f6f"}, {file = "pytest_asyncio-0.15.1-py3-none-any.whl", hash = "sha256:3042bcdf1c5d978f6b74d96a151c4cfb9dcece65006198389ccd7e6c60eb1eea"}, ] pytest-cov = [ - {file = "pytest-cov-2.11.1.tar.gz", hash = "sha256:359952d9d39b9f822d9d29324483e7ba04a3a17dd7d05aa6beb7ea01e359e5f7"}, - {file = "pytest_cov-2.11.1-py2.py3-none-any.whl", hash = "sha256:bdb9fdb0b85a7cc825269a4c56b48ccaa5c7e365054b6038772c32ddcdc969da"}, + {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, + {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"}, ] pytest-html = [ {file = "pytest-html-3.1.1.tar.gz", hash = "sha256:3ee1cf319c913d19fe53aeb0bc400e7b0bc2dbeb477553733db1dad12eb75ee3"}, {file = "pytest_html-3.1.1-py3-none-any.whl", hash = "sha256:b7f82f123936a3f4d2950bc993c2c1ca09ce262c9ae12f9ac763a2401380b455"}, ] +pytest-lazy-fixture = [ + {file = "pytest-lazy-fixture-0.6.3.tar.gz", hash = "sha256:0e7d0c7f74ba33e6e80905e9bfd81f9d15ef9a790de97993e34213deb5ad10ac"}, + {file = "pytest_lazy_fixture-0.6.3-py3-none-any.whl", hash = "sha256:e0b379f38299ff27a653f03eaa69b08a6fd4484e46fd1c9907d984b9f9daeda6"}, +] pytest-metadata = [ {file = "pytest-metadata-1.11.0.tar.gz", hash = "sha256:71b506d49d34e539cc3cfdb7ce2c5f072bea5c953320002c95968e0238f8ecf1"}, {file = "pytest_metadata-1.11.0-py2.py3-none-any.whl", hash = "sha256:576055b8336dd4a9006dd2a47615f76f2f8c30ab12b1b1c039d99e834583523f"}, ] pytest-mock = [ - {file = "pytest-mock-3.6.0.tar.gz", hash = "sha256:f7c3d42d6287f4e45846c8231c31902b6fa2bea98735af413a43da4cf5b727f1"}, - {file = "pytest_mock-3.6.0-py3-none-any.whl", hash = "sha256:952139a535b5b48ac0bb2f90b5dd36b67c7e1ba92601f3a8012678c4bd7f0bcc"}, + {file = "pytest-mock-3.6.1.tar.gz", hash = "sha256:40217a058c52a63f1042f0784f62009e976ba824c418cced42e88d5f40ab0e62"}, + {file = "pytest_mock-3.6.1-py3-none-any.whl", hash = "sha256:30c2f2cc9759e76eee674b81ea28c9f0b94f8f0445a1b87762cadf774f0df7e3"}, ] pytest-mypy = [ {file = "pytest-mypy-0.8.1.tar.gz", hash = "sha256:1fa55723a4bf1d054fcba1c3bd694215a2a65cc95ab10164f5808afd893f3b11"}, @@ -1754,8 +1857,8 @@ requests = [ {file = "requests-2.15.1.tar.gz", hash = "sha256:e5659b9315a0610505e050bb7190bf6fa2ccee1ac295f2b760ef9d8a03ebbb2e"}, ] six = [ - {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, - {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] snowballstemmer = [ {file = "snowballstemmer-2.1.0-py2.py3-none-any.whl", hash = "sha256:b51b447bea85f9968c13b650126a888aabd4cb4463fca868ec596826325dedc2"}, @@ -1774,8 +1877,8 @@ sphinx-autobuild = [ {file = "sphinx_autobuild-2020.9.1-py3-none-any.whl", hash = "sha256:df5c72cb8b8fc9b31279c4619780c4e95029be6de569ff60a8bb2e99d20f63dd"}, ] sphinx-copybutton = [ - {file = "sphinx-copybutton-0.3.1.tar.gz", hash = "sha256:0e0461df394515284e3907e3f418a0c60ef6ab6c9a27a800c8552772d0a402a2"}, - {file = "sphinx_copybutton-0.3.1-py3-none-any.whl", hash = "sha256:5125c718e763596e6e52d92e15ee0d6f4800ad3817939be6dee51218870b3e3d"}, + {file = "sphinx-copybutton-0.3.2.tar.gz", hash = "sha256:f901f17e7dadc063bcfca592c5160f9113ec17501a59e046af3edb82b7527656"}, + {file = "sphinx_copybutton-0.3.2-py3-none-any.whl", hash = "sha256:f16f8ed8dfc60f2b34a58cb69bfa04722e24be2f6d7e04db5554c32cde4df815"}, ] sphinx-intl = [ {file = "sphinx-intl-2.0.1.tar.gz", hash = "sha256:b25a6ec169347909e8d983eefe2d8adecb3edc2f27760db79b965c69950638b4"}, @@ -1798,8 +1901,8 @@ sphinxcontrib-devhelp = [ {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, ] sphinxcontrib-htmlhelp = [ - {file = "sphinxcontrib-htmlhelp-1.0.3.tar.gz", hash = "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b"}, - {file = "sphinxcontrib_htmlhelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f"}, + {file = "sphinxcontrib-htmlhelp-2.0.0.tar.gz", hash = "sha256:f5f8bb2d0d629f398bf47d0d69c07bc13b65f75a81ad9e2f71a63d4b7a2f6db2"}, + {file = "sphinxcontrib_htmlhelp-2.0.0-py2.py3-none-any.whl", hash = "sha256:d412243dfb797ae3ec2b59eca0e52dac12e75a241bf0e4eb861e450d06c6ed07"}, ] sphinxcontrib-jsmath = [ {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, @@ -1810,8 +1913,8 @@ sphinxcontrib-qthelp = [ {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"}, ] sphinxcontrib-serializinghtml = [ - {file = "sphinxcontrib-serializinghtml-1.1.4.tar.gz", hash = "sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc"}, - {file = "sphinxcontrib_serializinghtml-1.1.4-py2.py3-none-any.whl", hash = "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a"}, + {file = "sphinxcontrib-serializinghtml-1.1.5.tar.gz", hash = "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"}, + {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"}, ] toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, @@ -1860,6 +1963,10 @@ tornado = [ {file = "tornado-6.1-cp39-cp39-win_amd64.whl", hash = "sha256:548430be2740e327b3fe0201abe471f314741efcb0067ec4f2d7dcfb4825f3e4"}, {file = "tornado-6.1.tar.gz", hash = "sha256:33c6e81d7bd55b468d2e793517c909b139960b6c790a60b7991b9b6b76fb9791"}, ] +towncrier = [ + {file = "towncrier-21.3.0-py2.py3-none-any.whl", hash = "sha256:e6ccec65418bbcb8de5c908003e130e37fe0e9d6396cb77c1338241071edc082"}, + {file = "towncrier-21.3.0.tar.gz", hash = "sha256:6eed0bc924d72c98c000cb8a64de3bd566e5cb0d11032b73fcccf8a8f956ddfe"}, +] traitlets = [ {file = "traitlets-5.0.5-py3-none-any.whl", hash = "sha256:69ff3f9d5351f31a7ad80443c2674b7099df13cc41fc5fa6e2f6d3b0330b0426"}, {file = "traitlets-5.0.5.tar.gz", hash = "sha256:178f4ce988f69189f7e523337a3e11d91c786ded9360174a3d9ca83e79bc5396"}, @@ -1897,9 +2004,9 @@ typed-ast = [ {file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"}, ] typing-extensions = [ - {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, - {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"}, - {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, + {file = "typing_extensions-3.10.0.0-py2-none-any.whl", hash = "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497"}, + {file = "typing_extensions-3.10.0.0-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"}, + {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"}, ] uvloop = [ {file = "uvloop-0.15.2-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:19fa1d56c91341318ac5d417e7b61c56e9a41183946cc70c411341173de02c69"}, @@ -1914,8 +2021,8 @@ uvloop = [ {file = "uvloop-0.15.2.tar.gz", hash = "sha256:2bb0624a8a70834e54dde8feed62ed63b50bad7a1265c40d6403a2ac447bce01"}, ] virtualenv = [ - {file = "virtualenv-20.4.3-py2.py3-none-any.whl", hash = "sha256:83f95875d382c7abafe06bd2a4cdd1b363e1bb77e02f155ebe8ac082a916b37c"}, - {file = "virtualenv-20.4.3.tar.gz", hash = "sha256:49ec4eb4c224c6f7dd81bb6d0a28a09ecae5894f4e593c89b0db0885f565a107"}, + {file = "virtualenv-20.4.7-py2.py3-none-any.whl", hash = "sha256:2b0126166ea7c9c3661f5b8e06773d28f83322de7a3ff7d06f0aed18c9de6a76"}, + {file = "virtualenv-20.4.7.tar.gz", hash = "sha256:14fdf849f80dbb29a4eb6caa9875d476ee2a5cf76a5f5415fa2f1606010ab467"}, ] wcwidth = [ {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, diff --git a/pyproject.toml b/pyproject.toml index a480cbb9..8ddeeaaa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "aiogram" -version = "3.0.0-alpha.8" +version = "3.0.0-alpha.14" description = "Modern and fully asynchronous framework for Telegram Bot API" authors = ["Alex Root Junior "] license = "MIT" @@ -23,7 +23,6 @@ classifiers = [ "Intended Audience :: Developers", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Application Frameworks", @@ -32,25 +31,26 @@ classifiers = [ ] [tool.poetry.dependencies] -python = "^3.7" +python = "^3.8" +magic-filter = "^1.0.0" aiohttp = "^3.7.4" pydantic = "^1.8.1" Babel = "^2.9.1" aiofiles = "^0.6.0" async_lru = "^1.0.2" aiohttp-socks = { version = "^0.5.5", optional = true } -typing-extensions = { version = "^3.7.4", python = "<3.8" } -magic-filter = {version = "1.0.0a1", allow-prereleases = true} +aioredis = { version = "^2.0.0", allow-prereleases = true, optional = true } sphinx = { version = "^3.1.0", optional = true } sphinx-intl = { version = "^2.0.1", optional = true } sphinx-autobuild = { version = "^2020.9.1", optional = true } sphinx-copybutton = { version = "^0.3.1", optional = true } -furo = { version = "^2020.11.15-beta.17", optional = true } +furo = { version = "^2021.6.18-beta.36", optional = true } sphinx-prompt = { version = "^1.3.0", optional = true } Sphinx-Substitution-Extensions = { version = "^2020.9.30", optional = true } [tool.poetry.dev-dependencies] aiohttp-socks = "^0.5" +aioredis = { version = "^2.0.0a1", allow-prereleases = true } ipython = "^7.22.0" uvloop = { version = "^0.15.2", markers = "sys_platform == 'darwin' or sys_platform == 'linux'" } black = "^21.4b2" @@ -61,8 +61,9 @@ mypy = "^0.812" pytest = "^6.2.3" pytest-html = "^3.1.1" pytest-asyncio = "^0.15.1" -pytest-mypy = "^0.8.1" +pytest-lazy-fixture = "^0.6.3" pytest-mock = "^3.6.0" +pytest-mypy = "^0.8.1" pytest-cov = "^2.11.1" aresponses = "^2.1.4" asynctest = "^0.13.0" @@ -77,12 +78,16 @@ sphinx = "^3.1.0" sphinx-intl = "^2.0.1" sphinx-autobuild = "^2020.9.1" sphinx-copybutton = "^0.3.1" -furo = "^2020.11.15-beta.17" +furo = "^2021.6.18-beta.36" sphinx-prompt = "^1.3.0" Sphinx-Substitution-Extensions = "^2020.9.30" +towncrier = "^21.3.0" +diagrams = "^0.20.0" + [tool.poetry.extras] fast = ["uvloop"] +redis = ["aioredis"] proxy = ["aiohttp-socks"] docs = [ "sphinx", @@ -128,6 +133,41 @@ known_third_party = [ "pytest" ] +[tool.towncrier] +package = "aiogram" +filename = "CHANGES.rst" +directory = "CHANGES/" +template = "CHANGES/.template.rst.jinja2" +issue_format = "`#{issue} `_" + +[[tool.towncrier.section]] +path = "" + +[[tool.towncrier.type]] +directory = "feature" +name = "Features" +showcontent = true + +[[tool.towncrier.type]] +directory = "bugfix" +name = "Bugfixes" +showcontent = true + +[[tool.towncrier.type]] +directory = "doc" +name = "Improved Documentation" +showcontent = true + +[[tool.towncrier.type]] +directory = "removal" +name = "Deprecations and Removals" +showcontent = true + +[[tool.towncrier.type]] +directory = "misc" +name = "Misc" +showcontent = true + [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" diff --git a/scripts/bump_versions.py b/scripts/bump_versions.py index 28072c9c..68547d12 100644 --- a/scripts/bump_versions.py +++ b/scripts/bump_versions.py @@ -3,7 +3,7 @@ from pathlib import Path import toml -BASE_PATTERN = r'({variable} = ")[a-z0-9.+]+(")' +BASE_PATTERN = r'({variable} = ").+(")' PACKAGE_VERSION = re.compile(BASE_PATTERN.format(variable="__version__")) API_VERSION = re.compile(BASE_PATTERN.format(variable="__api_version__")) API_VERSION_BADGE = re.compile(r"(API-)[\d.]+(-blue\.svg)") @@ -62,23 +62,13 @@ def write_readme(package_version: str, api_version: str) -> None: def write_docs_index(package_version: str, api_version: str) -> None: - path = Path.cwd() / "docs2" / "index.rst" + path = Path.cwd() / "docs" / "index.rst" content = path.read_text() content = replace_line(content, API_VERSION_BADGE, api_version) print(f"Write {path}") path.write_text(content) -def write_docs_meta(package_version: str, api_version: str) -> None: - api_meta = Path.cwd() / "docs" / "_api_version.md" - package_meta = Path.cwd() / "docs" / "_package_version.md" - - print(f"Write {api_meta}") - api_meta.write_text(api_version + "\n") - print(f"Write {package_meta}") - package_meta.write_text(package_version + "\n") - - def main(): package_version = get_package_version() api_version = get_telegram_api_version() @@ -86,7 +76,6 @@ def main(): print(f"Package version: {package_version}") print(f"Telegram Bot API version: {api_version}") write_package_meta(package_version=package_version, api_version=api_version) - write_docs_meta(package_version=package_version, api_version=api_version) write_readme(package_version=package_version, api_version=api_version) write_docs_index(package_version=package_version, api_version=api_version) diff --git a/tests/conftest.py b/tests/conftest.py index 60d9d0fe..f2d0cebc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,74 @@ import pytest +from _pytest.config import UsageError +from aioredis.connection import parse_url as parse_redis_url from aiogram import Bot +from aiogram.dispatcher.fsm.storage.memory import MemoryStorage +from aiogram.dispatcher.fsm.storage.redis import RedisStorage from tests.mocked_bot import MockedBot +def pytest_addoption(parser): + parser.addoption("--redis", default=None, help="run tests which require redis connection") + + +def pytest_configure(config): + config.addinivalue_line("markers", "redis: marked tests require redis connection to run") + + +def pytest_collection_modifyitems(config, items): + redis_uri = config.getoption("--redis") + if redis_uri is None: + skip_redis = pytest.mark.skip(reason="need --redis option with redis URI to run") + for item in items: + if "redis" in item.keywords: + item.add_marker(skip_redis) + return + try: + parse_redis_url(redis_uri) + except ValueError as e: + raise UsageError(f"Invalid redis URI {redis_uri!r}: {e}") + + +@pytest.fixture(scope="session") +def redis_server(request): + redis_uri = request.config.getoption("--redis") + return redis_uri + + +@pytest.fixture() +@pytest.mark.redis +async def redis_storage(redis_server): + if not redis_server: + pytest.skip("Redis is not available here") + storage = RedisStorage.from_url(redis_server) + try: + await storage.redis.info() + except ConnectionError as e: + pytest.skip(str(e)) + try: + yield storage + finally: + conn = await storage.redis + await conn.flushdb() + await storage.close() + + +@pytest.fixture() +async def memory_storage(): + storage = MemoryStorage() + try: + yield storage + finally: + await storage.close() + + @pytest.fixture() def bot(): bot = MockedBot() token = Bot.set_current(bot) - yield bot - Bot.reset_current(token) - bot.me.invalidate(bot) + try: + yield bot + finally: + Bot.reset_current(token) + bot.me.invalidate(bot) diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml new file mode 100644 index 00000000..453f5e5a --- /dev/null +++ b/tests/docker-compose.yml @@ -0,0 +1,7 @@ +version: "3.9" + +services: + redis: + image: redis:6-alpine + ports: + - "${REDIS_PORT-6379}:6379" diff --git a/tests/mocked_bot.py b/tests/mocked_bot.py index aee31398..76abc445 100644 --- a/tests/mocked_bot.py +++ b/tests/mocked_bot.py @@ -4,17 +4,17 @@ from typing import TYPE_CHECKING, AsyncGenerator, Deque, Optional, Type from aiogram import Bot from aiogram.client.session.base import BaseSession from aiogram.methods import TelegramMethod -from aiogram.methods.base import Request, Response, T -from aiogram.types import UNSET +from aiogram.methods.base import Request, Response, TelegramType +from aiogram.types import UNSET, ResponseParameters class MockedSession(BaseSession): def __init__(self): super(MockedSession, self).__init__() - self.responses: Deque[Response[T]] = deque() + self.responses: Deque[Response[TelegramType]] = deque() self.requests: Deque[Request] = deque() - def add_result(self, response: Response[T]) -> Response[T]: + def add_result(self, response: Response[TelegramType]) -> Response[TelegramType]: self.responses.append(response) return response @@ -25,11 +25,13 @@ class MockedSession(BaseSession): pass async def make_request( - self, bot: Bot, method: TelegramMethod[T], timeout: Optional[int] = UNSET - ) -> T: + self, bot: Bot, method: TelegramMethod[TelegramType], timeout: Optional[int] = UNSET + ) -> TelegramType: self.requests.append(method.build_request(bot)) - response: Response[T] = self.responses.pop() - self.raise_for_status(response) + response: Response[TelegramType] = self.responses.pop() + self.check_response( + method=method, status_code=response.error_code, content=response.json() + ) return response.result # type: ignore async def stream_content( @@ -47,21 +49,23 @@ class MockedBot(Bot): def add_result_for( self, - method: Type[TelegramMethod[T]], + method: Type[TelegramMethod[TelegramType]], ok: bool, - result: T = None, + result: TelegramType = None, description: Optional[str] = None, - error_code: Optional[int] = None, + error_code: int = 200, migrate_to_chat_id: Optional[int] = None, retry_after: Optional[int] = None, - ) -> Response[T]: + ) -> Response[TelegramType]: response = Response[method.__returning__]( # type: ignore ok=ok, result=result, description=description, error_code=error_code, - migrate_to_chat_id=migrate_to_chat_id, - retry_after=retry_after, + parameters=ResponseParameters( + migrate_to_chat_id=migrate_to_chat_id, + retry_after=retry_after, + ), ) self.session.add_result(response) return response diff --git a/tests/test_api/test_client/test_bot.py b/tests/test_api/test_client/test_bot.py index a3c11bd9..b36006cc 100644 --- a/tests/test_api/test_client/test_bot.py +++ b/tests/test_api/test_client/test_bot.py @@ -16,6 +16,8 @@ except ImportError: from unittest.mock import AsyncMock as CoroutineMock # type: ignore from unittest.mock import patch +pytestmark = pytest.mark.asyncio + class TestBot: def test_init(self): @@ -32,7 +34,6 @@ class TestBot: assert bot == Bot("42:TEST") assert bot != "42:TEST" - @pytest.mark.asyncio async def test_emit(self): bot = Bot("42:TEST") @@ -45,7 +46,6 @@ class TestBot: await bot(method) mocked_make_request.assert_awaited_with(bot, method, timeout=None) - @pytest.mark.asyncio async def test_close(self): session = AiohttpSession() bot = Bot("42:TEST", session=session) @@ -57,7 +57,6 @@ class TestBot: await bot.session.close() mocked_close.assert_awaited() - @pytest.mark.asyncio @pytest.mark.parametrize("close", [True, False]) async def test_context_manager(self, close: bool): with patch( @@ -70,7 +69,6 @@ class TestBot: else: mocked_close.assert_not_awaited() - @pytest.mark.asyncio async def test_download_file(self, aresponses: ResponsesMockServer): aresponses.add( aresponses.ANY, aresponses.ANY, "get", aresponses.Response(status=200, body=b"\f" * 10) @@ -88,7 +86,6 @@ class TestBot: await bot.download_file("TEST", "file.png") mock_file.write.assert_called_once_with(b"\f" * 10) - @pytest.mark.asyncio async def test_download_file_default_destination(self, aresponses: ResponsesMockServer): bot = Bot("42:TEST") @@ -101,7 +98,6 @@ class TestBot: assert isinstance(result, io.BytesIO) assert result.read() == b"\f" * 10 - @pytest.mark.asyncio async def test_download_file_custom_destination(self, aresponses: ResponsesMockServer): bot = Bot("42:TEST") @@ -117,7 +113,6 @@ class TestBot: assert result is custom assert result.read() == b"\f" * 10 - @pytest.mark.asyncio async def test_download(self, bot: MockedBot, aresponses: ResponsesMockServer): bot.add_result_for( GetFile, ok=True, result=File(file_id="file id", file_unique_id="file id") diff --git a/tests/test_api/test_client/test_session/test_aiohttp_session.py b/tests/test_api/test_client/test_session/test_aiohttp_session.py index 42ee7e3e..36c9e17a 100644 --- a/tests/test_api/test_client/test_session/test_aiohttp_session.py +++ b/tests/test_api/test_client/test_session/test_aiohttp_session.py @@ -1,7 +1,9 @@ +import asyncio from typing import AsyncContextManager, AsyncGenerator import aiohttp_socks import pytest +from aiohttp import ClientError from aresponses import ResponsesMockServer from aiogram import Bot @@ -9,6 +11,7 @@ from aiogram.client.session import aiohttp from aiogram.client.session.aiohttp import AiohttpSession from aiogram.methods import Request, TelegramMethod from aiogram.types import UNSET, InputFile +from aiogram.utils.exceptions.network import NetworkError from tests.mocked_bot import MockedBot try: @@ -17,6 +20,8 @@ except ImportError: from unittest.mock import AsyncMock as CoroutineMock # type: ignore from unittest.mock import patch +pytestmark = pytest.mark.asyncio + class BareInputFile(InputFile): async def read(self, chunk_size: int): @@ -24,7 +29,6 @@ class BareInputFile(InputFile): class TestAiohttpSession: - @pytest.mark.asyncio async def test_create_session(self): session = AiohttpSession() @@ -33,7 +37,6 @@ class TestAiohttpSession: assert session._session is not None assert isinstance(aiohttp_session, aiohttp.ClientSession) - @pytest.mark.asyncio async def test_create_proxy_session(self): session = AiohttpSession( proxy=("socks5://proxy.url/", aiohttp.BasicAuth("login", "password", "encoding")) @@ -47,7 +50,6 @@ class TestAiohttpSession: aiohttp_session = await session.create_session() assert isinstance(aiohttp_session.connector, aiohttp_socks.ProxyConnector) - @pytest.mark.asyncio async def test_create_proxy_session_proxy_url(self): session = AiohttpSession(proxy="socks4://proxy.url/") @@ -59,7 +61,6 @@ class TestAiohttpSession: aiohttp_session = await session.create_session() assert isinstance(aiohttp_session.connector, aiohttp_socks.ProxyConnector) - @pytest.mark.asyncio async def test_create_proxy_session_chained_proxies(self): session = AiohttpSession( proxy=[ @@ -86,7 +87,6 @@ class TestAiohttpSession: aiohttp_session = await session.create_session() assert isinstance(aiohttp_session.connector, aiohttp_socks.ChainProxyConnector) - @pytest.mark.asyncio async def test_reset_connector(self): session = AiohttpSession() assert session._should_reset_connector @@ -102,7 +102,6 @@ class TestAiohttpSession: assert session._should_reset_connector is False await session.close() - @pytest.mark.asyncio async def test_close_session(self): session = AiohttpSession() await session.create_session() @@ -150,7 +149,6 @@ class TestAiohttpSession: assert fields[1][0]["filename"] == "file.txt" assert isinstance(fields[1][2], BareInputFile) - @pytest.mark.asyncio async def test_make_request(self, bot: MockedBot, aresponses: ResponsesMockServer): aresponses.add( aresponses.ANY, @@ -172,16 +170,26 @@ class TestAiohttpSession: return Request(method="method", data={}) call = TestMethod() + + result = await session.make_request(bot, call) + assert isinstance(result, int) + assert result == 42 + + @pytest.mark.parametrize("error", [ClientError("mocked"), asyncio.TimeoutError()]) + async def test_make_request_network_error(self, error): + bot = Bot("42:TEST") + + async def side_effect(*args, **kwargs): + raise error + with patch( - "aiogram.client.session.base.BaseSession.raise_for_status" - ) as patched_raise_for_status: - result = await session.make_request(bot, call) - assert isinstance(result, int) - assert result == 42 + "aiohttp.client.ClientSession._request", + new_callable=CoroutineMock, + side_effect=side_effect, + ): + with pytest.raises(NetworkError): + await bot.get_me() - assert patched_raise_for_status.called_once() - - @pytest.mark.asyncio async def test_stream_content(self, aresponses: ResponsesMockServer): aresponses.add( aresponses.ANY, aresponses.ANY, "get", aresponses.Response(status=200, body=b"\f" * 10) @@ -201,7 +209,6 @@ class TestAiohttpSession: size += chunk_size assert size == 10 - @pytest.mark.asyncio async def test_context_manager(self): session = AiohttpSession() assert isinstance(session, AsyncContextManager) diff --git a/tests/test_api/test_client/test_session/test_base_session.py b/tests/test_api/test_client/test_session/test_base_session.py index 3ed8a126..d4e28293 100644 --- a/tests/test_api/test_client/test_session/test_base_session.py +++ b/tests/test_api/test_client/test_session/test_base_session.py @@ -4,10 +4,20 @@ from typing import AsyncContextManager, AsyncGenerator, Optional import pytest -from aiogram.client.session.base import BaseSession, T +from aiogram import Bot +from aiogram.client.session.base import BaseSession, TelegramType from aiogram.client.telegram import PRODUCTION, TelegramAPIServer -from aiogram.methods import GetMe, Response, TelegramMethod -from aiogram.types import UNSET +from aiogram.methods import DeleteMessage, GetMe, TelegramMethod +from aiogram.types import UNSET, User +from aiogram.utils.exceptions.bad_request import BadRequest +from aiogram.utils.exceptions.base import TelegramAPIError +from aiogram.utils.exceptions.conflict import ConflictError +from aiogram.utils.exceptions.network import EntityTooLarge +from aiogram.utils.exceptions.not_found import NotFound +from aiogram.utils.exceptions.server import RestartingTelegram, ServerError +from aiogram.utils.exceptions.special import MigrateToChat, RetryAfter +from aiogram.utils.exceptions.unauthorized import UnauthorizedError +from tests.mocked_bot import MockedBot try: from asynctest import CoroutineMock, patch @@ -15,12 +25,16 @@ except ImportError: from unittest.mock import AsyncMock as CoroutineMock # type: ignore from unittest.mock import patch +pytestmark = pytest.mark.asyncio + class CustomSession(BaseSession): async def close(self): pass - async def make_request(self, token: str, method: TelegramMethod[T], timeout: Optional[int] = UNSET) -> None: # type: ignore + async def make_request( + self, token: str, method: TelegramMethod[TelegramType], timeout: Optional[int] = UNSET + ) -> None: # type: ignore assert isinstance(token, str) assert isinstance(method, TelegramMethod) @@ -135,20 +149,59 @@ class TestBaseSession: assert session.clean_json(42) == 42 - def test_raise_for_status(self): + @pytest.mark.parametrize( + "status_code,content,error", + [ + [200, '{"ok":true,"result":true}', None], + [400, '{"ok":false,"description":"test"}', BadRequest], + [ + 400, + '{"ok":false,"description":"test", "parameters": {"retry_after": 1}}', + RetryAfter, + ], + [ + 400, + '{"ok":false,"description":"test", "parameters": {"migrate_to_chat_id": -42}}', + MigrateToChat, + ], + [404, '{"ok":false,"description":"test"}', NotFound], + [401, '{"ok":false,"description":"test"}', UnauthorizedError], + [403, '{"ok":false,"description":"test"}', UnauthorizedError], + [409, '{"ok":false,"description":"test"}', ConflictError], + [413, '{"ok":false,"description":"test"}', EntityTooLarge], + [500, '{"ok":false,"description":"restarting"}', RestartingTelegram], + [500, '{"ok":false,"description":"test"}', ServerError], + [502, '{"ok":false,"description":"test"}', ServerError], + [499, '{"ok":false,"description":"test"}', TelegramAPIError], + [499, '{"ok":false,"description":"test"}', TelegramAPIError], + ], + ) + def test_check_response(self, status_code, content, error): session = CustomSession() + method = DeleteMessage(chat_id=42, message_id=42) + if error is None: + session.check_response( + method=method, + status_code=status_code, + content=content, + ) + else: + with pytest.raises(error) as exc_info: + session.check_response( + method=method, + status_code=status_code, + content=content, + ) + error: TelegramAPIError = exc_info.value + string = str(error) + if error.url: + assert error.url in string - session.raise_for_status(Response[bool](ok=True, result=True)) - with pytest.raises(Exception): - session.raise_for_status(Response[bool](ok=False, description="Error", error_code=400)) - - @pytest.mark.asyncio async def test_make_request(self): session = CustomSession() assert await session.make_request("42:TEST", GetMe()) is None - @pytest.mark.asyncio async def test_stream_content(self): session = CustomSession() stream = session.stream_content( @@ -159,7 +212,6 @@ class TestBaseSession: async for chunk in stream: assert isinstance(chunk, bytes) - @pytest.mark.asyncio async def test_context_manager(self): session = CustomSession() assert isinstance(session, AsyncContextManager) @@ -171,3 +223,35 @@ class TestBaseSession: async with session as ctx: assert session == ctx mocked_close.assert_awaited_once() + + def test_add_middleware(self): + async def my_middleware(bot, method, make_request): + return await make_request(bot, method) + + session = CustomSession() + assert not session.middlewares + + session.middleware(my_middleware) + assert my_middleware in session.middlewares + assert len(session.middlewares) == 1 + + async def test_use_middleware(self, bot: MockedBot): + flag_before = False + flag_after = False + + @bot.session.middleware + async def my_middleware(b, method, make_request): + nonlocal flag_before, flag_after + flag_before = True + try: + assert isinstance(b, Bot) + assert isinstance(method, TelegramMethod) + + return await make_request(bot, method) + finally: + flag_after = True + + bot.add_result_for(GetMe, ok=True, result=User(id=42, is_bot=True, first_name="Test")) + assert await bot.get_me() + assert flag_before + assert flag_after diff --git a/tests/test_api/test_methods/test_add_sticker_to_set.py b/tests/test_api/test_methods/test_add_sticker_to_set.py index dae220cc..35a08f75 100644 --- a/tests/test_api/test_methods/test_add_sticker_to_set.py +++ b/tests/test_api/test_methods/test_add_sticker_to_set.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import AddStickerToSet, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestAddStickerToSet: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(AddStickerToSet, ok=True, result=True) @@ -16,7 +17,6 @@ class TestAddStickerToSet: assert request.method == "addStickerToSet" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(AddStickerToSet, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_answer_callback_query.py b/tests/test_api/test_methods/test_answer_callback_query.py index c3749455..f157e86f 100644 --- a/tests/test_api/test_methods/test_answer_callback_query.py +++ b/tests/test_api/test_methods/test_answer_callback_query.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import AnswerCallbackQuery, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestAnswerCallbackQuery: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(AnswerCallbackQuery, ok=True, result=True) @@ -14,7 +15,6 @@ class TestAnswerCallbackQuery: assert request.method == "answerCallbackQuery" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(AnswerCallbackQuery, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_answer_inline_query.py b/tests/test_api/test_methods/test_answer_inline_query.py index a843a59d..98227b57 100644 --- a/tests/test_api/test_methods/test_answer_inline_query.py +++ b/tests/test_api/test_methods/test_answer_inline_query.py @@ -2,12 +2,13 @@ import pytest from aiogram import Bot from aiogram.methods import AnswerInlineQuery, Request -from aiogram.types import InlineQueryResult, InlineQueryResultPhoto +from aiogram.types import InlineQueryResult, InlineQueryResultPhoto, InputTextMessageContent from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestAnswerInlineQuery: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(AnswerInlineQuery, ok=True, result=True) @@ -18,7 +19,6 @@ class TestAnswerInlineQuery: assert request.method == "answerInlineQuery" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(AnswerInlineQuery, ok=True, result=True) @@ -40,3 +40,22 @@ class TestAnswerInlineQuery: new_bot = Bot(token="42:TEST", parse_mode="HTML") request = query.build_request(new_bot) assert request.data["results"][0]["parse_mode"] == "HTML" + + def test_parse_mode_input_message_content(self, bot: MockedBot): + query = AnswerInlineQuery( + inline_query_id="query id", + results=[ + InlineQueryResultPhoto( + id="result id", + photo_url="photo", + thumb_url="thumb", + input_message_content=InputTextMessageContent(message_text="test"), + ) + ], + ) + request = query.build_request(bot) + assert request.data["results"][0]["input_message_content"]["parse_mode"] is None + + new_bot = Bot(token="42:TEST", parse_mode="HTML") + request = query.build_request(new_bot) + assert request.data["results"][0]["input_message_content"]["parse_mode"] == "HTML" diff --git a/tests/test_api/test_methods/test_answer_pre_checkout_query.py b/tests/test_api/test_methods/test_answer_pre_checkout_query.py index b1afa384..7d0077c7 100644 --- a/tests/test_api/test_methods/test_answer_pre_checkout_query.py +++ b/tests/test_api/test_methods/test_answer_pre_checkout_query.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import AnswerPreCheckoutQuery, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestAnswerPreCheckoutQuery: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(AnswerPreCheckoutQuery, ok=True, result=True) @@ -14,7 +15,6 @@ class TestAnswerPreCheckoutQuery: assert request.method == "answerPreCheckoutQuery" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(AnswerPreCheckoutQuery, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_answer_shipping_query.py b/tests/test_api/test_methods/test_answer_shipping_query.py index bba639a3..1374543e 100644 --- a/tests/test_api/test_methods/test_answer_shipping_query.py +++ b/tests/test_api/test_methods/test_answer_shipping_query.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import AnswerShippingQuery, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestAnswerShippingQuery: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(AnswerShippingQuery, ok=True, result=True) @@ -14,7 +15,6 @@ class TestAnswerShippingQuery: assert request.method == "answerShippingQuery" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(AnswerShippingQuery, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_ban_chat_member.py b/tests/test_api/test_methods/test_ban_chat_member.py new file mode 100644 index 00000000..73090f40 --- /dev/null +++ b/tests/test_api/test_methods/test_ban_chat_member.py @@ -0,0 +1,24 @@ +import pytest + +from aiogram.methods import BanChatMember, Request +from tests.mocked_bot import MockedBot + +pytestmark = pytest.mark.asyncio + + +class TestKickChatMember: + async def test_method(self, bot: MockedBot): + prepare_result = bot.add_result_for(BanChatMember, ok=True, result=True) + + response: bool = await BanChatMember(chat_id=-42, user_id=42) + request: Request = bot.get_request() + assert request.method == "banChatMember" + assert response == prepare_result.result + + async def test_bot_method(self, bot: MockedBot): + prepare_result = bot.add_result_for(BanChatMember, ok=True, result=True) + + response: bool = await bot.ban_chat_member(chat_id=-42, user_id=42) + request: Request = bot.get_request() + assert request.method == "banChatMember" + assert response == prepare_result.result diff --git a/tests/test_api/test_methods/test_base.py b/tests/test_api/test_methods/test_base.py index 079721a3..4dc39946 100644 --- a/tests/test_api/test_methods/test_base.py +++ b/tests/test_api/test_methods/test_base.py @@ -6,6 +6,8 @@ from aiogram import Bot from aiogram.methods.base import prepare_parse_mode from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestPrepareFile: # TODO: Add tests @@ -34,7 +36,6 @@ class TestPrepareParseMode: ["Markdown", {"parse_mode": "HTML"}, "HTML"], ], ) - @pytest.mark.asyncio async def test_default_parse_mode( self, bot: MockedBot, parse_mode: str, data: Dict[str, str], result: Optional[str] ): @@ -43,7 +44,6 @@ class TestPrepareParseMode: prepare_parse_mode(bot, data) assert data.get("parse_mode") == result - @pytest.mark.asyncio async def test_list(self): data = [{}] * 2 data.append({"parse_mode": "HTML"}) diff --git a/tests/test_api/test_methods/test_close.py b/tests/test_api/test_methods/test_close.py index c497520e..c6a84e31 100644 --- a/tests/test_api/test_methods/test_close.py +++ b/tests/test_api/test_methods/test_close.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import Close, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestClose: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(Close, ok=True, result=True) @@ -15,7 +16,6 @@ class TestClose: # assert request.data == {} assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(Close, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_copy_message.py b/tests/test_api/test_methods/test_copy_message.py index d380f24a..e844cede 100644 --- a/tests/test_api/test_methods/test_copy_message.py +++ b/tests/test_api/test_methods/test_copy_message.py @@ -4,9 +4,10 @@ from aiogram.methods import CopyMessage, Request from aiogram.types import MessageId from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestCopyMessage: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(CopyMessage, ok=True, result=MessageId(message_id=42)) @@ -20,7 +21,6 @@ class TestCopyMessage: # assert request.data == {} assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(CopyMessage, ok=True, result=MessageId(message_id=42)) diff --git a/tests/test_api/test_methods/test_create_chat_invite_link.py b/tests/test_api/test_methods/test_create_chat_invite_link.py index d33d25f7..0241a60c 100644 --- a/tests/test_api/test_methods/test_create_chat_invite_link.py +++ b/tests/test_api/test_methods/test_create_chat_invite_link.py @@ -4,9 +4,10 @@ from aiogram.methods import CreateChatInviteLink, Request from aiogram.types import ChatInviteLink, User from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestCreateChatInviteLink: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( CreateChatInviteLink, @@ -27,7 +28,6 @@ class TestCreateChatInviteLink: # assert request.data == {"chat_id": -42} assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( CreateChatInviteLink, diff --git a/tests/test_api/test_methods/test_create_new_sticker_set.py b/tests/test_api/test_methods/test_create_new_sticker_set.py index 218bff0d..4c927b77 100644 --- a/tests/test_api/test_methods/test_create_new_sticker_set.py +++ b/tests/test_api/test_methods/test_create_new_sticker_set.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import CreateNewStickerSet, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestCreateNewStickerSet: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(CreateNewStickerSet, ok=True, result=True) @@ -16,7 +17,6 @@ class TestCreateNewStickerSet: assert request.method == "createNewStickerSet" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(CreateNewStickerSet, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_delete_chat_photo.py b/tests/test_api/test_methods/test_delete_chat_photo.py index c807bd71..0f528775 100644 --- a/tests/test_api/test_methods/test_delete_chat_photo.py +++ b/tests/test_api/test_methods/test_delete_chat_photo.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import DeleteChatPhoto, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestDeleteChatPhoto: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(DeleteChatPhoto, ok=True, result=True) @@ -14,7 +15,6 @@ class TestDeleteChatPhoto: assert request.method == "deleteChatPhoto" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(DeleteChatPhoto, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_delete_chat_sticker_set.py b/tests/test_api/test_methods/test_delete_chat_sticker_set.py index 80d0967d..8fdb7b84 100644 --- a/tests/test_api/test_methods/test_delete_chat_sticker_set.py +++ b/tests/test_api/test_methods/test_delete_chat_sticker_set.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import DeleteChatStickerSet, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestDeleteChatStickerSet: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(DeleteChatStickerSet, ok=True, result=True) @@ -14,7 +15,6 @@ class TestDeleteChatStickerSet: assert request.method == "deleteChatStickerSet" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(DeleteChatStickerSet, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_delete_message.py b/tests/test_api/test_methods/test_delete_message.py index 87b8efad..ecceb6dd 100644 --- a/tests/test_api/test_methods/test_delete_message.py +++ b/tests/test_api/test_methods/test_delete_message.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import DeleteMessage, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestDeleteMessage: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(DeleteMessage, ok=True, result=True) @@ -14,7 +15,6 @@ class TestDeleteMessage: assert request.method == "deleteMessage" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(DeleteMessage, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_delete_my_commands.py b/tests/test_api/test_methods/test_delete_my_commands.py new file mode 100644 index 00000000..14d36381 --- /dev/null +++ b/tests/test_api/test_methods/test_delete_my_commands.py @@ -0,0 +1,24 @@ +import pytest + +from aiogram.methods import DeleteMyCommands, Request +from tests.mocked_bot import MockedBot + +pytestmark = pytest.mark.asyncio + + +class TestKickChatMember: + async def test_method(self, bot: MockedBot): + prepare_result = bot.add_result_for(DeleteMyCommands, ok=True, result=True) + + response: bool = await DeleteMyCommands() + request: Request = bot.get_request() + assert request.method == "deleteMyCommands" + assert response == prepare_result.result + + async def test_bot_method(self, bot: MockedBot): + prepare_result = bot.add_result_for(DeleteMyCommands, ok=True, result=True) + + response: bool = await bot.delete_my_commands() + request: Request = bot.get_request() + assert request.method == "deleteMyCommands" + assert response == prepare_result.result diff --git a/tests/test_api/test_methods/test_delete_sticker_from_set.py b/tests/test_api/test_methods/test_delete_sticker_from_set.py index 350e0b3e..c17a5493 100644 --- a/tests/test_api/test_methods/test_delete_sticker_from_set.py +++ b/tests/test_api/test_methods/test_delete_sticker_from_set.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import DeleteStickerFromSet, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestDeleteStickerFromSet: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(DeleteStickerFromSet, ok=True, result=True) @@ -14,7 +15,6 @@ class TestDeleteStickerFromSet: assert request.method == "deleteStickerFromSet" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(DeleteStickerFromSet, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_delete_webhook.py b/tests/test_api/test_methods/test_delete_webhook.py index 91ecb809..21c0fcf7 100644 --- a/tests/test_api/test_methods/test_delete_webhook.py +++ b/tests/test_api/test_methods/test_delete_webhook.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import DeleteWebhook, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestDeleteWebhook: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(DeleteWebhook, ok=True, result=True) @@ -14,7 +15,6 @@ class TestDeleteWebhook: assert request.method == "deleteWebhook" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(DeleteWebhook, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_edit_chat_invite_link.py b/tests/test_api/test_methods/test_edit_chat_invite_link.py index 212d1e51..b0c055c7 100644 --- a/tests/test_api/test_methods/test_edit_chat_invite_link.py +++ b/tests/test_api/test_methods/test_edit_chat_invite_link.py @@ -4,9 +4,10 @@ from aiogram.methods import EditChatInviteLink, Request from aiogram.types import ChatInviteLink, User from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestEditChatInviteLink: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( EditChatInviteLink, @@ -27,7 +28,6 @@ class TestEditChatInviteLink: # assert request.data == {} assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( EditChatInviteLink, diff --git a/tests/test_api/test_methods/test_edit_message_caption.py b/tests/test_api/test_methods/test_edit_message_caption.py index 62803d43..a3afef46 100644 --- a/tests/test_api/test_methods/test_edit_message_caption.py +++ b/tests/test_api/test_methods/test_edit_message_caption.py @@ -7,9 +7,10 @@ from aiogram.methods import EditMessageCaption, Request from aiogram.types import Chat, Message from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestEditMessageCaption: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( EditMessageCaption, @@ -27,7 +28,6 @@ class TestEditMessageCaption: assert request.method == "editMessageCaption" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( EditMessageCaption, diff --git a/tests/test_api/test_methods/test_edit_message_live_location.py b/tests/test_api/test_methods/test_edit_message_live_location.py index db04fb19..32f19b2f 100644 --- a/tests/test_api/test_methods/test_edit_message_live_location.py +++ b/tests/test_api/test_methods/test_edit_message_live_location.py @@ -6,9 +6,10 @@ from aiogram.methods import EditMessageLiveLocation, Request from aiogram.types import Message from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestEditMessageLiveLocation: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(EditMessageLiveLocation, ok=True, result=True) @@ -19,7 +20,6 @@ class TestEditMessageLiveLocation: assert request.method == "editMessageLiveLocation" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(EditMessageLiveLocation, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_edit_message_media.py b/tests/test_api/test_methods/test_edit_message_media.py index c6715163..bc60e4d5 100644 --- a/tests/test_api/test_methods/test_edit_message_media.py +++ b/tests/test_api/test_methods/test_edit_message_media.py @@ -3,12 +3,13 @@ from typing import Union import pytest from aiogram.methods import EditMessageMedia, Request -from aiogram.types import BufferedInputFile, InputMedia, InputMediaPhoto, Message +from aiogram.types import BufferedInputFile, InputMediaPhoto, Message from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestEditMessageMedia: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(EditMessageMedia, ok=True, result=True) @@ -19,7 +20,6 @@ class TestEditMessageMedia: assert request.method == "editMessageMedia" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(EditMessageMedia, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_edit_message_reply_markup.py b/tests/test_api/test_methods/test_edit_message_reply_markup.py index 474f052a..71a1f823 100644 --- a/tests/test_api/test_methods/test_edit_message_reply_markup.py +++ b/tests/test_api/test_methods/test_edit_message_reply_markup.py @@ -6,9 +6,10 @@ from aiogram.methods import EditMessageReplyMarkup, Request from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestEditMessageReplyMarkup: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(EditMessageReplyMarkup, ok=True, result=True) @@ -25,7 +26,6 @@ class TestEditMessageReplyMarkup: assert request.method == "editMessageReplyMarkup" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(EditMessageReplyMarkup, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_edit_message_text.py b/tests/test_api/test_methods/test_edit_message_text.py index d889e7ec..5543f62c 100644 --- a/tests/test_api/test_methods/test_edit_message_text.py +++ b/tests/test_api/test_methods/test_edit_message_text.py @@ -6,9 +6,10 @@ from aiogram.methods import EditMessageText, Request from aiogram.types import Message from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestEditMessageText: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(EditMessageText, ok=True, result=True) @@ -19,7 +20,6 @@ class TestEditMessageText: assert request.method == "editMessageText" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(EditMessageText, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_export_chat_invite_link.py b/tests/test_api/test_methods/test_export_chat_invite_link.py index 2d5f6d05..fc8b4f13 100644 --- a/tests/test_api/test_methods/test_export_chat_invite_link.py +++ b/tests/test_api/test_methods/test_export_chat_invite_link.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import ExportChatInviteLink, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestExportChatInviteLink: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( ExportChatInviteLink, ok=True, result="http://example.com" @@ -16,7 +17,6 @@ class TestExportChatInviteLink: assert request.method == "exportChatInviteLink" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( ExportChatInviteLink, ok=True, result="http://example.com" diff --git a/tests/test_api/test_methods/test_forward_message.py b/tests/test_api/test_methods/test_forward_message.py index 77adb37d..94dfdf60 100644 --- a/tests/test_api/test_methods/test_forward_message.py +++ b/tests/test_api/test_methods/test_forward_message.py @@ -6,9 +6,10 @@ from aiogram.methods import ForwardMessage, Request from aiogram.types import Chat, Message from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestForwardMessage: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( ForwardMessage, @@ -27,7 +28,6 @@ class TestForwardMessage: # assert request.data == {} assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( ForwardMessage, diff --git a/tests/test_api/test_methods/test_get_chat.py b/tests/test_api/test_methods/test_get_chat.py index 3d9510b9..f5117d11 100644 --- a/tests/test_api/test_methods/test_get_chat.py +++ b/tests/test_api/test_methods/test_get_chat.py @@ -4,9 +4,10 @@ from aiogram.methods import GetChat, Request from aiogram.types import Chat from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestGetChat: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetChat, ok=True, result=Chat(id=-42, type="channel", title="chat") @@ -17,7 +18,6 @@ class TestGetChat: assert request.method == "getChat" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetChat, ok=True, result=Chat(id=-42, type="channel", title="chat") diff --git a/tests/test_api/test_methods/test_get_chat_administrators.py b/tests/test_api/test_methods/test_get_chat_administrators.py index 9309460b..e254d34e 100644 --- a/tests/test_api/test_methods/test_get_chat_administrators.py +++ b/tests/test_api/test_methods/test_get_chat_administrators.py @@ -3,18 +3,21 @@ from typing import List import pytest from aiogram.methods import GetChatAdministrators, Request -from aiogram.types import ChatMember, User +from aiogram.types import ChatMember, ChatMemberOwner, User from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestGetChatAdministrators: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetChatAdministrators, ok=True, result=[ - ChatMember(user=User(id=42, is_bot=False, first_name="User"), status="creator") + ChatMemberOwner( + user=User(id=42, is_bot=False, first_name="User"), is_anonymous=False + ) ], ) @@ -23,13 +26,14 @@ class TestGetChatAdministrators: assert request.method == "getChatAdministrators" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetChatAdministrators, ok=True, result=[ - ChatMember(user=User(id=42, is_bot=False, first_name="User"), status="creator") + ChatMemberOwner( + user=User(id=42, is_bot=False, first_name="User"), is_anonymous=False + ) ], ) response: List[ChatMember] = await bot.get_chat_administrators(chat_id=-42) diff --git a/tests/test_api/test_methods/test_get_chat_member.py b/tests/test_api/test_methods/test_get_chat_member.py index 111b06cd..35dc98bd 100644 --- a/tests/test_api/test_methods/test_get_chat_member.py +++ b/tests/test_api/test_methods/test_get_chat_member.py @@ -1,17 +1,20 @@ import pytest from aiogram.methods import GetChatMember, Request -from aiogram.types import ChatMember, User +from aiogram.types import ChatMember, ChatMemberOwner, User from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestGetChatMember: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetChatMember, ok=True, - result=ChatMember(user=User(id=42, is_bot=False, first_name="User"), status="creator"), + result=ChatMemberOwner( + user=User(id=42, is_bot=False, first_name="User"), is_anonymous=False + ), ) response: ChatMember = await GetChatMember(chat_id=-42, user_id=42) @@ -19,12 +22,13 @@ class TestGetChatMember: assert request.method == "getChatMember" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetChatMember, ok=True, - result=ChatMember(user=User(id=42, is_bot=False, first_name="User"), status="creator"), + result=ChatMemberOwner( + user=User(id=42, is_bot=False, first_name="User"), is_anonymous=False + ), ) response: ChatMember = await bot.get_chat_member(chat_id=-42, user_id=42) diff --git a/tests/test_api/test_methods/test_get_chat_member_count.py b/tests/test_api/test_methods/test_get_chat_member_count.py new file mode 100644 index 00000000..e0fd1a4a --- /dev/null +++ b/tests/test_api/test_methods/test_get_chat_member_count.py @@ -0,0 +1,24 @@ +import pytest + +from aiogram.methods import GetChatMemberCount, Request +from tests.mocked_bot import MockedBot + +pytestmark = pytest.mark.asyncio + + +class TestGetChatMembersCount: + async def test_method(self, bot: MockedBot): + prepare_result = bot.add_result_for(GetChatMemberCount, ok=True, result=42) + + response: int = await GetChatMemberCount(chat_id=-42) + request: Request = bot.get_request() + assert request.method == "getChatMemberCount" + assert response == prepare_result.result + + async def test_bot_method(self, bot: MockedBot): + prepare_result = bot.add_result_for(GetChatMemberCount, ok=True, result=42) + + response: int = await bot.get_chat_member_count(chat_id=-42) + request: Request = bot.get_request() + assert request.method == "getChatMemberCount" + assert response == prepare_result.result diff --git a/tests/test_api/test_methods/test_get_chat_members_count.py b/tests/test_api/test_methods/test_get_chat_members_count.py index fd88f925..cd44a13a 100644 --- a/tests/test_api/test_methods/test_get_chat_members_count.py +++ b/tests/test_api/test_methods/test_get_chat_members_count.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import GetChatMembersCount, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestGetChatMembersCount: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(GetChatMembersCount, ok=True, result=42) @@ -14,7 +15,6 @@ class TestGetChatMembersCount: assert request.method == "getChatMembersCount" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(GetChatMembersCount, ok=True, result=42) diff --git a/tests/test_api/test_methods/test_get_file.py b/tests/test_api/test_methods/test_get_file.py index f466ef04..38923a6e 100644 --- a/tests/test_api/test_methods/test_get_file.py +++ b/tests/test_api/test_methods/test_get_file.py @@ -4,9 +4,10 @@ from aiogram.methods import GetFile, Request from aiogram.types import File from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestGetFile: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetFile, ok=True, result=File(file_id="file id", file_unique_id="file id") @@ -17,7 +18,6 @@ class TestGetFile: assert request.method == "getFile" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetFile, ok=True, result=File(file_id="file id", file_unique_id="file id") diff --git a/tests/test_api/test_methods/test_get_game_high_scores.py b/tests/test_api/test_methods/test_get_game_high_scores.py index 397b450d..bc2c7116 100644 --- a/tests/test_api/test_methods/test_get_game_high_scores.py +++ b/tests/test_api/test_methods/test_get_game_high_scores.py @@ -6,9 +6,10 @@ from aiogram.methods import GetGameHighScores, Request from aiogram.types import GameHighScore, User from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestGetGameHighScores: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetGameHighScores, @@ -25,7 +26,6 @@ class TestGetGameHighScores: assert request.method == "getGameHighScores" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetGameHighScores, diff --git a/tests/test_api/test_methods/test_get_me.py b/tests/test_api/test_methods/test_get_me.py index 9e6454e3..7da53ea8 100644 --- a/tests/test_api/test_methods/test_get_me.py +++ b/tests/test_api/test_methods/test_get_me.py @@ -4,9 +4,10 @@ from aiogram.methods import GetMe, Request from aiogram.types import User from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestGetMe: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetMe, ok=True, result=User(id=42, is_bot=False, first_name="User") @@ -18,7 +19,6 @@ class TestGetMe: assert request.data == {} assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetMe, ok=True, result=User(id=42, is_bot=False, first_name="User") @@ -29,7 +29,6 @@ class TestGetMe: assert request.data == {} assert response == prepare_result.result - @pytest.mark.asyncio async def test_me_property(self, bot: MockedBot): prepare_result = bot.add_result_for( GetMe, ok=True, result=User(id=42, is_bot=False, first_name="User") diff --git a/tests/test_api/test_methods/test_get_my_commands.py b/tests/test_api/test_methods/test_get_my_commands.py index 569a13a3..a28ef026 100644 --- a/tests/test_api/test_methods/test_get_my_commands.py +++ b/tests/test_api/test_methods/test_get_my_commands.py @@ -6,9 +6,10 @@ from aiogram.methods import GetMyCommands, Request from aiogram.types import BotCommand from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestGetMyCommands: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(GetMyCommands, ok=True, result=None) @@ -17,7 +18,6 @@ class TestGetMyCommands: assert request.method == "getMyCommands" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(GetMyCommands, ok=True, result=None) diff --git a/tests/test_api/test_methods/test_get_sticker_set.py b/tests/test_api/test_methods/test_get_sticker_set.py index 8b331982..baed1d40 100644 --- a/tests/test_api/test_methods/test_get_sticker_set.py +++ b/tests/test_api/test_methods/test_get_sticker_set.py @@ -4,9 +4,10 @@ from aiogram.methods import GetStickerSet, Request from aiogram.types import Sticker, StickerSet from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestGetStickerSet: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetStickerSet, @@ -33,7 +34,6 @@ class TestGetStickerSet: assert request.method == "getStickerSet" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetStickerSet, diff --git a/tests/test_api/test_methods/test_get_updates.py b/tests/test_api/test_methods/test_get_updates.py index c400b1df..8f8dfa87 100644 --- a/tests/test_api/test_methods/test_get_updates.py +++ b/tests/test_api/test_methods/test_get_updates.py @@ -6,9 +6,10 @@ from aiogram.methods import GetUpdates, Request from aiogram.types import Update from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestGetUpdates: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(GetUpdates, ok=True, result=[Update(update_id=42)]) @@ -17,7 +18,6 @@ class TestGetUpdates: assert request.method == "getUpdates" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(GetUpdates, ok=True, result=[Update(update_id=42)]) diff --git a/tests/test_api/test_methods/test_get_url.py b/tests/test_api/test_methods/test_get_url.py new file mode 100644 index 00000000..76b24200 --- /dev/null +++ b/tests/test_api/test_methods/test_get_url.py @@ -0,0 +1,51 @@ +import datetime +from typing import Optional + +import pytest + +from aiogram.types import Chat, Message +from tests.mocked_bot import MockedBot + + +class TestGetMessageUrl: + @pytest.mark.parametrize( + "chat_type,chat_id,chat_username,force_private,expected_result", + [ + ["private", 123456, "username", False, None], + ["group", -123456, "username", False, None], + ["supergroup", -1001234567890, None, False, "https://t.me/c/1234567890/10"], + ["supergroup", -1001234567890, None, True, "https://t.me/c/1234567890/10"], + ["supergroup", -1001234567890, "username", False, "https://t.me/username/10"], + ["supergroup", -1001234567890, "username", True, "https://t.me/c/1234567890/10"], + ["channel", -1001234567890, None, False, "https://t.me/c/1234567890/10"], + ["channel", -1001234567890, None, True, "https://t.me/c/1234567890/10"], + ["channel", -1001234567890, "username", False, "https://t.me/username/10"], + ["channel", -1001234567890, "username", True, "https://t.me/c/1234567890/10"], + # 2 extra cases: 9-digit ID and 11-digit ID (without "-100") + ["supergroup", -100123456789, None, True, "https://t.me/c/123456789/10"], + ["supergroup", -10012345678901, None, True, "https://t.me/c/12345678901/10"], + ], + ) + def test_method( + self, + bot: MockedBot, + chat_type: str, + chat_id: int, + chat_username: Optional[str], + force_private: bool, + expected_result: Optional[str], + ): + + fake_chat = Chat(id=chat_id, username=chat_username, type=chat_type) + fake_message_id = 10 + fake_message = Message( + message_id=fake_message_id, + date=datetime.datetime.now(), + text="test", + chat=fake_chat, + ) + + if expected_result is None: + assert fake_message.get_url(force_private=force_private) is None + else: + assert fake_message.get_url(force_private=force_private) == expected_result diff --git a/tests/test_api/test_methods/test_get_user_profile_photos.py b/tests/test_api/test_methods/test_get_user_profile_photos.py index 3e24a115..d6094eb9 100644 --- a/tests/test_api/test_methods/test_get_user_profile_photos.py +++ b/tests/test_api/test_methods/test_get_user_profile_photos.py @@ -4,9 +4,10 @@ from aiogram.methods import GetUserProfilePhotos, Request from aiogram.types import PhotoSize, UserProfilePhotos from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestGetUserProfilePhotos: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetUserProfilePhotos, @@ -24,7 +25,6 @@ class TestGetUserProfilePhotos: assert request.method == "getUserProfilePhotos" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetUserProfilePhotos, diff --git a/tests/test_api/test_methods/test_get_webhook_info.py b/tests/test_api/test_methods/test_get_webhook_info.py index 6dc28928..f50213cb 100644 --- a/tests/test_api/test_methods/test_get_webhook_info.py +++ b/tests/test_api/test_methods/test_get_webhook_info.py @@ -4,9 +4,10 @@ from aiogram.methods import GetWebhookInfo, Request from aiogram.types import WebhookInfo from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestGetWebhookInfo: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetWebhookInfo, @@ -21,7 +22,6 @@ class TestGetWebhookInfo: assert request.method == "getWebhookInfo" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetWebhookInfo, diff --git a/tests/test_api/test_methods/test_kick_chat_member.py b/tests/test_api/test_methods/test_kick_chat_member.py index d60133c5..4aaa651b 100644 --- a/tests/test_api/test_methods/test_kick_chat_member.py +++ b/tests/test_api/test_methods/test_kick_chat_member.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import KickChatMember, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestKickChatMember: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(KickChatMember, ok=True, result=True) @@ -14,7 +15,6 @@ class TestKickChatMember: assert request.method == "kickChatMember" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(KickChatMember, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_leave_chat.py b/tests/test_api/test_methods/test_leave_chat.py index d4788002..77bf739f 100644 --- a/tests/test_api/test_methods/test_leave_chat.py +++ b/tests/test_api/test_methods/test_leave_chat.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import LeaveChat, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestLeaveChat: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(LeaveChat, ok=True, result=True) @@ -14,7 +15,6 @@ class TestLeaveChat: assert request.method == "leaveChat" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(LeaveChat, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_log_out.py b/tests/test_api/test_methods/test_log_out.py index e000540f..b472da50 100644 --- a/tests/test_api/test_methods/test_log_out.py +++ b/tests/test_api/test_methods/test_log_out.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import LogOut, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestLogOut: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(LogOut, ok=True, result=True) @@ -15,7 +16,6 @@ class TestLogOut: # assert request.data == {} assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(LogOut, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_pin_chat_message.py b/tests/test_api/test_methods/test_pin_chat_message.py index 190fefcc..59a2bf91 100644 --- a/tests/test_api/test_methods/test_pin_chat_message.py +++ b/tests/test_api/test_methods/test_pin_chat_message.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import PinChatMessage, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestPinChatMessage: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(PinChatMessage, ok=True, result=True) @@ -14,7 +15,6 @@ class TestPinChatMessage: assert request.method == "pinChatMessage" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(PinChatMessage, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_promote_chat_member.py b/tests/test_api/test_methods/test_promote_chat_member.py index 11528160..20c796bd 100644 --- a/tests/test_api/test_methods/test_promote_chat_member.py +++ b/tests/test_api/test_methods/test_promote_chat_member.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import PromoteChatMember, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestPromoteChatMember: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(PromoteChatMember, ok=True, result=True) @@ -14,7 +15,6 @@ class TestPromoteChatMember: assert request.method == "promoteChatMember" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(PromoteChatMember, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_restrict_chat_member.py b/tests/test_api/test_methods/test_restrict_chat_member.py index fe3ce74d..715d0c28 100644 --- a/tests/test_api/test_methods/test_restrict_chat_member.py +++ b/tests/test_api/test_methods/test_restrict_chat_member.py @@ -4,9 +4,10 @@ from aiogram.methods import Request, RestrictChatMember from aiogram.types import ChatPermissions from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestRestrictChatMember: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(RestrictChatMember, ok=True, result=True) @@ -17,7 +18,6 @@ class TestRestrictChatMember: assert request.method == "restrictChatMember" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(RestrictChatMember, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_revoke_chat_invite_link.py b/tests/test_api/test_methods/test_revoke_chat_invite_link.py index a791d53b..fc30ff69 100644 --- a/tests/test_api/test_methods/test_revoke_chat_invite_link.py +++ b/tests/test_api/test_methods/test_revoke_chat_invite_link.py @@ -4,9 +4,10 @@ from aiogram.methods import Request, RevokeChatInviteLink from aiogram.types import ChatInviteLink, User from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestRevokeChatInviteLink: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( RevokeChatInviteLink, @@ -28,7 +29,6 @@ class TestRevokeChatInviteLink: # assert request.data == {} assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( RevokeChatInviteLink, diff --git a/tests/test_api/test_methods/test_send_animation.py b/tests/test_api/test_methods/test_send_animation.py index 95e85cc1..9a123f1a 100644 --- a/tests/test_api/test_methods/test_send_animation.py +++ b/tests/test_api/test_methods/test_send_animation.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, SendAnimation from aiogram.types import Animation, Chat, Message from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendAnimation: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendAnimation, @@ -28,7 +29,6 @@ class TestSendAnimation: assert request.method == "sendAnimation" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendAnimation, diff --git a/tests/test_api/test_methods/test_send_audio.py b/tests/test_api/test_methods/test_send_audio.py index 4a33bbdc..77ad22f7 100644 --- a/tests/test_api/test_methods/test_send_audio.py +++ b/tests/test_api/test_methods/test_send_audio.py @@ -3,12 +3,13 @@ import datetime import pytest from aiogram.methods import Request, SendAudio -from aiogram.types import Audio, Chat, File, Message +from aiogram.types import Audio, Chat, Message from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendAudio: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendAudio, @@ -26,7 +27,6 @@ class TestSendAudio: assert request.method == "sendAudio" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendAudio, diff --git a/tests/test_api/test_methods/test_send_chat_action.py b/tests/test_api/test_methods/test_send_chat_action.py index 1478b160..6b6454ae 100644 --- a/tests/test_api/test_methods/test_send_chat_action.py +++ b/tests/test_api/test_methods/test_send_chat_action.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import Request, SendChatAction from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendChatAction: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SendChatAction, ok=True, result=True) @@ -14,7 +15,6 @@ class TestSendChatAction: assert request.method == "sendChatAction" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SendChatAction, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_send_contact.py b/tests/test_api/test_methods/test_send_contact.py index 801968ed..adfb697e 100644 --- a/tests/test_api/test_methods/test_send_contact.py +++ b/tests/test_api/test_methods/test_send_contact.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, SendContact from aiogram.types import Chat, Contact, Message from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendContact: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendContact, @@ -26,7 +27,6 @@ class TestSendContact: assert request.method == "sendContact" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendContact, diff --git a/tests/test_api/test_methods/test_send_dice.py b/tests/test_api/test_methods/test_send_dice.py index 981c242b..80e618ac 100644 --- a/tests/test_api/test_methods/test_send_dice.py +++ b/tests/test_api/test_methods/test_send_dice.py @@ -4,9 +4,10 @@ from aiogram.methods import Request, SendDice from aiogram.types import Message from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendDice: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SendDice, ok=True, result=None) @@ -15,7 +16,6 @@ class TestSendDice: assert request.method == "sendDice" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SendDice, ok=True, result=None) diff --git a/tests/test_api/test_methods/test_send_document.py b/tests/test_api/test_methods/test_send_document.py index d7d5b32d..106e1737 100644 --- a/tests/test_api/test_methods/test_send_document.py +++ b/tests/test_api/test_methods/test_send_document.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, SendDocument from aiogram.types import Chat, Document, Message from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendDocument: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendDocument, @@ -26,7 +27,6 @@ class TestSendDocument: assert request.method == "sendDocument" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendDocument, diff --git a/tests/test_api/test_methods/test_send_game.py b/tests/test_api/test_methods/test_send_game.py index 35373f2e..fca6753c 100644 --- a/tests/test_api/test_methods/test_send_game.py +++ b/tests/test_api/test_methods/test_send_game.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, SendGame from aiogram.types import Chat, Game, Message, PhotoSize from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendGame: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendGame, @@ -32,7 +33,6 @@ class TestSendGame: assert request.method == "sendGame" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendGame, diff --git a/tests/test_api/test_methods/test_send_invoice.py b/tests/test_api/test_methods/test_send_invoice.py index d033e621..6915fcc5 100644 --- a/tests/test_api/test_methods/test_send_invoice.py +++ b/tests/test_api/test_methods/test_send_invoice.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, SendInvoice from aiogram.types import Chat, Invoice, LabeledPrice, Message from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendInvoice: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendInvoice, @@ -41,7 +42,6 @@ class TestSendInvoice: assert request.method == "sendInvoice" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendInvoice, diff --git a/tests/test_api/test_methods/test_send_location.py b/tests/test_api/test_methods/test_send_location.py index cbadceaa..0f42cf74 100644 --- a/tests/test_api/test_methods/test_send_location.py +++ b/tests/test_api/test_methods/test_send_location.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, SendLocation from aiogram.types import Chat, Location, Message from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendLocation: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendLocation, @@ -26,7 +27,6 @@ class TestSendLocation: assert request.method == "sendLocation" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendLocation, diff --git a/tests/test_api/test_methods/test_send_media_group.py b/tests/test_api/test_methods/test_send_media_group.py index 884d362c..5b6dc3e1 100644 --- a/tests/test_api/test_methods/test_send_media_group.py +++ b/tests/test_api/test_methods/test_send_media_group.py @@ -15,9 +15,10 @@ from aiogram.types import ( ) from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendMediaGroup: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendMediaGroup, @@ -59,7 +60,6 @@ class TestSendMediaGroup: assert request.method == "sendMediaGroup" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendMediaGroup, diff --git a/tests/test_api/test_methods/test_send_message.py b/tests/test_api/test_methods/test_send_message.py index 26d85613..c2da672e 100644 --- a/tests/test_api/test_methods/test_send_message.py +++ b/tests/test_api/test_methods/test_send_message.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, SendMessage from aiogram.types import Chat, Message from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendMessage: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendMessage, @@ -26,7 +27,6 @@ class TestSendMessage: assert request.method == "sendMessage" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendMessage, diff --git a/tests/test_api/test_methods/test_send_photo.py b/tests/test_api/test_methods/test_send_photo.py index e3c2065e..c55565f3 100644 --- a/tests/test_api/test_methods/test_send_photo.py +++ b/tests/test_api/test_methods/test_send_photo.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, SendPhoto from aiogram.types import Chat, Message, PhotoSize from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendPhoto: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendPhoto, @@ -28,7 +29,6 @@ class TestSendPhoto: assert request.method == "sendPhoto" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendPhoto, diff --git a/tests/test_api/test_methods/test_send_poll.py b/tests/test_api/test_methods/test_send_poll.py index 2f963506..5cbb3c75 100644 --- a/tests/test_api/test_methods/test_send_poll.py +++ b/tests/test_api/test_methods/test_send_poll.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, SendPoll from aiogram.types import Chat, Message, Poll, PollOption from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendPoll: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendPoll, @@ -41,7 +42,6 @@ class TestSendPoll: assert request.method == "sendPoll" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendPoll, diff --git a/tests/test_api/test_methods/test_send_sticker.py b/tests/test_api/test_methods/test_send_sticker.py index 12c3b28e..d356e8ae 100644 --- a/tests/test_api/test_methods/test_send_sticker.py +++ b/tests/test_api/test_methods/test_send_sticker.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, SendSticker from aiogram.types import Chat, Message, Sticker from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendSticker: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendSticker, @@ -32,7 +33,6 @@ class TestSendSticker: assert request.method == "sendSticker" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendSticker, diff --git a/tests/test_api/test_methods/test_send_venue.py b/tests/test_api/test_methods/test_send_venue.py index 9246dd90..2f046196 100644 --- a/tests/test_api/test_methods/test_send_venue.py +++ b/tests/test_api/test_methods/test_send_venue.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, SendVenue from aiogram.types import Chat, Location, Message, Venue from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendVenue: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendVenue, @@ -38,7 +39,6 @@ class TestSendVenue: assert request.method == "sendVenue" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendVenue, diff --git a/tests/test_api/test_methods/test_send_video.py b/tests/test_api/test_methods/test_send_video.py index 0729dda5..cb1c1222 100644 --- a/tests/test_api/test_methods/test_send_video.py +++ b/tests/test_api/test_methods/test_send_video.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, SendVideo from aiogram.types import Chat, Message, Video from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendVideo: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendVideo, @@ -28,7 +29,6 @@ class TestSendVideo: assert request.method == "sendVideo" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendVideo, diff --git a/tests/test_api/test_methods/test_send_video_note.py b/tests/test_api/test_methods/test_send_video_note.py index 8a31209e..63c5bd84 100644 --- a/tests/test_api/test_methods/test_send_video_note.py +++ b/tests/test_api/test_methods/test_send_video_note.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, SendVideoNote from aiogram.types import BufferedInputFile, Chat, Message, VideoNote from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendVideoNote: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendVideoNote, @@ -30,7 +31,6 @@ class TestSendVideoNote: assert request.method == "sendVideoNote" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendVideoNote, diff --git a/tests/test_api/test_methods/test_send_voice.py b/tests/test_api/test_methods/test_send_voice.py index bd19921a..ee3894cd 100644 --- a/tests/test_api/test_methods/test_send_voice.py +++ b/tests/test_api/test_methods/test_send_voice.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, SendVoice from aiogram.types import Chat, Message, Voice from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendVoice: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendVoice, @@ -26,7 +27,6 @@ class TestSendVoice: assert request.method == "sendVoice" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendVoice, diff --git a/tests/test_api/test_methods/test_set_chat_administrator_custom_title.py b/tests/test_api/test_methods/test_set_chat_administrator_custom_title.py index 2f4752c7..968c805b 100644 --- a/tests/test_api/test_methods/test_set_chat_administrator_custom_title.py +++ b/tests/test_api/test_methods/test_set_chat_administrator_custom_title.py @@ -1,11 +1,12 @@ import pytest -from aiogram.methods import Request, SetChatAdministratorCustomTitle, SetChatTitle +from aiogram.methods import Request, SetChatAdministratorCustomTitle from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSetChatTitle: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetChatAdministratorCustomTitle, ok=True, result=True) @@ -16,7 +17,6 @@ class TestSetChatTitle: assert request.method == "setChatAdministratorCustomTitle" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetChatAdministratorCustomTitle, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_set_chat_description.py b/tests/test_api/test_methods/test_set_chat_description.py index 3679d1c5..36d05cec 100644 --- a/tests/test_api/test_methods/test_set_chat_description.py +++ b/tests/test_api/test_methods/test_set_chat_description.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import Request, SetChatDescription from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSetChatDescription: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetChatDescription, ok=True, result=True) @@ -14,7 +15,6 @@ class TestSetChatDescription: assert request.method == "setChatDescription" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetChatDescription, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_set_chat_permissions.py b/tests/test_api/test_methods/test_set_chat_permissions.py index 83c90883..a0278b80 100644 --- a/tests/test_api/test_methods/test_set_chat_permissions.py +++ b/tests/test_api/test_methods/test_set_chat_permissions.py @@ -4,9 +4,10 @@ from aiogram.methods import Request, SetChatPermissions from aiogram.types import ChatPermissions from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSetChatPermissions: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetChatPermissions, ok=True, result=True) @@ -17,7 +18,6 @@ class TestSetChatPermissions: assert request.method == "setChatPermissions" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetChatPermissions, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_set_chat_photo.py b/tests/test_api/test_methods/test_set_chat_photo.py index 02e00670..268f668b 100644 --- a/tests/test_api/test_methods/test_set_chat_photo.py +++ b/tests/test_api/test_methods/test_set_chat_photo.py @@ -1,12 +1,13 @@ import pytest from aiogram.methods import Request, SetChatPhoto -from aiogram.types import BufferedInputFile, InputFile +from aiogram.types import BufferedInputFile from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSetChatPhoto: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetChatPhoto, ok=True, result=True) @@ -17,7 +18,6 @@ class TestSetChatPhoto: assert request.method == "setChatPhoto" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetChatPhoto, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_set_chat_sticker_set.py b/tests/test_api/test_methods/test_set_chat_sticker_set.py index 50a87ca7..311b2dd8 100644 --- a/tests/test_api/test_methods/test_set_chat_sticker_set.py +++ b/tests/test_api/test_methods/test_set_chat_sticker_set.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import Request, SetChatStickerSet from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSetChatStickerSet: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetChatStickerSet, ok=True, result=True) @@ -14,7 +15,6 @@ class TestSetChatStickerSet: assert request.method == "setChatStickerSet" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetChatStickerSet, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_set_chat_title.py b/tests/test_api/test_methods/test_set_chat_title.py index 40473bc1..01558d84 100644 --- a/tests/test_api/test_methods/test_set_chat_title.py +++ b/tests/test_api/test_methods/test_set_chat_title.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import Request, SetChatTitle from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSetChatTitle: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetChatTitle, ok=True, result=True) @@ -14,7 +15,6 @@ class TestSetChatTitle: assert request.method == "setChatTitle" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetChatTitle, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_set_game_score.py b/tests/test_api/test_methods/test_set_game_score.py index 5b6cbb84..c8177625 100644 --- a/tests/test_api/test_methods/test_set_game_score.py +++ b/tests/test_api/test_methods/test_set_game_score.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, SetGameScore from aiogram.types import Message from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSetGameScore: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetGameScore, ok=True, result=True) @@ -19,7 +20,6 @@ class TestSetGameScore: assert request.method == "setGameScore" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetGameScore, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_set_my_commands.py b/tests/test_api/test_methods/test_set_my_commands.py index 23b9476e..ec08bbc3 100644 --- a/tests/test_api/test_methods/test_set_my_commands.py +++ b/tests/test_api/test_methods/test_set_my_commands.py @@ -4,9 +4,10 @@ from aiogram.methods import Request, SetMyCommands from aiogram.types import BotCommand from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSetMyCommands: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetMyCommands, ok=True, result=None) @@ -17,7 +18,6 @@ class TestSetMyCommands: assert request.method == "setMyCommands" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetMyCommands, ok=True, result=None) diff --git a/tests/test_api/test_methods/test_set_passport_data_errors.py b/tests/test_api/test_methods/test_set_passport_data_errors.py index e5ad0a8d..fba464f7 100644 --- a/tests/test_api/test_methods/test_set_passport_data_errors.py +++ b/tests/test_api/test_methods/test_set_passport_data_errors.py @@ -4,9 +4,10 @@ from aiogram.methods import Request, SetPassportDataErrors from aiogram.types import PassportElementError from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSetPassportDataErrors: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetPassportDataErrors, ok=True, result=True) @@ -15,7 +16,6 @@ class TestSetPassportDataErrors: assert request.method == "setPassportDataErrors" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetPassportDataErrors, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_set_sticker_position_in_set.py b/tests/test_api/test_methods/test_set_sticker_position_in_set.py index b1e72507..4de83feb 100644 --- a/tests/test_api/test_methods/test_set_sticker_position_in_set.py +++ b/tests/test_api/test_methods/test_set_sticker_position_in_set.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import Request, SetStickerPositionInSet from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSetStickerPositionInSet: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetStickerPositionInSet, ok=True, result=True) @@ -14,7 +15,6 @@ class TestSetStickerPositionInSet: assert request.method == "setStickerPositionInSet" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetStickerPositionInSet, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_set_sticker_set_thumb.py b/tests/test_api/test_methods/test_set_sticker_set_thumb.py index b31526d5..2ded22ee 100644 --- a/tests/test_api/test_methods/test_set_sticker_set_thumb.py +++ b/tests/test_api/test_methods/test_set_sticker_set_thumb.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import Request, SetStickerSetThumb from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSetStickerSetThumb: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetStickerSetThumb, ok=True, result=None) @@ -15,7 +16,6 @@ class TestSetStickerSetThumb: # assert request.data == {} assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetStickerSetThumb, ok=True, result=None) diff --git a/tests/test_api/test_methods/test_set_webhook.py b/tests/test_api/test_methods/test_set_webhook.py index 08ddae7d..48a67c73 100644 --- a/tests/test_api/test_methods/test_set_webhook.py +++ b/tests/test_api/test_methods/test_set_webhook.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import Request, SetWebhook from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSetWebhook: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetWebhook, ok=True, result=True) @@ -14,7 +15,6 @@ class TestSetWebhook: assert request.method == "setWebhook" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetWebhook, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_stop_message_live_location.py b/tests/test_api/test_methods/test_stop_message_live_location.py index 8ea1ed62..4d34795b 100644 --- a/tests/test_api/test_methods/test_stop_message_live_location.py +++ b/tests/test_api/test_methods/test_stop_message_live_location.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, StopMessageLiveLocation from aiogram.types import Message from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestStopMessageLiveLocation: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(StopMessageLiveLocation, ok=True, result=True) @@ -19,7 +20,6 @@ class TestStopMessageLiveLocation: assert request.method == "stopMessageLiveLocation" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(StopMessageLiveLocation, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_stop_poll.py b/tests/test_api/test_methods/test_stop_poll.py index 03ea9b75..e3b83bc1 100644 --- a/tests/test_api/test_methods/test_stop_poll.py +++ b/tests/test_api/test_methods/test_stop_poll.py @@ -4,9 +4,10 @@ from aiogram.methods import Request, StopPoll from aiogram.types import Poll, PollOption from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestStopPoll: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( StopPoll, @@ -29,7 +30,6 @@ class TestStopPoll: assert request.method == "stopPoll" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( StopPoll, diff --git a/tests/test_api/test_methods/test_unban_chat_member.py b/tests/test_api/test_methods/test_unban_chat_member.py index 2d0fffbd..0139b9de 100644 --- a/tests/test_api/test_methods/test_unban_chat_member.py +++ b/tests/test_api/test_methods/test_unban_chat_member.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import Request, UnbanChatMember from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestUnbanChatMember: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(UnbanChatMember, ok=True, result=True) @@ -14,7 +15,6 @@ class TestUnbanChatMember: assert request.method == "unbanChatMember" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(UnbanChatMember, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_unpin_all_chat_messages.py b/tests/test_api/test_methods/test_unpin_all_chat_messages.py index 48348dfd..24d90171 100644 --- a/tests/test_api/test_methods/test_unpin_all_chat_messages.py +++ b/tests/test_api/test_methods/test_unpin_all_chat_messages.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import Request, UnpinAllChatMessages from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestUnpinAllChatMessages: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(UnpinAllChatMessages, ok=True, result=True) @@ -17,7 +18,6 @@ class TestUnpinAllChatMessages: # assert request.data == {} assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(UnpinAllChatMessages, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_unpin_chat_message.py b/tests/test_api/test_methods/test_unpin_chat_message.py index 1ebe5ccb..7a0bca41 100644 --- a/tests/test_api/test_methods/test_unpin_chat_message.py +++ b/tests/test_api/test_methods/test_unpin_chat_message.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import Request, UnpinChatMessage from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestUnpinChatMessage: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(UnpinChatMessage, ok=True, result=True) @@ -14,7 +15,6 @@ class TestUnpinChatMessage: assert request.method == "unpinChatMessage" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(UnpinChatMessage, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_upload_sticker_file.py b/tests/test_api/test_methods/test_upload_sticker_file.py index 22b85a33..2a7b14ea 100644 --- a/tests/test_api/test_methods/test_upload_sticker_file.py +++ b/tests/test_api/test_methods/test_upload_sticker_file.py @@ -4,9 +4,10 @@ from aiogram.methods import Request, UploadStickerFile from aiogram.types import BufferedInputFile, File from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestUploadStickerFile: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( UploadStickerFile, ok=True, result=File(file_id="file id", file_unique_id="file id") @@ -19,7 +20,6 @@ class TestUploadStickerFile: assert request.method == "uploadStickerFile" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( UploadStickerFile, ok=True, result=File(file_id="file id", file_unique_id="file id") diff --git a/tests/test_api/test_types/test_chat_member.py b/tests/test_api/test_types/test_chat_member.py deleted file mode 100644 index e92c7203..00000000 --- a/tests/test_api/test_types/test_chat_member.py +++ /dev/null @@ -1,29 +0,0 @@ -import pytest - -from aiogram.types import ChatMember, User - -user = User(id=42, is_bot=False, first_name="User", last_name=None) - - -class TestChatMember: - @pytest.mark.parametrize( - "status,result", [["administrator", True], ["creator", True], ["member", False]] - ) - def test_is_chat_admin(self, status: str, result: bool): - chat_member = ChatMember(user=user, status=status) - assert chat_member.is_chat_admin == result - - @pytest.mark.parametrize( - "status,result", - [ - ["administrator", True], - ["creator", True], - ["member", True], - ["restricted", True], - ["kicked", False], - ["left", False], - ], - ) - def test_is_chat_member(self, status: str, result: bool): - chat_member = ChatMember(user=user, status=status) - assert chat_member.is_chat_member == result diff --git a/tests/test_api/test_types/test_input_file.py b/tests/test_api/test_types/test_input_file.py index e59dc461..9317158e 100644 --- a/tests/test_api/test_types/test_input_file.py +++ b/tests/test_api/test_types/test_input_file.py @@ -6,6 +6,8 @@ from aresponses import ResponsesMockServer from aiogram import Bot from aiogram.types import BufferedInputFile, FSInputFile, InputFile, URLInputFile +pytestmark = pytest.mark.asyncio + class TestInputFile: def test_fs_input_file(self): @@ -18,7 +20,6 @@ class TestInputFile: assert file.filename.endswith(".py") assert file.chunk_size > 0 - @pytest.mark.asyncio async def test_fs_input_file_readable(self): file = FSInputFile(__file__, chunk_size=1) @@ -39,7 +40,6 @@ class TestInputFile: assert file.filename == "file.bin" assert isinstance(file.data, bytes) - @pytest.mark.asyncio async def test_buffered_input_file_readable(self): file = BufferedInputFile(b"\f" * 10, filename="file.bin", chunk_size=1) @@ -50,7 +50,6 @@ class TestInputFile: size += chunk_size assert size == 10 - @pytest.mark.asyncio async def test_buffered_input_file_from_file(self): file = BufferedInputFile.from_file(__file__, chunk_size=10) @@ -62,7 +61,6 @@ class TestInputFile: assert isinstance(file.data, bytes) assert file.chunk_size == 10 - @pytest.mark.asyncio async def test_buffered_input_file_from_file_readable(self): file = BufferedInputFile.from_file(__file__, chunk_size=1) @@ -73,7 +71,6 @@ class TestInputFile: size += chunk_size assert size > 0 - @pytest.mark.asyncio async def test_uri_input_file(self, aresponses: ResponsesMockServer): aresponses.add( aresponses.ANY, aresponses.ANY, "get", aresponses.Response(status=200, body=b"\f" * 10) diff --git a/tests/test_api/test_types/test_message.py b/tests/test_api/test_types/test_message.py index 5e56ed8a..cb450731 100644 --- a/tests/test_api/test_types/test_message.py +++ b/tests/test_api/test_types/test_message.py @@ -5,6 +5,10 @@ import pytest from aiogram.methods import ( CopyMessage, + DeleteMessage, + EditMessageCaption, + EditMessageReplyMarkup, + EditMessageText, SendAnimation, SendAudio, SendContact, @@ -33,6 +37,8 @@ from aiogram.types import ( Document, EncryptedCredentials, Game, + InlineKeyboardButton, + InlineKeyboardMarkup, Invoice, Location, MessageAutoDeleteTimerChanged, @@ -549,3 +555,86 @@ class TestMessage: if method: assert isinstance(method, expected_method) # TODO: Check additional fields + + def test_edit_text(self): + message = Message( + message_id=42, chat=Chat(id=42, type="private"), date=datetime.datetime.now() + ) + method = message.edit_text(text="test") + assert isinstance(method, EditMessageText) + assert method.chat_id == message.chat.id + + def test_edit_reply_markup(self): + reply_markup = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="test", + callback_data="test", + ), + ], + ] + ) + reply_markup_new = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="test2", + callback_data="test2", + ), + ], + ] + ) + + message = Message( + message_id=42, + chat=Chat(id=42, type="private"), + date=datetime.datetime.now(), + reply_markup=reply_markup, + ) + method = message.edit_reply_markup( + reply_markup=reply_markup_new, + ) + assert isinstance(method, EditMessageReplyMarkup) + assert method.reply_markup == reply_markup_new + assert method.chat_id == message.chat.id + + def test_delete_reply_markup(self): + reply_markup = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="test", + callback_data="test", + ), + ], + ] + ) + + message = Message( + message_id=42, + chat=Chat(id=42, type="private"), + date=datetime.datetime.now(), + reply_markup=reply_markup, + ) + method = message.delete_reply_markup() + assert isinstance(method, EditMessageReplyMarkup) + assert method.reply_markup is None + assert method.chat_id == message.chat.id + + def test_edit_caption(self): + message = Message( + message_id=42, chat=Chat(id=42, type="private"), date=datetime.datetime.now() + ) + method = message.edit_caption(caption="test") + assert isinstance(method, EditMessageCaption) + assert method.chat_id == message.chat.id + + def test_delete(self): + message = Message( + message_id=42, chat=Chat(id=42, type="private"), date=datetime.datetime.now() + ) + method = message.delete() + assert isinstance(method, DeleteMessage) + assert method.chat_id == message.chat.id + assert method.message_id == message.message_id diff --git a/tests/test_dispatcher/test_dispatcher.py b/tests/test_dispatcher/test_dispatcher.py index ecf44712..520b190c 100644 --- a/tests/test_dispatcher/test_dispatcher.py +++ b/tests/test_dispatcher/test_dispatcher.py @@ -2,6 +2,7 @@ import asyncio import datetime import time import warnings +from collections import Counter from typing import Any import pytest @@ -9,14 +10,12 @@ import pytest from aiogram import Bot from aiogram.dispatcher.dispatcher import Dispatcher from aiogram.dispatcher.event.bases import UNHANDLED, SkipHandler -from aiogram.dispatcher.fsm.strategy import FSMStrategy -from aiogram.dispatcher.middlewares.user_context import UserContextMiddleware from aiogram.dispatcher.router import Router from aiogram.methods import GetMe, GetUpdates, SendMessage from aiogram.types import ( CallbackQuery, Chat, - ChatMember, + ChatMemberMember, ChatMemberUpdated, ChosenInlineResult, InlineQuery, @@ -30,6 +29,7 @@ from aiogram.types import ( Update, User, ) +from aiogram.utils.handlers_in_use import get_handlers_in_use from tests.mocked_bot import MockedBot try: @@ -38,6 +38,8 @@ except ImportError: from unittest.mock import AsyncMock as CoroutineMock # type: ignore from unittest.mock import patch +pytestmark = pytest.mark.asyncio + async def simple_message_handler(message: Message): await asyncio.sleep(0.2) @@ -49,6 +51,10 @@ async def invalid_message_handler(message: Message): raise Exception(42) +async def anext(ait): + return await ait.__anext__() + + RAW_UPDATE = { "update_id": 42, "message": { @@ -78,7 +84,6 @@ class TestDispatcher: dp._parent_router = Router() assert dp.parent_router is None - @pytest.mark.asyncio @pytest.mark.parametrize("isolate_events", (True, False)) async def test_feed_update(self, isolate_events): dp = Dispatcher(isolate_events=isolate_events) @@ -108,7 +113,6 @@ class TestDispatcher: results_count += 1 assert result == "test" - @pytest.mark.asyncio async def test_feed_raw_update(self): dp = Dispatcher() bot = Bot("42:TEST") @@ -133,7 +137,6 @@ class TestDispatcher: ) assert result == "test" - @pytest.mark.asyncio async def test_listen_updates(self, bot: MockedBot): dispatcher = Dispatcher() bot.add_result_for( @@ -147,7 +150,20 @@ class TestDispatcher: break assert index == 42 - @pytest.mark.asyncio + async def test_listen_update_with_error(self, bot: MockedBot): + dispatcher = Dispatcher() + listen = dispatcher._listen_updates(bot=bot) + bot.add_result_for( + GetUpdates, ok=True, result=[Update(update_id=update_id) for update_id in range(42)] + ) + bot.add_result_for(GetUpdates, ok=False, error_code=500, description="restarting") + with patch( + "aiogram.utils.backoff.Backoff.asleep", + new_callable=CoroutineMock, + ) as mocked_asleep: + assert isinstance(await anext(listen), Update) + assert mocked_asleep.awaited + async def test_silent_call_request(self, bot: MockedBot, caplog): dispatcher = Dispatcher() bot.add_result_for(SendMessage, ok=False, error_code=400, description="Kaboom") @@ -156,14 +172,12 @@ class TestDispatcher: assert len(log_records) == 1 assert "Failed to make answer" in log_records[0] - @pytest.mark.asyncio async def test_process_update_empty(self, bot: MockedBot): dispatcher = Dispatcher() result = await dispatcher._process_update(bot=bot, update=Update(update_id=42)) assert not result - @pytest.mark.asyncio async def test_process_update_handled(self, bot: MockedBot): dispatcher = Dispatcher() @@ -173,7 +187,6 @@ class TestDispatcher: assert await dispatcher._process_update(bot=bot, update=Update(update_id=42)) - @pytest.mark.asyncio @pytest.mark.parametrize( "event_type,update,has_chat,has_user", [ @@ -376,11 +389,11 @@ class TestDispatcher: chat=Chat(id=42, type="private"), from_user=User(id=42, is_bot=False, first_name="Test"), date=datetime.datetime.now(), - old_chat_member=ChatMember( - user=User(id=42, is_bot=False, first_name="Test"), status="restricted" + old_chat_member=ChatMemberMember( + user=User(id=42, is_bot=False, first_name="Test") ), - new_chat_member=ChatMember( - user=User(id=42, is_bot=False, first_name="Test"), status="restricted" + new_chat_member=ChatMemberMember( + user=User(id=42, is_bot=False, first_name="Test") ), ), ), @@ -395,11 +408,11 @@ class TestDispatcher: chat=Chat(id=42, type="private"), from_user=User(id=42, is_bot=False, first_name="Test"), date=datetime.datetime.now(), - old_chat_member=ChatMember( - user=User(id=42, is_bot=False, first_name="Test"), status="restricted" + old_chat_member=ChatMemberMember( + user=User(id=42, is_bot=False, first_name="Test") ), - new_chat_member=ChatMember( - user=User(id=42, is_bot=False, first_name="Test"), status="restricted" + new_chat_member=ChatMemberMember( + user=User(id=42, is_bot=False, first_name="Test") ), ), ), @@ -409,7 +422,12 @@ class TestDispatcher: ], ) async def test_listen_update( - self, event_type: str, update: Update, has_chat: bool, has_user: bool + self, + event_type: str, + update: Update, + has_chat: bool, + has_user: bool, + bot: MockedBot, ): router = Dispatcher() observer = router.observers[event_type] @@ -423,20 +441,18 @@ class TestDispatcher: assert User.get_current(False) return kwargs - result = await router.update.trigger(update, test="PASS") + result = await router.feed_update(bot, update, test="PASS") assert isinstance(result, dict) assert result["event_update"] == update assert result["event_router"] == router assert result["test"] == "PASS" - @pytest.mark.asyncio async def test_listen_unknown_update(self): dp = Dispatcher() with pytest.raises(SkipHandler): await dp._listen_update(Update(update_id=42)) - @pytest.mark.asyncio async def test_listen_unhandled_update(self): dp = Dispatcher() observer = dp.observers["message"] @@ -466,8 +482,7 @@ class TestDispatcher: ) assert response is UNHANDLED - @pytest.mark.asyncio - async def test_nested_router_listen_update(self): + async def test_nested_router_listen_update(self, bot: MockedBot): dp = Dispatcher() router0 = Router() router1 = Router() @@ -489,13 +504,55 @@ class TestDispatcher: from_user=User(id=42, is_bot=False, first_name="Test"), ), ) - result = await dp._listen_update(update, test="PASS") + result = await dp.feed_update(bot, update, test="PASS") assert isinstance(result, dict) assert result["event_update"] == update assert result["event_router"] == router1 assert result["test"] == "PASS" - @pytest.mark.asyncio + async def test_nested_router_middleware_resolution(self, bot: MockedBot): + counter = Counter() + + def mw(type_: str, inject_data: dict): + async def middleware(h, event, data): + counter[type_] += 1 + data.update(inject_data) + return await h(event, data) + + return middleware + + async def handler(event, foo, bar, baz, fizz, buzz): + counter["child.handler"] += 1 + + root = Dispatcher() + child = Router() + + root.message.outer_middleware(mw("root.outer_middleware", {"foo": True})) + root.message.middleware(mw("root.middleware", {"bar": None})) + child.message.outer_middleware(mw("child.outer_middleware", {"fizz": 42})) + child.message.middleware(mw("child.middleware", {"buzz": -42})) + child.message.register(handler) + + root.include_router(child) + await root.feed_update( + bot=bot, + update=Update( + update_id=42, + message=Message( + message_id=42, + date=datetime.datetime.fromtimestamp(0), + chat=Chat(id=-42, type="group"), + ), + ), + baz=..., + ) + + assert counter["root.outer_middleware"] == 1 + assert counter["root.middleware"] == 1 + assert counter["child.outer_middleware"] == 1 + assert counter["child.middleware"] == 1 + assert counter["child.handler"] == 1 + async def test_process_update_call_request(self, bot: MockedBot): dispatcher = Dispatcher() @@ -513,7 +570,6 @@ class TestDispatcher: print(result) mocked_silent_call_request.assert_awaited() - @pytest.mark.asyncio async def test_process_update_exception(self, bot: MockedBot, caplog): dispatcher = Dispatcher() @@ -526,8 +582,8 @@ class TestDispatcher: assert len(log_records) == 1 assert "Cause exception while process update" in log_records[0] - @pytest.mark.asyncio - async def test_polling(self, bot: MockedBot): + @pytest.mark.parametrize("as_task", [True, False]) + async def test_polling(self, bot: MockedBot, as_task: bool): dispatcher = Dispatcher() async def _mock_updates(*_): @@ -539,18 +595,23 @@ class TestDispatcher: "aiogram.dispatcher.dispatcher.Dispatcher._listen_updates" ) as patched_listen_updates: patched_listen_updates.return_value = _mock_updates() - await dispatcher._polling(bot=bot) - mocked_process_update.assert_awaited() + await dispatcher._polling(bot=bot, handle_as_tasks=as_task) + if as_task: + pass + else: + mocked_process_update.assert_awaited() - @pytest.mark.asyncio - async def test_exception_handler_catch_exceptions(self): + async def test_exception_handler_catch_exceptions(self, bot: MockedBot): dp = Dispatcher() router = Router() dp.include_router(router) + class CustomException(Exception): + pass + @router.message() async def message_handler(message: Message): - raise Exception("KABOOM") + raise CustomException("KABOOM") update = Update( update_id=42, @@ -562,26 +623,25 @@ class TestDispatcher: from_user=User(id=42, is_bot=False, first_name="Test"), ), ) - with pytest.raises(Exception, match="KABOOM"): - await dp.update.trigger(update) + with pytest.raises(CustomException, match="KABOOM"): + await dp.feed_update(bot, update) @router.errors() async def error_handler(event: Update, exception: Exception): return "KABOOM" - response = await dp.update.trigger(update) + response = await dp.feed_update(bot, update) assert response == "KABOOM" @dp.errors() async def root_error_handler(event: Update, exception: Exception): return exception - response = await dp.update.trigger(update) + response = await dp.feed_update(bot, update) - assert isinstance(response, Exception) + assert isinstance(response, CustomException) assert str(response) == "KABOOM" - @pytest.mark.asyncio async def test_start_polling(self, bot: MockedBot): dispatcher = Dispatcher() bot.add_result_for( @@ -615,7 +675,6 @@ class TestDispatcher: dispatcher.run_polling(bot) patched_start_polling.assert_awaited_once() - @pytest.mark.asyncio async def test_feed_webhook_update_fast_process(self, bot: MockedBot): dispatcher = Dispatcher() dispatcher.message.register(simple_message_handler) @@ -625,7 +684,6 @@ class TestDispatcher: assert response["method"] == "sendMessage" assert response["text"] == "ok" - @pytest.mark.asyncio async def test_feed_webhook_update_slow_process(self, bot: MockedBot, recwarn): warnings.simplefilter("always") @@ -641,7 +699,6 @@ class TestDispatcher: await asyncio.sleep(0.5) mocked_silent_call_request.assert_awaited() - @pytest.mark.asyncio async def test_feed_webhook_update_fast_process_error(self, bot: MockedBot, caplog): warnings.simplefilter("always") @@ -655,19 +712,55 @@ class TestDispatcher: log_records = [rec.message for rec in caplog.records] assert "Cause exception while process update" in log_records[0] - @pytest.mark.parametrize( - "strategy,case,expected", - [ - [FSMStrategy.USER_IN_CHAT, (-42, 42), (-42, 42)], - [FSMStrategy.CHAT, (-42, 42), (-42, -42)], - [FSMStrategy.GLOBAL_USER, (-42, 42), (42, 42)], - [FSMStrategy.USER_IN_CHAT, (42, 42), (42, 42)], - [FSMStrategy.CHAT, (42, 42), (42, 42)], - [FSMStrategy.GLOBAL_USER, (42, 42), (42, 42)], - ], - ) - def test_get_current_state_context(self, strategy, case, expected): - dp = Dispatcher(fsm_strategy=strategy) - chat_id, user_id = case - state = dp.current_state(chat_id=chat_id, user_id=user_id) - assert (state.chat_id, state.user_id) == expected + def test_specify_updates_calculation(self): + def simple_msg_handler() -> None: + ... + + def simple_callback_query_handler() -> None: + ... + + def simple_poll_handler() -> None: + ... + + def simple_edited_msg_handler() -> None: + ... + + dispatcher = Dispatcher() + dispatcher.message.register(simple_msg_handler) + + router1 = Router() + router1.callback_query.register(simple_callback_query_handler) + + router2 = Router() + router2.poll.register(simple_poll_handler) + + router21 = Router() + router21.edited_message.register(simple_edited_msg_handler) + + useful_updates1 = get_handlers_in_use(dispatcher) + + assert sorted(useful_updates1) == sorted(["message"]) + + dispatcher.include_router(router1) + + useful_updates2 = get_handlers_in_use(dispatcher) + + assert sorted(useful_updates2) == sorted(["message", "callback_query"]) + + dispatcher.include_router(router2) + + useful_updates3 = get_handlers_in_use(dispatcher) + + assert sorted(useful_updates3) == sorted(["message", "callback_query", "poll"]) + + router2.include_router(router21) + + useful_updates4 = get_handlers_in_use(dispatcher) + + assert sorted(useful_updates4) == sorted( + ["message", "callback_query", "poll", "edited_message"] + ) + + useful_updates5 = get_handlers_in_use(router2) + + assert sorted(useful_updates5) == sorted(["poll", "edited_message"]) diff --git a/tests/test_dispatcher/test_event/test_event.py b/tests/test_dispatcher/test_event/test_event.py index be733ebb..735c498b 100644 --- a/tests/test_dispatcher/test_event/test_event.py +++ b/tests/test_dispatcher/test_event/test_event.py @@ -12,6 +12,8 @@ except ImportError: from unittest.mock import AsyncMock as CoroutineMock # type: ignore from unittest.mock import patch +pytestmark = pytest.mark.asyncio + async def my_handler(value: str, index: int = 0) -> Any: return value @@ -39,7 +41,6 @@ class TestEventObserver: assert registered_handler.callback == wrapped_handler assert not registered_handler.filters - @pytest.mark.asyncio async def test_trigger(self): observer = EventObserver() diff --git a/tests/test_dispatcher/test_event/test_handler.py b/tests/test_dispatcher/test_event/test_handler.py index d7e6a1da..7257b47b 100644 --- a/tests/test_dispatcher/test_event/test_handler.py +++ b/tests/test_dispatcher/test_event/test_handler.py @@ -5,11 +5,12 @@ import pytest from aiogram import F from aiogram.dispatcher.event.handler import CallableMixin, FilterObject, HandlerObject -from aiogram.dispatcher.filters import Text from aiogram.dispatcher.filters.base import BaseFilter from aiogram.dispatcher.handler.base import BaseHandler from aiogram.types import Update +pytestmark = pytest.mark.asyncio + def callback1(foo: int, bar: int, baz: int): return locals() @@ -23,6 +24,10 @@ async def callback3(foo: int, **kwargs): return locals() +async def callback4(foo: int, *, bar: int, baz: int): + return locals() + + class Filter(BaseFilter): async def __call__(self, foo: int, bar: int, baz: int) -> Union[bool, Dict[str, Any]]: return locals() @@ -96,11 +101,21 @@ class TestCallableMixin: {"foo": 42, "spam": True, "baz": "fuz", "bar": "test"}, {"foo": 42, "baz": "fuz", "bar": "test"}, ), + pytest.param( + functools.partial(callback2, bar="test"), + {"foo": 42, "spam": True, "baz": "fuz"}, + {"foo": 42, "baz": "fuz"}, + ), pytest.param( callback3, {"foo": 42, "spam": True, "baz": "fuz", "bar": "test"}, {"foo": 42, "spam": True, "baz": "fuz", "bar": "test"}, ), + pytest.param( + callback4, + {"foo": 42, "spam": True, "baz": "fuz", "bar": "test"}, + {"foo": 42, "baz": "fuz", "bar": "test"}, + ), pytest.param( Filter(), {"foo": 42, "spam": True, "baz": "fuz"}, {"foo": 42, "baz": "fuz"} ), @@ -113,14 +128,12 @@ class TestCallableMixin: obj = CallableMixin(callback) assert obj._prepare_kwargs(kwargs) == result - @pytest.mark.asyncio async def test_sync_call(self): obj = CallableMixin(callback1) result = await obj.call(foo=42, bar="test", baz="fuz", spam=True) assert result == {"foo": 42, "bar": "test", "baz": "fuz"} - @pytest.mark.asyncio async def test_async_call(self): obj = CallableMixin(callback2) @@ -141,14 +154,12 @@ async def simple_handler(*args, **kwargs): class TestHandlerObject: - @pytest.mark.asyncio async def test_check_with_bool_result(self): handler = HandlerObject(simple_handler, [FilterObject(lambda value: True)] * 3) result, data = await handler.check(42, foo=True) assert result assert data == {"foo": True} - @pytest.mark.asyncio async def test_check_with_dict_result(self): handler = HandlerObject( simple_handler, @@ -163,7 +174,6 @@ class TestHandlerObject: assert result assert data == {"foo": True, "test0": "ok", "test1": "ok", "test2": "ok"} - @pytest.mark.asyncio async def test_check_with_combined_result(self): handler = HandlerObject( simple_handler, @@ -173,13 +183,11 @@ class TestHandlerObject: assert result assert data == {"foo": True, "test": 42} - @pytest.mark.asyncio async def test_check_rejected(self): handler = HandlerObject(simple_handler, [FilterObject(lambda value: False)]) result, data = await handler.check(42, foo=True) assert not result - @pytest.mark.asyncio async def test_check_partial_rejected(self): handler = HandlerObject( simple_handler, [FilterObject(lambda value: True), FilterObject(lambda value: False)] @@ -187,7 +195,6 @@ class TestHandlerObject: result, data = await handler.check(42, foo=True) assert not result - @pytest.mark.asyncio async def test_class_based_handler(self): class MyHandler(BaseHandler): event: Update diff --git a/tests/test_dispatcher/test_event/test_telegram.py b/tests/test_dispatcher/test_event/test_telegram.py index d4306b1a..39535219 100644 --- a/tests/test_dispatcher/test_event/test_telegram.py +++ b/tests/test_dispatcher/test_event/test_telegram.py @@ -4,13 +4,16 @@ from typing import Any, Awaitable, Callable, Dict, NoReturn, Union import pytest -from aiogram.dispatcher.event.bases import SkipHandler +from aiogram.dispatcher.event.bases import REJECTED, SkipHandler from aiogram.dispatcher.event.handler import HandlerObject from aiogram.dispatcher.event.telegram import TelegramEventObserver from aiogram.dispatcher.filters.base import BaseFilter from aiogram.dispatcher.router import Router from aiogram.types import Chat, Message, User +pytestmark = pytest.mark.asyncio + + # TODO: Test middlewares in routers tree @@ -138,7 +141,6 @@ class TestTelegramEventObserver: assert len(observer.handlers) == 1 assert observer.handlers[0].callback == my_handler - @pytest.mark.asyncio async def test_trigger(self): router = Router(use_builtin_filters=False) observer = router.message @@ -178,7 +180,6 @@ class TestTelegramEventObserver: assert registered_handler.callback == wrapped_handler assert len(registered_handler.filters) == len(filters) - @pytest.mark.asyncio async def test_trigger_right_context_in_handlers(self): router = Router(use_builtin_filters=False) observer = router.message @@ -233,3 +234,46 @@ class TestTelegramEventObserver: assert my_middleware3 in middlewares assert middlewares == [my_middleware1, my_middleware2, my_middleware3] + + def test_register_global_filters(self): + router = Router(use_builtin_filters=False) + assert isinstance(router.message._handler.filters, list) + assert not router.message._handler.filters + + my_filter = MyFilter1(test="pass") + router.message.filter(my_filter) + + assert len(router.message._handler.filters) == 1 + assert router.message._handler.filters[0].callback is my_filter + + router.message._handler.filters = None + router.message.filter(my_filter) + assert len(router.message._handler.filters) == 1 + assert router.message._handler.filters[0].callback is my_filter + + async def test_global_filter(self): + r1 = Router() + r2 = Router() + + async def handler(evt): + return evt + + r1.message.filter(lambda evt: False) + r1.message.register(handler) + r2.message.register(handler) + + assert await r1.message.trigger(None) is REJECTED + assert await r2.message.trigger(None) is None + + async def test_global_filter_in_nested_router(self): + r1 = Router() + r2 = Router() + + async def handler(evt): + return evt + + r1.include_router(r2) + r1.message.filter(lambda evt: False) + r2.message.register(handler) + + assert await r1.message.trigger(None) is REJECTED diff --git a/tests/test_dispatcher/test_filters/test_base.py b/tests/test_dispatcher/test_filters/test_base.py index 27a1f349..3e1e9a3e 100644 --- a/tests/test_dispatcher/test_filters/test_base.py +++ b/tests/test_dispatcher/test_filters/test_base.py @@ -10,6 +10,8 @@ except ImportError: from unittest.mock import AsyncMock as CoroutineMock # type: ignore from unittest.mock import patch +pytestmark = pytest.mark.asyncio + class MyFilter(BaseFilter): foo: str @@ -19,7 +21,6 @@ class MyFilter(BaseFilter): class TestBaseFilter: - @pytest.mark.asyncio async def test_awaitable(self): my_filter = MyFilter(foo="bar") diff --git a/tests/test_dispatcher/test_filters/test_callback_data.py b/tests/test_dispatcher/test_filters/test_callback_data.py new file mode 100644 index 00000000..cd7bc53e --- /dev/null +++ b/tests/test_dispatcher/test_filters/test_callback_data.py @@ -0,0 +1,181 @@ +from decimal import Decimal +from enum import Enum, auto +from fractions import Fraction +from typing import Optional +from uuid import UUID + +import pytest +from magic_filter import MagicFilter +from pydantic import ValidationError + +from aiogram import F +from aiogram.dispatcher.filters.callback_data import CallbackData +from aiogram.types import CallbackQuery, User + +pytestmark = pytest.mark.asyncio + + +class MyIntEnum(Enum): + FOO = auto() + + +class MyStringEnum(str, Enum): + FOO = "FOO" + + +class MyCallback(CallbackData, prefix="test"): + foo: str + bar: int + + +class TestCallbackData: + def test_init_subclass_prefix_required(self): + assert MyCallback.prefix == "test" + + with pytest.raises(ValueError, match="prefix required.+"): + + class MyInvalidCallback(CallbackData): + pass + + def test_init_subclass_sep_validation(self): + assert MyCallback.sep == ":" + + class MyCallback2(CallbackData, prefix="test2", sep="@"): + pass + + assert MyCallback2.sep == "@" + + with pytest.raises(ValueError, match="Separator symbol '@' .+ 'sp@m'"): + + class MyInvalidCallback(CallbackData, prefix="sp@m", sep="@"): + pass + + @pytest.mark.parametrize( + "value,success,expected", + [ + [None, True, ""], + [42, True, "42"], + ["test", True, "test"], + [9.99, True, "9.99"], + [Decimal("9.99"), True, "9.99"], + [Fraction("3/2"), True, "3/2"], + [ + UUID("123e4567-e89b-12d3-a456-426655440000"), + True, + "123e4567-e89b-12d3-a456-426655440000", + ], + [MyIntEnum.FOO, True, "1"], + [MyStringEnum.FOO, True, "FOO"], + [..., False, "..."], + [object, False, "..."], + [object(), False, "..."], + [User(id=42, is_bot=False, first_name="test"), False, "..."], + ], + ) + def test_encode_value(self, value, success, expected): + callback = MyCallback(foo="test", bar=42) + if success: + assert callback._encode_value("test", value) == expected + else: + with pytest.raises(ValueError): + assert callback._encode_value("test", value) == expected + + def test_pack(self): + with pytest.raises(ValueError, match="Separator symbol .+"): + assert MyCallback(foo="te:st", bar=42).pack() + + with pytest.raises(ValueError, match=".+is too long.+"): + assert MyCallback(foo="test" * 32, bar=42).pack() + + assert MyCallback(foo="test", bar=42).pack() == "test:test:42" + + def test_pack_optional(self): + class MyCallback1(CallbackData, prefix="test1"): + foo: str + bar: Optional[int] = None + + assert MyCallback1(foo="spam").pack() == "test1:spam:" + assert MyCallback1(foo="spam", bar=42).pack() == "test1:spam:42" + + class MyCallback2(CallbackData, prefix="test2"): + foo: Optional[str] = None + bar: int + + assert MyCallback2(bar=42).pack() == "test2::42" + assert MyCallback2(foo="spam", bar=42).pack() == "test2:spam:42" + + class MyCallback3(CallbackData, prefix="test3"): + foo: Optional[str] = "experiment" + bar: int + + assert MyCallback3(bar=42).pack() == "test3:experiment:42" + assert MyCallback3(foo="spam", bar=42).pack() == "test3:spam:42" + + def test_unpack(self): + with pytest.raises(TypeError, match=".+ takes 2 arguments but 3 were given"): + MyCallback.unpack("test:test:test:test") + + with pytest.raises(ValueError, match="Bad prefix .+"): + MyCallback.unpack("spam:test:test") + + assert MyCallback.unpack("test:test:42") == MyCallback(foo="test", bar=42) + + def test_unpack_optional(self): + with pytest.raises(ValidationError): + assert MyCallback.unpack("test:test:") + + class MyCallback1(CallbackData, prefix="test1"): + foo: str + bar: Optional[int] = None + + assert MyCallback1.unpack("test1:spam:") == MyCallback1(foo="spam") + assert MyCallback1.unpack("test1:spam:42") == MyCallback1(foo="spam", bar=42) + + class MyCallback2(CallbackData, prefix="test2"): + foo: Optional[str] = None + bar: int + + assert MyCallback2.unpack("test2::42") == MyCallback2(bar=42) + assert MyCallback2.unpack("test2:spam:42") == MyCallback2(foo="spam", bar=42) + + class MyCallback3(CallbackData, prefix="test3"): + foo: Optional[str] = "experiment" + bar: int + + assert MyCallback3.unpack("test3:experiment:42") == MyCallback3(bar=42) + assert MyCallback3.unpack("test3:spam:42") == MyCallback3(foo="spam", bar=42) + + def test_build_filter(self): + filter_object = MyCallback.filter(F.foo == "test") + assert isinstance(filter_object.rule, MagicFilter) + assert filter_object.callback_data is MyCallback + + +class TestCallbackDataFilter: + @pytest.mark.parametrize( + "query,rule,result", + [ + ["test", F.foo == "test", False], + ["test:spam:42", F.foo == "test", False], + ["test:test:42", F.foo == "test", {"callback_data": MyCallback(foo="test", bar=42)}], + ["test:test:42", None, {"callback_data": MyCallback(foo="test", bar=42)}], + ["test:test:777", None, {"callback_data": MyCallback(foo="test", bar=777)}], + ["spam:test:777", None, False], + ["test:test:", F.foo == "test", False], + ["test:test:", None, False], + ], + ) + async def test_call(self, query, rule, result): + callback_query = CallbackQuery( + id="1", + from_user=User(id=42, is_bot=False, first_name="test"), + data=query, + chat_instance="test", + ) + + filter_object = MyCallback.filter(rule) + assert await filter_object(callback_query) == result + + async def test_invalid_call(self): + filter_object = MyCallback.filter(F.test) + assert not await filter_object(User(id=42, is_bot=False, first_name="test")) diff --git a/tests/test_dispatcher/test_filters/test_command.py b/tests/test_dispatcher/test_filters/test_command.py index 6eb24097..51aabade 100644 --- a/tests/test_dispatcher/test_filters/test_command.py +++ b/tests/test_dispatcher/test_filters/test_command.py @@ -1,14 +1,17 @@ import datetime import re -from typing import Match import pytest +from aiogram import F from aiogram.dispatcher.filters import Command, CommandObject +from aiogram.dispatcher.filters.command import CommandStart from aiogram.methods import GetMe from aiogram.types import Chat, Message, User from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestCommandFilter: def test_convert_to_list(self): @@ -18,47 +21,54 @@ class TestCommandFilter: assert cmd.commands[0] == "start" assert cmd == Command(commands=["start"]) - @pytest.mark.asyncio - async def test_parse_command(self, bot: MockedBot): - # TODO: parametrize + @pytest.mark.parametrize( + "text,command,result", + [ + ["/test@tbot", Command(commands=["test"], commands_prefix="/"), True], + ["!test", Command(commands=["test"], commands_prefix="/"), False], + ["/test@mention", Command(commands=["test"], commands_prefix="/"), False], + ["/tests", Command(commands=["test"], commands_prefix="/"), False], + ["/", Command(commands=["test"], commands_prefix="/"), False], + ["/ test", Command(commands=["test"], commands_prefix="/"), False], + ["", Command(commands=["test"], commands_prefix="/"), False], + [" ", Command(commands=["test"], commands_prefix="/"), False], + ["test", Command(commands=["test"], commands_prefix="/"), False], + [" test", Command(commands=["test"], commands_prefix="/"), False], + ["a", Command(commands=["test"], commands_prefix="/"), False], + ["/test@tbot some args", Command(commands=["test"]), True], + ["/test42@tbot some args", Command(commands=[re.compile(r"test(\d+)")]), True], + [ + "/test42@tbot some args", + Command(commands=[re.compile(r"test(\d+)")], command_magic=F.args == "some args"), + True, + ], + [ + "/test42@tbot some args", + Command(commands=[re.compile(r"test(\d+)")], command_magic=F.args == "test"), + False, + ], + ["/start test", CommandStart(), True], + ["/start", CommandStart(deep_link=True), False], + ["/start test", CommandStart(deep_link=True), True], + ["/start test", CommandStart(deep_link=True, deep_link_encoded=True), False], + ["/start dGVzdA", CommandStart(deep_link=True, deep_link_encoded=True), True], + ], + ) + async def test_parse_command(self, bot: MockedBot, text: str, result: bool, command: Command): # TODO: test ignore case # TODO: test ignore mention bot.add_result_for( GetMe, ok=True, result=User(id=42, is_bot=True, first_name="The bot", username="tbot") ) - command = Command(commands=["test", re.compile(r"test(\d+)")], commands_prefix="/") - assert await command.parse_command("/test@tbot", bot) - assert not await command.parse_command("!test", bot) - assert not await command.parse_command("/test@mention", bot) - assert not await command.parse_command("/tests", bot) - assert not await command.parse_command("/", bot) - assert not await command.parse_command("/ test", bot) - assert not await command.parse_command("", bot) - assert not await command.parse_command(" ", bot) - assert not await command.parse_command("test", bot) - assert not await command.parse_command(" test", bot) - assert not await command.parse_command("a", bot) + message = Message( + message_id=0, text=text, chat=Chat(id=42, type="private"), date=datetime.datetime.now() + ) - result = await command.parse_command("/test@tbot some args", bot) - assert isinstance(result, dict) - assert "command" in result - assert isinstance(result["command"], CommandObject) - assert result["command"].command == "test" - assert result["command"].mention == "tbot" - assert result["command"].args == "some args" + response = await command(message, bot) + assert bool(response) is result - result = await command.parse_command("/test42@tbot some args", bot) - assert isinstance(result, dict) - assert "command" in result - assert isinstance(result["command"], CommandObject) - assert result["command"].command == "test42" - assert result["command"].mention == "tbot" - assert result["command"].args == "some args" - assert isinstance(result["command"].match, Match) - - @pytest.mark.asyncio @pytest.mark.parametrize( "message,result", [ diff --git a/tests/test_dispatcher/test_filters/test_content_types.py b/tests/test_dispatcher/test_filters/test_content_types.py index 63eb207e..8d1706b2 100644 --- a/tests/test_dispatcher/test_filters/test_content_types.py +++ b/tests/test_dispatcher/test_filters/test_content_types.py @@ -7,6 +7,8 @@ from pydantic import ValidationError from aiogram.dispatcher.filters import ContentTypesFilter from aiogram.types import ContentType, Message +pytestmark = pytest.mark.asyncio + @dataclass class MinimalMessage: @@ -14,7 +16,6 @@ class MinimalMessage: class TestContentTypesFilter: - @pytest.mark.asyncio async def test_validator_empty(self): filter_ = ContentTypesFilter() assert not filter_.content_types @@ -53,7 +54,6 @@ class TestContentTypesFilter: [[ContentType.ANY, ContentType.PHOTO, ContentType.DOCUMENT], ContentType.TEXT, True], ], ) - @pytest.mark.asyncio async def test_call(self, values, content_type, result): filter_ = ContentTypesFilter(content_types=values) assert await filter_(cast(Message, MinimalMessage(content_type=content_type))) == result diff --git a/tests/test_dispatcher/test_filters/test_exception.py b/tests/test_dispatcher/test_filters/test_exception.py index 4dd6d5d9..ca37b9e9 100644 --- a/tests/test_dispatcher/test_filters/test_exception.py +++ b/tests/test_dispatcher/test_filters/test_exception.py @@ -4,16 +4,17 @@ import pytest from aiogram.dispatcher.filters import ExceptionMessageFilter, ExceptionTypeFilter +pytestmark = pytest.mark.asyncio + class TestExceptionMessageFilter: @pytest.mark.parametrize("value", ["value", re.compile("value")]) def test_converter(self, value): - obj = ExceptionMessageFilter(match=value) - assert isinstance(obj.match, re.Pattern) + obj = ExceptionMessageFilter(pattern=value) + assert isinstance(obj.pattern, re.Pattern) - @pytest.mark.asyncio async def test_match(self): - obj = ExceptionMessageFilter(match="KABOOM") + obj = ExceptionMessageFilter(pattern="KABOOM") result = await obj(Exception()) assert not result @@ -32,7 +33,6 @@ class MyAnotherException(MyException): class TestExceptionTypeFilter: - @pytest.mark.asyncio @pytest.mark.parametrize( "exception,value", [ diff --git a/tests/test_dispatcher/test_filters/test_text.py b/tests/test_dispatcher/test_filters/test_text.py index 72f95e9d..ac3178de 100644 --- a/tests/test_dispatcher/test_filters/test_text.py +++ b/tests/test_dispatcher/test_filters/test_text.py @@ -9,6 +9,8 @@ from aiogram.dispatcher.filters import BUILTIN_FILTERS from aiogram.dispatcher.filters.text import Text from aiogram.types import CallbackQuery, Chat, InlineQuery, Message, Poll, PollOption, User +pytestmark = pytest.mark.asyncio + class TestText: def test_default_for_observer(self): @@ -240,7 +242,6 @@ class TestText: ["text", True, ["question", "another question"], object(), False], ], ) - @pytest.mark.asyncio async def test_check_text(self, argument, ignore_case, input_value, result, update_type): text = Text(**{argument: input_value}, text_ignore_case=ignore_case) assert await text(obj=update_type) is result diff --git a/tests/test_dispatcher/test_fsm/storage/test_memory.py b/tests/test_dispatcher/test_fsm/storage/test_memory.py deleted file mode 100644 index 2f587075..00000000 --- a/tests/test_dispatcher/test_fsm/storage/test_memory.py +++ /dev/null @@ -1,45 +0,0 @@ -import pytest - -from aiogram.dispatcher.fsm.storage.memory import MemoryStorage, MemoryStorageRecord - - -@pytest.fixture() -def storage(): - return MemoryStorage() - - -class TestMemoryStorage: - @pytest.mark.asyncio - async def test_set_state(self, storage: MemoryStorage): - assert await storage.get_state(chat_id=-42, user_id=42) is None - - await storage.set_state(chat_id=-42, user_id=42, state="state") - assert await storage.get_state(chat_id=-42, user_id=42) == "state" - - assert -42 in storage.storage - assert 42 in storage.storage[-42] - assert isinstance(storage.storage[-42][42], MemoryStorageRecord) - assert storage.storage[-42][42].state == "state" - - @pytest.mark.asyncio - async def test_set_data(self, storage: MemoryStorage): - assert await storage.get_data(chat_id=-42, user_id=42) == {} - - await storage.set_data(chat_id=-42, user_id=42, data={"foo": "bar"}) - assert await storage.get_data(chat_id=-42, user_id=42) == {"foo": "bar"} - - assert -42 in storage.storage - assert 42 in storage.storage[-42] - assert isinstance(storage.storage[-42][42], MemoryStorageRecord) - assert storage.storage[-42][42].data == {"foo": "bar"} - - @pytest.mark.asyncio - async def test_update_data(self, storage: MemoryStorage): - assert await storage.get_data(chat_id=-42, user_id=42) == {} - assert await storage.update_data(chat_id=-42, user_id=42, data={"foo": "bar"}) == { - "foo": "bar" - } - assert await storage.update_data(chat_id=-42, user_id=42, data={"baz": "spam"}) == { - "foo": "bar", - "baz": "spam", - } diff --git a/tests/test_dispatcher/test_fsm/storage/test_redis.py b/tests/test_dispatcher/test_fsm/storage/test_redis.py new file mode 100644 index 00000000..d3b4d090 --- /dev/null +++ b/tests/test_dispatcher/test_fsm/storage/test_redis.py @@ -0,0 +1,22 @@ +import pytest + +from aiogram.dispatcher.fsm.storage.redis import RedisStorage +from tests.mocked_bot import MockedBot + +pytestmark = pytest.mark.asyncio + + +@pytest.mark.redis +class TestRedisStorage: + @pytest.mark.parametrize( + "prefix_bot,result", + [ + [False, "fsm:-1:2"], + [True, "fsm:42:-1:2"], + [{42: "kaboom"}, "fsm:kaboom:-1:2"], + [lambda bot: "kaboom", "fsm:kaboom:-1:2"], + ], + ) + async def test_generate_key(self, bot: MockedBot, redis_server, prefix_bot, result): + storage = RedisStorage.from_url(redis_server, prefix_bot=prefix_bot) + assert storage.generate_key(bot, -1, 2) == result diff --git a/tests/test_dispatcher/test_fsm/storage/test_storages.py b/tests/test_dispatcher/test_fsm/storage/test_storages.py new file mode 100644 index 00000000..f7bbf082 --- /dev/null +++ b/tests/test_dispatcher/test_fsm/storage/test_storages.py @@ -0,0 +1,46 @@ +import pytest + +from aiogram.dispatcher.fsm.storage.base import BaseStorage +from tests.mocked_bot import MockedBot + +pytestmark = pytest.mark.asyncio + + +@pytest.mark.parametrize( + "storage", + [pytest.lazy_fixture("redis_storage"), pytest.lazy_fixture("memory_storage")], +) +class TestStorages: + async def test_lock(self, bot: MockedBot, storage: BaseStorage): + # TODO: ?!? + async with storage.lock(bot=bot, chat_id=-42, user_id=42): + assert True, "You are kidding me?" + + async def test_set_state(self, bot: MockedBot, storage: BaseStorage): + assert await storage.get_state(bot=bot, chat_id=-42, user_id=42) is None + + await storage.set_state(bot=bot, chat_id=-42, user_id=42, state="state") + assert await storage.get_state(bot=bot, chat_id=-42, user_id=42) == "state" + await storage.set_state(bot=bot, chat_id=-42, user_id=42, state=None) + assert await storage.get_state(bot=bot, chat_id=-42, user_id=42) is None + + async def test_set_data(self, bot: MockedBot, storage: BaseStorage): + assert await storage.get_data(bot=bot, chat_id=-42, user_id=42) == {} + + await storage.set_data(bot=bot, chat_id=-42, user_id=42, data={"foo": "bar"}) + assert await storage.get_data(bot=bot, chat_id=-42, user_id=42) == {"foo": "bar"} + await storage.set_data(bot=bot, chat_id=-42, user_id=42, data={}) + assert await storage.get_data(bot=bot, chat_id=-42, user_id=42) == {} + + async def test_update_data(self, bot: MockedBot, storage: BaseStorage): + assert await storage.get_data(bot=bot, chat_id=-42, user_id=42) == {} + assert await storage.update_data( + bot=bot, chat_id=-42, user_id=42, data={"foo": "bar"} + ) == {"foo": "bar"} + assert await storage.update_data( + bot=bot, chat_id=-42, user_id=42, data={"baz": "spam"} + ) == {"foo": "bar", "baz": "spam"} + assert await storage.get_data(bot=bot, chat_id=-42, user_id=42) == { + "foo": "bar", + "baz": "spam", + } diff --git a/tests/test_dispatcher/test_fsm/test_context.py b/tests/test_dispatcher/test_fsm/test_context.py index 6c444c44..ad68bd84 100644 --- a/tests/test_dispatcher/test_fsm/test_context.py +++ b/tests/test_dispatcher/test_fsm/test_context.py @@ -2,27 +2,29 @@ import pytest from aiogram.dispatcher.fsm.context import FSMContext from aiogram.dispatcher.fsm.storage.memory import MemoryStorage +from tests.mocked_bot import MockedBot + +pytestmark = pytest.mark.asyncio @pytest.fixture() -def state(): +def state(bot: MockedBot): storage = MemoryStorage() - ctx = storage.storage[-42][42] + ctx = storage.storage[bot][-42][42] ctx.state = "test" ctx.data = {"foo": "bar"} - return FSMContext(storage=storage, user_id=-42, chat_id=42) + return FSMContext(bot=bot, storage=storage, user_id=-42, chat_id=42) class TestFSMContext: - @pytest.mark.asyncio - async def test_address_mapping(self): + async def test_address_mapping(self, bot: MockedBot): storage = MemoryStorage() - ctx = storage.storage[-42][42] + ctx = storage.storage[bot][-42][42] ctx.state = "test" ctx.data = {"foo": "bar"} - state = FSMContext(storage=storage, chat_id=-42, user_id=42) - state2 = FSMContext(storage=storage, chat_id=42, user_id=42) - state3 = FSMContext(storage=storage, chat_id=69, user_id=69) + state = FSMContext(bot=bot, storage=storage, chat_id=-42, user_id=42) + state2 = FSMContext(bot=bot, storage=storage, chat_id=42, user_id=42) + state3 = FSMContext(bot=bot, storage=storage, chat_id=69, user_id=69) assert await state.get_state() == "test" assert await state2.get_state() is None diff --git a/tests/test_dispatcher/test_fsm/test_state.py b/tests/test_dispatcher/test_fsm/test_state.py index c89f158f..589db2e5 100644 --- a/tests/test_dispatcher/test_fsm/test_state.py +++ b/tests/test_dispatcher/test_fsm/test_state.py @@ -139,8 +139,7 @@ class TestStatesGroup: assert MyGroup.state1 not in MyGroup.MyNestedGroup assert MyGroup.state1 in MyGroup - # Not working as well - # assert MyGroup.MyNestedGroup in MyGroup + assert MyGroup.MyNestedGroup in MyGroup assert "MyGroup.MyNestedGroup:state1" in MyGroup assert "MyGroup.MyNestedGroup:state1" in MyGroup.MyNestedGroup @@ -157,3 +156,37 @@ class TestStatesGroup: y = State() assert set(Group) == {Group.x, Group.y} + + def test_empty_filter(self): + class MyGroup(StatesGroup): + pass + + assert str(MyGroup()) == "StatesGroup MyGroup" + + def test_with_state_filter(self): + class MyGroup(StatesGroup): + state1 = State() + state2 = State() + + assert MyGroup()(None, "MyGroup:state1") + assert MyGroup()(None, "MyGroup:state2") + assert not MyGroup()(None, "MyGroup:state3") + + assert str(MyGroup()) == "StatesGroup MyGroup" + + def test_nested_group_filter(self): + class MyGroup(StatesGroup): + state1 = State() + + class MyNestedGroup(StatesGroup): + state1 = State() + + assert MyGroup()(None, "MyGroup:state1") + assert MyGroup()(None, "MyGroup.MyNestedGroup:state1") + assert not MyGroup()(None, "MyGroup:state2") + assert MyGroup.MyNestedGroup()(None, "MyGroup.MyNestedGroup:state1") + assert not MyGroup.MyNestedGroup()(None, "MyGroup:state1") + + assert str(MyGroup()) == "StatesGroup MyGroup" + assert str(MyGroup.MyNestedGroup()) == "StatesGroup MyGroup.MyNestedGroup" + diff --git a/tests/test_dispatcher/test_handler/test_base.py b/tests/test_dispatcher/test_handler/test_base.py index 88982907..09a8882d 100644 --- a/tests/test_dispatcher/test_handler/test_base.py +++ b/tests/test_dispatcher/test_handler/test_base.py @@ -10,6 +10,8 @@ from aiogram.dispatcher.event.handler import HandlerObject from aiogram.dispatcher.handler.base import BaseHandler from aiogram.types import Chat, Message, Update +pytestmark = pytest.mark.asyncio + class MyHandler(BaseHandler): async def handle(self) -> Any: @@ -18,7 +20,6 @@ class MyHandler(BaseHandler): class TestBaseClassBasedHandler: - @pytest.mark.asyncio async def test_base_handler(self): event = Update(update_id=42) handler = MyHandler(event=event, key=42) @@ -28,7 +29,6 @@ class TestBaseClassBasedHandler: assert not hasattr(handler, "filters") assert await handler == 42 - @pytest.mark.asyncio async def test_bot_from_context(self): event = Update(update_id=42) handler = MyHandler(event=event, key=42) @@ -40,7 +40,6 @@ class TestBaseClassBasedHandler: Bot.set_current(bot) assert handler.bot == bot - @pytest.mark.asyncio async def test_bot_from_data(self): event = Update(update_id=42) bot = Bot("42:TEST") @@ -59,7 +58,6 @@ class TestBaseClassBasedHandler: assert handler.event == event assert handler.update == update - @pytest.mark.asyncio async def test_wrapped_handler(self): # wrap the handler on dummy function handler = wraps(MyHandler)(lambda: None) diff --git a/tests/test_dispatcher/test_handler/test_callback_query.py b/tests/test_dispatcher/test_handler/test_callback_query.py index e47534f4..c33d9358 100644 --- a/tests/test_dispatcher/test_handler/test_callback_query.py +++ b/tests/test_dispatcher/test_handler/test_callback_query.py @@ -5,9 +5,10 @@ import pytest from aiogram.dispatcher.handler import CallbackQueryHandler from aiogram.types import CallbackQuery, User +pytestmark = pytest.mark.asyncio + class TestCallbackQueryHandler: - @pytest.mark.asyncio async def test_attributes_aliases(self): event = CallbackQuery( id="chosen", diff --git a/tests/test_dispatcher/test_handler/test_chat_member.py b/tests/test_dispatcher/test_handler/test_chat_member.py index baf1ee85..191b9002 100644 --- a/tests/test_dispatcher/test_handler/test_chat_member.py +++ b/tests/test_dispatcher/test_handler/test_chat_member.py @@ -4,22 +4,19 @@ from typing import Any import pytest from aiogram.dispatcher.handler.chat_member import ChatMemberHandler -from aiogram.types import Chat, ChatMember, ChatMemberUpdated, User +from aiogram.types import Chat, ChatMemberMember, ChatMemberUpdated, User + +pytestmark = pytest.mark.asyncio class TestChatMemberUpdated: - @pytest.mark.asyncio async def test_attributes_aliases(self): event = ChatMemberUpdated( chat=Chat(id=42, type="private"), from_user=User(id=42, is_bot=False, first_name="Test"), date=datetime.datetime.now(), - old_chat_member=ChatMember( - user=User(id=42, is_bot=False, first_name="Test"), status="restricted" - ), - new_chat_member=ChatMember( - user=User(id=42, is_bot=False, first_name="Test"), status="restricted" - ), + old_chat_member=ChatMemberMember(user=User(id=42, is_bot=False, first_name="Test")), + new_chat_member=ChatMemberMember(user=User(id=42, is_bot=False, first_name="Test")), ) class MyHandler(ChatMemberHandler): diff --git a/tests/test_dispatcher/test_handler/test_chosen_inline_result.py b/tests/test_dispatcher/test_handler/test_chosen_inline_result.py index 2e1f4045..5c06aeec 100644 --- a/tests/test_dispatcher/test_handler/test_chosen_inline_result.py +++ b/tests/test_dispatcher/test_handler/test_chosen_inline_result.py @@ -3,11 +3,12 @@ from typing import Any import pytest from aiogram.dispatcher.handler import ChosenInlineResultHandler -from aiogram.types import CallbackQuery, ChosenInlineResult, User +from aiogram.types import ChosenInlineResult, User + +pytestmark = pytest.mark.asyncio class TestChosenInlineResultHandler: - @pytest.mark.asyncio async def test_attributes_aliases(self): event = ChosenInlineResult( result_id="chosen", diff --git a/tests/test_dispatcher/test_handler/test_error.py b/tests/test_dispatcher/test_handler/test_error.py index f6e6b090..58b4010a 100644 --- a/tests/test_dispatcher/test_handler/test_error.py +++ b/tests/test_dispatcher/test_handler/test_error.py @@ -2,20 +2,12 @@ from typing import Any import pytest -from aiogram.dispatcher.handler import ErrorHandler, PollHandler -from aiogram.types import ( - CallbackQuery, - InlineQuery, - Poll, - PollOption, - ShippingAddress, - ShippingQuery, - User, -) +from aiogram.dispatcher.handler import ErrorHandler + +pytestmark = pytest.mark.asyncio class TestErrorHandler: - @pytest.mark.asyncio async def test_extensions(self): event = KeyError("kaboom") diff --git a/tests/test_dispatcher/test_handler/test_inline_query.py b/tests/test_dispatcher/test_handler/test_inline_query.py index 100fccdd..99fe65e7 100644 --- a/tests/test_dispatcher/test_handler/test_inline_query.py +++ b/tests/test_dispatcher/test_handler/test_inline_query.py @@ -3,11 +3,12 @@ from typing import Any import pytest from aiogram.dispatcher.handler import InlineQueryHandler -from aiogram.types import CallbackQuery, InlineQuery, User +from aiogram.types import InlineQuery, User + +pytestmark = pytest.mark.asyncio class TestCallbackQueryHandler: - @pytest.mark.asyncio async def test_attributes_aliases(self): event = InlineQuery( id="query", diff --git a/tests/test_dispatcher/test_handler/test_message.py b/tests/test_dispatcher/test_handler/test_message.py index 5f95d2bd..643a5f51 100644 --- a/tests/test_dispatcher/test_handler/test_message.py +++ b/tests/test_dispatcher/test_handler/test_message.py @@ -7,6 +7,8 @@ from aiogram.dispatcher.filters import CommandObject from aiogram.dispatcher.handler.message import MessageHandler, MessageHandlerCommandMixin from aiogram.types import Chat, Message, User +pytestmark = pytest.mark.asyncio + class MyHandler(MessageHandler): async def handle(self) -> Any: @@ -14,7 +16,6 @@ class MyHandler(MessageHandler): class TestClassBasedMessageHandler: - @pytest.mark.asyncio async def test_message_handler(self): event = Message( message_id=42, diff --git a/tests/test_dispatcher/test_handler/test_poll.py b/tests/test_dispatcher/test_handler/test_poll.py index 172012d6..aa82bf78 100644 --- a/tests/test_dispatcher/test_handler/test_poll.py +++ b/tests/test_dispatcher/test_handler/test_poll.py @@ -3,19 +3,12 @@ from typing import Any import pytest from aiogram.dispatcher.handler import PollHandler -from aiogram.types import ( - CallbackQuery, - InlineQuery, - Poll, - PollOption, - ShippingAddress, - ShippingQuery, - User, -) +from aiogram.types import Poll, PollOption + +pytestmark = pytest.mark.asyncio class TestShippingQueryHandler: - @pytest.mark.asyncio async def test_attributes_aliases(self): event = Poll( id="query", diff --git a/tests/test_dispatcher/test_handler/test_pre_checkout_query.py b/tests/test_dispatcher/test_handler/test_pre_checkout_query.py index 828bd57d..76de7d55 100644 --- a/tests/test_dispatcher/test_handler/test_pre_checkout_query.py +++ b/tests/test_dispatcher/test_handler/test_pre_checkout_query.py @@ -5,9 +5,10 @@ import pytest from aiogram.dispatcher.handler import PreCheckoutQueryHandler from aiogram.types import PreCheckoutQuery, User +pytestmark = pytest.mark.asyncio + class TestPreCheckoutQueryHandler: - @pytest.mark.asyncio async def test_attributes_aliases(self): event = PreCheckoutQuery( id="query", diff --git a/tests/test_dispatcher/test_handler/test_shipping_query.py b/tests/test_dispatcher/test_handler/test_shipping_query.py index 0d5aa578..831a773a 100644 --- a/tests/test_dispatcher/test_handler/test_shipping_query.py +++ b/tests/test_dispatcher/test_handler/test_shipping_query.py @@ -3,11 +3,12 @@ from typing import Any import pytest from aiogram.dispatcher.handler import ShippingQueryHandler -from aiogram.types import CallbackQuery, InlineQuery, ShippingAddress, ShippingQuery, User +from aiogram.types import ShippingAddress, ShippingQuery, User + +pytestmark = pytest.mark.asyncio class TestShippingQueryHandler: - @pytest.mark.asyncio async def test_attributes_aliases(self): event = ShippingQuery( id="query", diff --git a/tests/test_dispatcher/test_router.py b/tests/test_dispatcher/test_router.py index c84239b1..980e5f34 100644 --- a/tests/test_dispatcher/test_router.py +++ b/tests/test_dispatcher/test_router.py @@ -1,9 +1,10 @@ import pytest -from aiogram.dispatcher.event.bases import SkipHandler, skip +from aiogram.dispatcher.event.bases import UNHANDLED, SkipHandler, skip from aiogram.dispatcher.router import Router from aiogram.utils.warnings import CodeHasNoEffect +pytestmark = pytest.mark.asyncio importable_router = Router() @@ -73,7 +74,6 @@ class TestRouter: assert router.observers["pre_checkout_query"] == router.pre_checkout_query assert router.observers["poll"] == router.poll - @pytest.mark.asyncio async def test_emit_startup(self): router1 = Router() router2 = Router() @@ -95,7 +95,6 @@ class TestRouter: await router1.emit_startup() assert results == [2, 1, 2] - @pytest.mark.asyncio async def test_emit_shutdown(self): router1 = Router() router2 = Router() @@ -122,3 +121,16 @@ class TestRouter: skip() with pytest.raises(SkipHandler, match="KABOOM"): skip("KABOOM") + + async def test_global_filter_in_nested_router(self): + r1 = Router() + r2 = Router() + + async def handler(evt): + return evt + + r1.include_router(r2) + r1.message.filter(lambda evt: False) + r2.message.register(handler) + + assert await r1.propagate_event(update_type="message", event=None) is UNHANDLED diff --git a/tests/test_utils/test_auth_widget.py b/tests/test_utils/test_auth_widget.py new file mode 100644 index 00000000..a6071760 --- /dev/null +++ b/tests/test_utils/test_auth_widget.py @@ -0,0 +1,27 @@ +import pytest + +from aiogram.utils.auth_widget import check_integrity + +TOKEN = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" + + +@pytest.fixture +def data(): + return { + "id": "42", + "first_name": "John", + "last_name": "Smith", + "username": "username", + "photo_url": "https://t.me/i/userpic/320/picname.jpg", + "auth_date": "1565810688", + "hash": "c303db2b5a06fe41d23a9b14f7c545cfc11dcc7473c07c9c5034ae60062461ce", + } + + +class TestCheckIntegrity: + def test_ok(self, data): + assert check_integrity(TOKEN, data) is True + + def test_fail(self, data): + data.pop("username") + assert check_integrity(TOKEN, data) is False diff --git a/tests/test_utils/test_backoff.py b/tests/test_utils/test_backoff.py new file mode 100644 index 00000000..b409e22a --- /dev/null +++ b/tests/test_utils/test_backoff.py @@ -0,0 +1,76 @@ +import pytest + +from aiogram.utils.backoff import Backoff, BackoffConfig + +BACKOFF_CONFIG = BackoffConfig(min_delay=0.1, max_delay=1.0, factor=2.0, jitter=0.0) +pytestmark = pytest.mark.asyncio + + +class TestBackoffConfig: + @pytest.mark.parametrize( + "kwargs", + [ + dict(min_delay=1.0, max_delay=1.0, factor=2.0, jitter=0.1), # equals min and max + dict(min_delay=1.0, max_delay=1.0, factor=1.0, jitter=0.1), # factor == 1 + dict(min_delay=1.0, max_delay=2.0, factor=0.5, jitter=0.1), # factor < 1 + dict(min_delay=2.0, max_delay=1.0, factor=2.0, jitter=0.1), # min > max + ], + ) + def test_incorrect_post_init(self, kwargs): + with pytest.raises(ValueError): + BackoffConfig(**kwargs) + + @pytest.mark.parametrize( + "kwargs", + [dict(min_delay=1.0, max_delay=2.0, factor=1.2, jitter=0.1)], + ) + def test_correct_post_init(self, kwargs): + assert BackoffConfig(**kwargs) + + +class TestBackoff: + def test_aliases(self): + backoff = Backoff(config=BACKOFF_CONFIG) + assert backoff.min_delay == BACKOFF_CONFIG.min_delay + assert backoff.max_delay == BACKOFF_CONFIG.max_delay + assert backoff.factor == BACKOFF_CONFIG.factor + assert backoff.jitter == BACKOFF_CONFIG.jitter + + def test_calculation(self): + backoff = Backoff(config=BACKOFF_CONFIG) + index = 0 + + iterable = iter(backoff) + assert iterable == backoff + + assert backoff.current_delay == 0.0 + assert backoff.next_delay == 0.1 + + while (val := next(backoff)) < 1: + index += 1 + assert val in {0.1, 0.2, 0.4, 0.8} + + assert next(backoff) == 1 + assert next(backoff) == 1 + assert index == 4 + + assert backoff.current_delay == 1 + assert backoff.next_delay == 1 + assert backoff.counter == 7 # 4+1 in while loop + 2 after loop + + assert str(backoff) == "Backoff(tryings=7, current_delay=1.0, next_delay=1.0)" + + backoff.reset() + assert backoff.current_delay == 0.0 + assert backoff.next_delay == 0.1 + assert backoff.counter == 0 + + def test_sleep(self): + backoff = Backoff(config=BACKOFF_CONFIG) + backoff.sleep() + assert backoff.counter == 1 + + async def test_asleep(self): + backoff = Backoff(config=BACKOFF_CONFIG) + await backoff.asleep() + assert backoff.counter == 1 diff --git a/tests/test_utils/test_deep_linking.py b/tests/test_utils/test_deep_linking.py new file mode 100644 index 00000000..830c8149 --- /dev/null +++ b/tests/test_utils/test_deep_linking.py @@ -0,0 +1,95 @@ +import pytest +from async_lru import alru_cache + +from aiogram.utils.deep_linking import ( + create_start_link, + create_startgroup_link, + decode_payload, + encode_payload, +) +from tests.mocked_bot import MockedBot + +PAYLOADS = [ + "foo", + "AAbbCCddEEff1122334455", + "aaBBccDDeeFF5544332211", + -12345678901234567890, + 12345678901234567890, +] +WRONG_PAYLOADS = [ + "@BotFather", + "Some:special$characters#=", + "spaces spaces spaces", + 1234567890123456789.0, +] + +pytestmark = pytest.mark.asyncio + + +@pytest.fixture(params=PAYLOADS, name="payload") +def payload_fixture(request): + return request.param + + +@pytest.fixture(params=WRONG_PAYLOADS, name="wrong_payload") +def wrong_payload_fixture(request): + return request.param + + +@pytest.fixture(autouse=True) +def get_bot_user_fixture(monkeypatch): + """Monkey patching of bot.me calling.""" + + @alru_cache() + async def get_bot_user_mock(self): + from aiogram.types import User + + return User( + id=12345678, + is_bot=True, + first_name="FirstName", + last_name="LastName", + username="username", + language_code="uk-UA", + ) + + monkeypatch.setattr(MockedBot, "me", get_bot_user_mock) + + +class TestDeepLinking: + async def test_get_start_link(self, bot, payload): + link = await create_start_link(bot=bot, payload=payload) + assert link == f"https://t.me/username?start={payload}" + + async def test_wrong_symbols(self, bot, wrong_payload): + with pytest.raises(ValueError): + await create_start_link(bot, wrong_payload) + + async def test_get_startgroup_link(self, bot, payload): + link = await create_startgroup_link(bot, payload) + assert link == f"https://t.me/username?startgroup={payload}" + + async def test_filter_encode_and_decode(self, payload): + encoded = encode_payload(payload) + decoded = decode_payload(encoded) + assert decoded == str(payload) + + async def test_get_start_link_with_encoding(self, bot, wrong_payload): + # define link + link = await create_start_link(bot, wrong_payload, encode=True) + + # define reference link + encoded_payload = encode_payload(wrong_payload) + + assert link == f"https://t.me/username?start={encoded_payload}" + + async def test_64_len_payload(self, bot): + payload = "p" * 64 + link = await create_start_link(bot, payload) + assert link + + async def test_too_long_payload(self, bot): + payload = "p" * 65 + print(payload, len(payload)) + with pytest.raises(ValueError): + await create_start_link(bot, payload) diff --git a/tests/test_utils/test_link.py b/tests/test_utils/test_link.py new file mode 100644 index 00000000..4dbfe8a2 --- /dev/null +++ b/tests/test_utils/test_link.py @@ -0,0 +1,24 @@ +from typing import Any, Dict + +import pytest + +from aiogram.utils.link import create_telegram_link, create_tg_link + + +class TestLink: + @pytest.mark.parametrize( + "base,params,result", + [["user", dict(id=42), "tg://user?id=42"]], + ) + def test_create_tg_link(self, base: str, params: Dict[str, Any], result: str): + assert create_tg_link(base, **params) == result + + @pytest.mark.parametrize( + "base,params,result", + [ + ["username", dict(), "https://t.me/username"], + ["username", dict(start="test"), "https://t.me/username?start=test"], + ], + ) + def test_create_telegram_link(self, base: str, params: Dict[str, Any], result: str): + assert create_telegram_link(base, **params) == result diff --git a/tests/test_utils/test_markdown.py b/tests/test_utils/test_markdown.py index 12e44ccf..815b1c5d 100644 --- a/tests/test_utils/test_markdown.py +++ b/tests/test_utils/test_markdown.py @@ -35,7 +35,7 @@ class TestMarkdown: [hitalic, ("test", "test"), " ", "test test"], [code, ("test", "test"), " ", "`test test`"], [hcode, ("test", "test"), " ", "test test"], - [pre, ("test", "test"), " ", "```test test```"], + [pre, ("test", "test"), " ", "```\ntest test\n```"], [hpre, ("test", "test"), " ", "
test test
"], [underline, ("test", "test"), " ", "__\rtest test__\r"], [hunderline, ("test", "test"), " ", "test test"], diff --git a/tests/test_utils/test_text_decorations.py b/tests/test_utils/test_text_decorations.py index 6cb5105d..da171575 100644 --- a/tests/test_utils/test_text_decorations.py +++ b/tests/test_utils/test_text_decorations.py @@ -55,7 +55,7 @@ class TestTextDecoration: [markdown_decoration, MessageEntity(type="bold", offset=0, length=5), "*test*"], [markdown_decoration, MessageEntity(type="italic", offset=0, length=5), "_\rtest_\r"], [markdown_decoration, MessageEntity(type="code", offset=0, length=5), "`test`"], - [markdown_decoration, MessageEntity(type="pre", offset=0, length=5), "```test```"], + [markdown_decoration, MessageEntity(type="pre", offset=0, length=5), "```\ntest\n```"], [ markdown_decoration, MessageEntity(type="pre", offset=0, length=5, language="python"),