Migrate to hatchling (#1095)

* Migrate to hatchling instead of poetry, ruff instead of flake8

* Migrate to hatchling instead of poetry, ruff instead of flake8

* Update tests suite

* venv?

* -m venv?

* Change dependencies

* Remove venv

* Change mypy config

* Added changelog

* Mark uvloop incompatible with pypy

* Update release script

* Use internal caching for dependencies

* Re-disable cov branches

* Added contributing guide
This commit is contained in:
Alex Root Junior 2023-01-12 02:49:58 +02:00 committed by GitHub
parent 04ccb390d5
commit f4ce4431f9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 799 additions and 3001 deletions

View file

@ -1,6 +0,0 @@
[report]
exclude_lines =
pragma: no cover
if TYPE_CHECKING:
@abstractmethod
@overload

12
.flake8
View file

@ -1,12 +0,0 @@
[flake8]
max-line-length = 99
select = C,E,F,W,B,B950
ignore = E501,W503,E203
exclude =
.git
build
dist
venv
docs
*.egg-info
experiment.py

View file

@ -1,4 +1,4 @@
name: Deploy name: "Deploy"
on: on:
push: push:
@ -17,16 +17,12 @@ jobs:
with: with:
python-version: "3.10" python-version: "3.10"
- name: Install and configure Poetry - name: Install build dependencies
uses: snok/install-poetry@v1 run: python -m pip install --upgrade build
with:
version: 1.2.1 - name: Build source distribution
virtualenvs-create: false run: python -m build .
installer-parallel: true
- name: Build
run: |
poetry build
- name: Try install wheel - name: Try install wheel
run: | run: |
pip install -U virtualenv pip install -U virtualenv
@ -53,17 +49,17 @@ jobs:
name: dist name: dist
path: dist path: dist
# - name: Publish a Python distribution to Test PyPI # - name: Publish a Python distribution to Test PyPI
# uses: pypa/gh-action-pypi-publish@master # uses: pypa/gh-action-pypi-publish@master
## if: github.event.action != 'published' ## if: github.event.action != 'published'
# with: # with:
# user: __token__ # user: __token__
# password: ${{ secrets.PYPI_TEST_TOKEN }} # password: ${{ secrets.PYPI_TEST_TOKEN }}
# repository_url: https://test.pypi.org/legacy/ # repository_url: https://test.pypi.org/legacy/
- name: Publish a Python distribution to PyPI - name: Publish a Python distribution to PyPI
uses: pypa/gh-action-pypi-publish@master uses: pypa/gh-action-pypi-publish@master
# if: github.event.action == 'published' # if: github.event.action == 'published'
with: with:
user: __token__ user: __token__
password: ${{ secrets.PYPI_TOKEN }} password: ${{ secrets.PYPI_TOKEN }}

View file

@ -8,11 +8,7 @@ on:
- ".github/workflows/tests.yml" - ".github/workflows/tests.yml"
- "aiogram/**" - "aiogram/**"
- "tests/**" - "tests/**"
- ".coveragerc"
- ".flake8"
- "codecov.yaml" - "codecov.yaml"
- "mypy.ini"
- "poetry.lock"
- "pyproject.toml" - "pyproject.toml"
pull_request: pull_request:
branches: branches:
@ -21,11 +17,6 @@ on:
- ".github/workflows/tests.yml" - ".github/workflows/tests.yml"
- "aiogram/**" - "aiogram/**"
- "tests/**" - "tests/**"
- ".coveragerc"
- ".flake8"
- "codecov.yaml"
- "mypy.ini"
- "poetry.lock"
- "pyproject.toml" - "pyproject.toml"
jobs: jobs:
@ -67,30 +58,25 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@master uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }}
uses: actions/setup-python@v4 uses: actions/setup-python@v4
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
cache: "pip"
cache-dependency-path: pyproject.toml
- name: Install and configure Poetry - name: Install project dependencies
uses: snok/install-poetry@v1
if: "env.IS_PYPY == 'false' || env.IS_WINDOWS == 'false'"
with:
version: 1.2.1
virtualenvs-create: true
virtualenvs-in-project: true
installer-parallel: true
- name: Install and configure Poetry (PyPy on Windows)
if: "env.IS_PYPY == 'true' && env.IS_WINDOWS == 'true'"
run: | run: |
set -eu pip install -e .[dev,test,redis,proxy,i18n,fast]
pip install "poetry==1.2.1"
poetry config virtualenvs.create true - name: Lint code
poetry config virtualenvs.in-project true if: "env.IS_PYPY == 'false'"
poetry config installer.parallel true run: |
ruff --format=github aiogram examples
mypy aiogram
black --check --diff aiogram tests
- name: Setup redis - name: Setup redis
if: ${{ env.IS_WINDOWS == 'false' }} if: ${{ env.IS_WINDOWS == 'false' }}
@ -98,33 +84,12 @@ jobs:
with: with:
redis-version: 6 redis-version: 6
- name: Load cached venv
id: cached-poetry-dependencies
uses: actions/cache@v2
with:
path: .venv
key: venv-${{ matrix.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}-${{ secrets.CACHE_VERSION }}
- name: Install project dependencies
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
run: |
flags=""
[[ "$IS_PYPY" == "false" ]] && flags="$flags -E fast"
poetry install --no-interaction -E redis -E proxy -E i18n $flags
- name: Lint code
if: "env.IS_PYPY == 'false'"
run: |
poetry run flake8 aiogram
poetry run mypy aiogram
poetry run black --check --diff aiogram tests
- name: Run tests - name: Run tests
run: | run: |
flags="" flags=""
[[ "$IS_PYPY" == "false" ]] && flags="$flags --cov=aiogram --cov-config .coveragerc --cov-report=xml" [[ "$IS_PYPY" == "false" ]] && flags="$flags --cov=aiogram --cov-config .coveragerc --cov-report=xml"
[[ "$IS_WINDOWS" == "false" ]] && flags="$flags --redis redis://localhost:6379/0" [[ "$IS_WINDOWS" == "false" ]] && flags="$flags --redis redis://localhost:6379/0"
poetry run pytest $flags pytest $flags
- name: Upload coverage data - name: Upload coverage data
if: "env.IS_PYPY == 'false'" if: "env.IS_PYPY == 'false'"

2
.gitignore vendored
View file

@ -13,7 +13,7 @@ dist/
site/ site/
*.egg-info/ *.egg-info/
*.egg *.egg
aiogram/_meta.py .ruff_cache
.mypy_cache .mypy_cache
.pytest_cache .pytest_cache

View file

@ -19,34 +19,8 @@ repos:
- id: black - id: black
files: &files '^(aiogram|tests|examples)' files: &files '^(aiogram|tests|examples)'
- repo: https://github.com/pre-commit/mirrors-isort - repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v5.10.1 rev: 'v0.0.215'
hooks: hooks:
- id: isort - id: ruff
additional_dependencies: [ toml ] args: [ "--force-exclude" ]
files: *files
- repo: https://gitlab.com/pycqa/flake8
rev: 3.9.2
hooks:
- id: flake8
args: [ '--config=.flake8' ]
files: *files
- repo: https://github.com/floatingpurr/sync_with_poetry
rev: 0.2.0
hooks:
- id: sync_with_poetry
- repo: https://github.com/python-poetry/poetry
rev: '1.2.1'
hooks:
- id: poetry-check
- id: poetry-lock
args: [ "--no-update" ]
- id: poetry-export
args: [ "-f", "requirements.txt", "--without-hashes", "-o", "requirements/base.txt" ]
- id: poetry-export
args: [ "-f", "requirements.txt", "--without-hashes", "-o", "requirements/docs.txt",
"-E", "fast", "-E", "redis", "-E", "proxy", "-E", "i18n",
"--with", "docs" ]

View file

@ -1,11 +1,13 @@
version: 2 version: 2
python: python:
version: "3.8" version: "3.10"
install: install:
- method: pip - method: pip
path: . path: .
- requirements: requirements/docs.txt extra_requirements:
- docs
- redis
sphinx: sphinx:
configuration: docs/conf.py configuration: docs/conf.py

1
CHANGES/1095.misc.rst Normal file
View file

@ -0,0 +1 @@
Updated package metadata, moved build internals from Poetry to Hatch, added contributing guides.

1
CONTRIBUTING.md Normal file
View file

@ -0,0 +1 @@
Please read the [Contributing](https://docs.aiogram.dev/en/dev-3.x/contributing/) guidelines in the documentation site.

View file

@ -1,8 +1,4 @@
.DEFAULT_GOAL := help .DEFAULT_GOAL := lint
base_python := python3
py := poetry run
python := $(py) python
package_dir := aiogram package_dir := aiogram
tests_dir := tests tests_dir := tests
@ -13,43 +9,10 @@ reports_dir := reports
redis_connection := redis://localhost:6379 redis_connection := redis://localhost:6379
.PHONY: help
help:
@echo "======================================================================================="
@echo " aiogram build tools "
@echo "======================================================================================="
@echo "Environment:"
@echo " help: Show this message"
@echo " install: Install development dependencies"
@echo " clean: Delete temporary files"
@echo ""
@echo "Code quality:"
@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"
@echo " test-coverage: Run tests with HTML reporting (results + coverage)"
@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-prepare-reports: Move all HTML reports to docs dir"
@echo ""
@echo "Project"
@echo " build: Run tests build package and docs"
@echo ""
# ================================================================================================= # =================================================================================================
# Environment # Environment
# ================================================================================================= # =================================================================================================
.PHONY: install
install:
poetry install --all-extras
$(py) pre-commit install
.PHONY: clean .PHONY: clean
clean: clean:
rm -rf `find . -name __pycache__` rm -rf `find . -name __pycache__`
@ -68,16 +31,15 @@ clean:
.PHONY: lint .PHONY: lint
lint: lint:
$(py) isort --check-only $(code_dir) isort --check-only $(code_dir)
$(py) black --check --diff $(code_dir) black --check --diff $(code_dir)
$(py) flake8 $(code_dir) ruff $(package_dir)
$(py) mypy $(package_dir) mypy $(package_dir)
# TODO: wemake-python-styleguide
.PHONY: reformat .PHONY: reformat
reformat: reformat:
$(py) black $(code_dir) black $(code_dir)
$(py) isort $(code_dir) isort $(code_dir)
# ================================================================================================= # =================================================================================================
# Tests # Tests
@ -88,17 +50,17 @@ test-run-services:
.PHONY: test .PHONY: test
test: test-run-services test: test-run-services
$(py) pytest --cov=aiogram --cov-config .coveragerc tests/ --redis $(redis_connection) pytest --cov=aiogram --cov-config .coveragerc tests/ --redis $(redis_connection)
.PHONY: test-coverage .PHONY: test-coverage
test-coverage: test-run-services test-coverage: test-run-services
mkdir -p $(reports_dir)/tests/ mkdir -p $(reports_dir)/tests/
$(py) pytest --cov=aiogram --cov-config .coveragerc --html=$(reports_dir)/tests/index.html tests/ --redis $(redis_connection) pytest --cov=aiogram --cov-config .coveragerc --html=$(reports_dir)/tests/index.html tests/ --redis $(redis_connection)
$(py) coverage html -d $(reports_dir)/coverage coverage html -d $(reports_dir)/coverage
.PHONY: test-coverage-view .PHONY: test-coverage-view
test-coverage-view: test-coverage-view:
$(py) coverage html -d $(reports_dir)/coverage coverage html -d $(reports_dir)/coverage
python -c "import webbrowser; webbrowser.open('file://$(shell pwd)/reports/coverage/index.html')" python -c "import webbrowser; webbrowser.open('file://$(shell pwd)/reports/coverage/index.html')"
# ================================================================================================= # =================================================================================================
@ -117,7 +79,7 @@ docs-gettext:
docs-serve: docs-serve:
#rm -rf docs/_build #rm -rf docs/_build
$(py) sphinx-autobuild --watch aiogram/ --watch CHANGELOG.rst --watch README.rst docs/ docs/_build/ $(OPTS) sphinx-autobuild --watch aiogram/ --watch CHANGELOG.rst --watch README.rst docs/ docs/_build/ $(OPTS)
.PHONY: docs-serve .PHONY: docs-serve
$(locale_targets): docs-serve-%: $(locale_targets): docs-serve-%:
@ -129,15 +91,13 @@ $(locale_targets): docs-serve-%:
# ================================================================================================= # =================================================================================================
.PHONY: build .PHONY: build
build: clean flake8-report mypy-report test-coverage build: clean
mkdir -p site/simple hatch build
poetry build
mv dist site/simple/aiogram
.PHONY: bump .PHONY: bump
bump: bump:
poetry version $(args) hatch version $(args)
$(python) scripts/bump_versions.py python scripts/bump_versions.py
.PHONY: towncrier-build .PHONY: towncrier-build
towncrier-build: towncrier-build:
@ -160,10 +120,3 @@ release:
git add . git add .
git commit -m "Release $(shell poetry version -s)" git commit -m "Release $(shell poetry version -s)"
git tag v$(shell poetry version -s) git tag v$(shell poetry version -s)
_poetry_export_args := --format requirements.txt --without-hashes
.PHONY: export-requirements
export-requirements:
poetry export $(_poetry_export_args) --output requirements/base.txt
poetry export $(_poetry_export_args) --output requirements/docs.txt -E fast -E redis -E proxy -E i18n --with docs

View file

@ -1,5 +1,9 @@
from aiogram.dispatcher.flags import FlagGenerator from contextlib import suppress
from aiogram.dispatcher.flags import FlagGenerator
from . import methods
from . import types
from . import enums
from .client import session from .client import session
from .client.bot import Bot from .client.bot import Bot
from .dispatcher.dispatcher import Dispatcher from .dispatcher.dispatcher import Dispatcher
@ -9,12 +13,10 @@ from .utils.magic_filter import MagicFilter
from .utils.text_decorations import html_decoration as html from .utils.text_decorations import html_decoration as html
from .utils.text_decorations import markdown_decoration as md from .utils.text_decorations import markdown_decoration as md
try: with suppress(ImportError):
import uvloop as _uvloop import uvloop as _uvloop
_uvloop.install() _uvloop.install()
except ImportError: # pragma: no cover
pass
F = MagicFilter() F = MagicFilter()
flags = FlagGenerator() flags = FlagGenerator()
@ -24,6 +26,7 @@ __all__ = (
"__version__", "__version__",
"types", "types",
"methods", "methods",
"enums",
"Bot", "Bot",
"session", "session",
"Dispatcher", "Dispatcher",

View file

@ -310,10 +310,9 @@ class Bot(ContextInstanceMixin["Bot"]):
if isinstance(destination, (str, pathlib.Path)): if isinstance(destination, (str, pathlib.Path)):
await self.__download_file(destination=destination, stream=stream) await self.__download_file(destination=destination, stream=stream)
return None return None
else: return await self.__download_file_binary_io(
return await self.__download_file_binary_io( destination=destination, seek=seek, stream=stream
destination=destination, seek=seek, stream=stream )
)
finally: finally:
if close_stream: if close_stream:
await stream.aclose() await stream.aclose()

View file

@ -48,18 +48,22 @@ def _retrieve_basic(basic: _ProxyBasic) -> Dict[str, Any]:
username = proxy_auth.login username = proxy_auth.login
password = proxy_auth.password password = proxy_auth.password
return dict( return {
proxy_type=proxy_type, "proxy_type": proxy_type,
host=host, "host": host,
port=port, "port": port,
username=username, "username": username,
password=password, "password": password,
rdns=True, "rdns": True,
) }
def _prepare_connector(chain_or_plain: _ProxyType) -> Tuple[Type["TCPConnector"], Dict[str, Any]]: def _prepare_connector(chain_or_plain: _ProxyType) -> Tuple[Type["TCPConnector"], Dict[str, Any]]:
from aiohttp_socks import ChainProxyConnector, ProxyConnector, ProxyInfo # type: ignore from aiohttp_socks import ( # type: ignore
ChainProxyConnector,
ProxyConnector,
ProxyInfo,
)
# since tuple is Iterable(compatible with _ProxyChain) object, we assume that # since tuple is Iterable(compatible with _ProxyChain) object, we assume that
# user wants chained proxies if tuple is a pair of string(url) and BasicAuth # user wants chained proxies if tuple is a pair of string(url) and BasicAuth
@ -74,7 +78,7 @@ def _prepare_connector(chain_or_plain: _ProxyType) -> Tuple[Type["TCPConnector"]
for basic in chain_or_plain: for basic in chain_or_plain:
infos.append(ProxyInfo(**_retrieve_basic(basic))) infos.append(ProxyInfo(**_retrieve_basic(basic)))
return ChainProxyConnector, dict(proxy_infos=infos) return ChainProxyConnector, {"proxy_infos": infos}
class AiohttpSession(BaseSession): class AiohttpSession(BaseSession):

View file

@ -6,7 +6,17 @@ import json
from enum import Enum from enum import Enum
from http import HTTPStatus from http import HTTPStatus
from types import TracebackType from types import TracebackType
from typing import TYPE_CHECKING, Any, AsyncGenerator, Callable, Final, Optional, Type, Union, cast from typing import (
TYPE_CHECKING,
Any,
AsyncGenerator,
Callable,
Final,
Optional,
Type,
Union,
cast,
)
from pydantic import ValidationError from pydantic import ValidationError
@ -165,8 +175,7 @@ class BaseSession(abc.ABC):
return str(round(value.timestamp())) return str(round(value.timestamp()))
if isinstance(value, Enum): if isinstance(value, Enum):
return self.prepare_value(value.value) return self.prepare_value(value.value)
else: return str(value)
return str(value)
def clean_json(self, value: Any) -> Any: def clean_json(self, value: Any) -> Any:
""" """
@ -174,7 +183,7 @@ class BaseSession(abc.ABC):
""" """
if isinstance(value, list): if isinstance(value, list):
return [self.clean_json(v) for v in value if v is not None] return [self.clean_json(v) for v in value if v is not None]
elif isinstance(value, dict): if isinstance(value, dict):
return {k: self.clean_json(v) for k, v in value.items() if v is not None} return {k: self.clean_json(v) for k, v in value.items() if v is not None}
return value return value

View file

@ -52,7 +52,8 @@ class TelegramAPIServer:
file: str file: str
"""Files URL""" """Files URL"""
is_local: bool = False is_local: bool = False
"""Mark this server is in `local mode <https://core.telegram.org/bots/api#using-a-local-bot-api-server>`_.""" """Mark this server is
in `local mode <https://core.telegram.org/bots/api#using-a-local-bot-api-server>`_."""
wrap_local_file: FilesPathWrapper = BareFilesPathWrapper() wrap_local_file: FilesPathWrapper = BareFilesPathWrapper()
"""Callback to wrap files path in local mode""" """Callback to wrap files path in local mode"""

View file

@ -107,13 +107,13 @@ class Dispatcher(Router):
return self.fsm.storage return self.fsm.storage
@property @property
def parent_router(self) -> None: def parent_router(self) -> Optional[Router]:
""" """
Dispatcher has no parent router and can't be included to any other routers or dispatchers Dispatcher has no parent router and can't be included to any other routers or dispatchers
:return: :return:
""" """
return None return None # noqa: RET501
@parent_router.setter @parent_router.setter
def parent_router(self, value: Router) -> None: def parent_router(self, value: Router) -> None:

View file

@ -9,7 +9,8 @@ class EventObserver:
""" """
Simple events observer Simple events observer
Is used for managing events is not related with Telegram (For example startup/shutdown processes) Is used for managing events is not related with Telegram
(For example startup/shutdown processes)
Handlers can be registered via decorator or method Handlers can be registered via decorator or method

View file

@ -94,7 +94,7 @@ def extract_flags(handler: Union["HandlerObject", Dict[str, Any]]) -> Dict[str,
handler = handler["handler"] handler = handler["handler"]
if not hasattr(handler, "flags"): if not hasattr(handler, "flags"):
return {} return {}
return handler.flags # type: ignore return handler.flags
def get_flag( def get_flag(

View file

@ -1,7 +1,11 @@
import functools import functools
from typing import Any, Callable, Dict, List, Optional, Sequence, Union, overload from typing import Any, Callable, Dict, List, Optional, Sequence, Union, overload
from aiogram.dispatcher.event.bases import MiddlewareEventType, MiddlewareType, NextMiddlewareType from aiogram.dispatcher.event.bases import (
MiddlewareEventType,
MiddlewareType,
NextMiddlewareType,
)
from aiogram.dispatcher.event.handler import CallbackType from aiogram.dispatcher.event.handler import CallbackType
from aiogram.types import TelegramObject from aiogram.types import TelegramObject

View file

@ -101,7 +101,7 @@ class Router:
if observer.handlers and update_name not in skip_events: if observer.handlers and update_name not in skip_events:
handlers_in_use.add(update_name) handlers_in_use.add(update_name)
return list(sorted(handlers_in_use)) return list(sorted(handlers_in_use)) # NOQA: C413
async def propagate_event(self, update_type: str, event: TelegramObject, **kwargs: Any) -> Any: async def propagate_event(self, update_type: str, event: TelegramObject, **kwargs: Any) -> Any:
kwargs.update(event_router=self) kwargs.update(event_router=self)

View file

@ -120,6 +120,7 @@ class ClientDecodeError(AiogramError):
original_type = type(self.original) original_type = type(self.original)
return ( return (
f"{self.message}\n" f"{self.message}\n"
f"Caused from error: {original_type.__module__}.{original_type.__name__}: {self.original}\n" f"Caused from error: "
f"{original_type.__module__}.{original_type.__name__}: {self.original}\n"
f"Content: {self.data}" f"Content: {self.data}"
) )

View file

@ -37,7 +37,8 @@ class Filter(ABC):
def update_handler_flags(self, flags: Dict[str, Any]) -> None: def update_handler_flags(self, flags: Dict[str, Any]) -> None:
""" """
Also if you want to extend handler flags with using this filter you should implement this method Also if you want to extend handler flags with using this filter
you should implement this method
:param flags: existing flags, can be updated directly :param flags: existing flags, can be updated directly
""" """

View file

@ -3,7 +3,17 @@ from __future__ import annotations
from decimal import Decimal from decimal import Decimal
from enum import Enum from enum import Enum
from fractions import Fraction from fractions import Fraction
from typing import TYPE_CHECKING, Any, ClassVar, Dict, Literal, Optional, Type, TypeVar, Union from typing import (
TYPE_CHECKING,
Any,
ClassVar,
Dict,
Literal,
Optional,
Type,
TypeVar,
Union,
)
from uuid import UUID from uuid import UUID
from magic_filter import MagicFilter from magic_filter import MagicFilter

View file

@ -22,7 +22,7 @@ class _MemberStatusMarker:
result = self.name.upper() result = self.name.upper()
if self.is_member is not None: if self.is_member is not None:
result = ("+" if self.is_member else "-") + result result = ("+" if self.is_member else "-") + result
return result return result # noqa: RET504
def __pos__(self: MarkerT) -> MarkerT: def __pos__(self: MarkerT) -> MarkerT:
return type(self)(name=self.name, is_member=True) return type(self)(name=self.name, is_member=True)
@ -38,7 +38,8 @@ class _MemberStatusMarker:
if isinstance(other, _MemberStatusGroupMarker): if isinstance(other, _MemberStatusGroupMarker):
return other | self return other | self
raise TypeError( raise TypeError(
f"unsupported operand type(s) for |: {type(self).__name__!r} and {type(other).__name__!r}" f"unsupported operand type(s) for |: "
f"{type(self).__name__!r} and {type(other).__name__!r}"
) )
__ror__ = __or__ __ror__ = __or__
@ -52,7 +53,8 @@ class _MemberStatusMarker:
if isinstance(other, _MemberStatusGroupMarker): if isinstance(other, _MemberStatusGroupMarker):
return _MemberStatusTransition(old=old, new=other) return _MemberStatusTransition(old=old, new=other)
raise TypeError( raise TypeError(
f"unsupported operand type(s) for >>: {type(self).__name__!r} and {type(other).__name__!r}" f"unsupported operand type(s) for >>: "
f"{type(self).__name__!r} and {type(other).__name__!r}"
) )
def __lshift__( def __lshift__(
@ -64,7 +66,8 @@ class _MemberStatusMarker:
if isinstance(other, _MemberStatusGroupMarker): if isinstance(other, _MemberStatusGroupMarker):
return _MemberStatusTransition(old=other, new=new) return _MemberStatusTransition(old=other, new=new)
raise TypeError( raise TypeError(
f"unsupported operand type(s) for <<: {type(self).__name__!r} and {type(other).__name__!r}" f"unsupported operand type(s) for <<: "
f"{type(self).__name__!r} and {type(other).__name__!r}"
) )
def __hash__(self) -> int: def __hash__(self) -> int:
@ -89,10 +92,11 @@ class _MemberStatusGroupMarker:
) -> MarkerGroupT: ) -> MarkerGroupT:
if isinstance(other, _MemberStatusMarker): if isinstance(other, _MemberStatusMarker):
return type(self)(*self.statuses, other) return type(self)(*self.statuses, other)
elif isinstance(other, _MemberStatusGroupMarker): if isinstance(other, _MemberStatusGroupMarker):
return type(self)(*self.statuses, *other.statuses) return type(self)(*self.statuses, *other.statuses)
raise TypeError( raise TypeError(
f"unsupported operand type(s) for |: {type(self).__name__!r} and {type(other).__name__!r}" f"unsupported operand type(s) for |: "
f"{type(self).__name__!r} and {type(other).__name__!r}"
) )
def __rshift__( def __rshift__(
@ -103,7 +107,8 @@ class _MemberStatusGroupMarker:
if isinstance(other, _MemberStatusGroupMarker): if isinstance(other, _MemberStatusGroupMarker):
return _MemberStatusTransition(old=self, new=other) return _MemberStatusTransition(old=self, new=other)
raise TypeError( raise TypeError(
f"unsupported operand type(s) for >>: {type(self).__name__!r} and {type(other).__name__!r}" f"unsupported operand type(s) for >>: "
f"{type(self).__name__!r} and {type(other).__name__!r}"
) )
def __lshift__( def __lshift__(
@ -114,7 +119,8 @@ class _MemberStatusGroupMarker:
if isinstance(other, _MemberStatusGroupMarker): if isinstance(other, _MemberStatusGroupMarker):
return _MemberStatusTransition(old=other, new=self) return _MemberStatusTransition(old=other, new=self)
raise TypeError( raise TypeError(
f"unsupported operand type(s) for <<: {type(self).__name__!r} and {type(other).__name__!r}" f"unsupported operand type(s) for <<: "
f"{type(self).__name__!r} and {type(other).__name__!r}"
) )
def __str__(self) -> str: def __str__(self) -> str:
@ -124,10 +130,7 @@ class _MemberStatusGroupMarker:
return result return result
def check(self, *, member: ChatMember) -> bool: def check(self, *, member: ChatMember) -> bool:
for status in self.statuses: return any(status.check(member=member) for status in self.statuses)
if status.check(member=member):
return True
return False
class _MemberStatusTransition: class _MemberStatusTransition:

View file

@ -181,7 +181,7 @@ class Command(Filter):
await self.validate_mention(bot=bot, command=command) await self.validate_mention(bot=bot, command=command)
command = self.validate_command(command) command = self.validate_command(command)
command = self.do_magic(command=command) command = self.do_magic(command=command)
return command return command # noqa: RET504
def do_magic(self, command: CommandObject) -> Any: def do_magic(self, command: CommandObject) -> Any:
if not self.magic: if not self.magic:

View file

@ -84,7 +84,7 @@ class Text(Filter):
@classmethod @classmethod
def _validate_constraints(cls, **values: Any) -> None: def _validate_constraints(cls, **values: Any) -> None:
# Validate that only one text filter type is presented # Validate that only one text filter type is presented
used_args = set(key for key, value in values.items() if value is not None) used_args = {key for key, value in values.items() if value is not None}
if len(used_args) < 1: if len(used_args) < 1:
raise ValueError(f"Filter should contain one of arguments: {set(values.keys())}") raise ValueError(f"Filter should contain one of arguments: {set(values.keys())}")
if len(used_args) > 1: if len(used_args) > 1:
@ -133,5 +133,4 @@ class Text(Filter):
def prepare_text(self, text: str) -> str: def prepare_text(self, text: str) -> str:
if self.ignore_case: if self.ignore_case:
return str(text).lower() return str(text).lower()
else: return str(text)
return str(text)

View file

@ -3,7 +3,12 @@ from typing import Any, Awaitable, Callable, Dict, Optional, cast
from aiogram import Bot from aiogram import Bot
from aiogram.dispatcher.middlewares.base import BaseMiddleware from aiogram.dispatcher.middlewares.base import BaseMiddleware
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.fsm.storage.base import DEFAULT_DESTINY, BaseEventIsolation, BaseStorage, StorageKey from aiogram.fsm.storage.base import (
DEFAULT_DESTINY,
BaseEventIsolation,
BaseStorage,
StorageKey,
)
from aiogram.fsm.strategy import FSMStrategy, apply_strategy from aiogram.fsm.strategy import FSMStrategy, apply_strategy
from aiogram.types import TelegramObject from aiogram.types import TelegramObject

View file

@ -6,7 +6,12 @@ from typing import Any, AsyncGenerator, DefaultDict, Dict, Hashable, Optional
from aiogram import Bot from aiogram import Bot
from aiogram.fsm.state import State from aiogram.fsm.state import State
from aiogram.fsm.storage.base import BaseEventIsolation, BaseStorage, StateType, StorageKey from aiogram.fsm.storage.base import (
BaseEventIsolation,
BaseStorage,
StateType,
StorageKey,
)
@dataclass @dataclass

View file

@ -2,7 +2,16 @@ from __future__ import annotations
import abc import abc
import secrets import secrets
from typing import TYPE_CHECKING, Any, Dict, Generator, Generic, Optional, TypeVar, Union from typing import (
TYPE_CHECKING,
Any,
Dict,
Generator,
Generic,
Optional,
TypeVar,
Union,
)
from pydantic import BaseConfig, BaseModel, Extra, root_validator from pydantic import BaseConfig, BaseModel, Extra, root_validator
from pydantic.generics import GenericModel from pydantic.generics import GenericModel

View file

@ -5,7 +5,9 @@ from .audio import Audio
from .base import UNSET, TelegramObject from .base import UNSET, TelegramObject
from .bot_command import BotCommand from .bot_command import BotCommand
from .bot_command_scope import BotCommandScope from .bot_command_scope import BotCommandScope
from .bot_command_scope_all_chat_administrators import BotCommandScopeAllChatAdministrators from .bot_command_scope_all_chat_administrators import (
BotCommandScopeAllChatAdministrators,
)
from .bot_command_scope_all_group_chats import BotCommandScopeAllGroupChats from .bot_command_scope_all_group_chats import BotCommandScopeAllGroupChats
from .bot_command_scope_all_private_chats import BotCommandScopeAllPrivateChats from .bot_command_scope_all_private_chats import BotCommandScopeAllPrivateChats
from .bot_command_scope_chat import BotCommandScopeChat from .bot_command_scope_chat import BotCommandScopeChat
@ -110,7 +112,9 @@ from .passport_element_error_front_side import PassportElementErrorFrontSide
from .passport_element_error_reverse_side import PassportElementErrorReverseSide from .passport_element_error_reverse_side import PassportElementErrorReverseSide
from .passport_element_error_selfie import PassportElementErrorSelfie from .passport_element_error_selfie import PassportElementErrorSelfie
from .passport_element_error_translation_file import PassportElementErrorTranslationFile from .passport_element_error_translation_file import PassportElementErrorTranslationFile
from .passport_element_error_translation_files import PassportElementErrorTranslationFiles from .passport_element_error_translation_files import (
PassportElementErrorTranslationFiles,
)
from .passport_element_error_unspecified import PassportElementErrorUnspecified from .passport_element_error_unspecified import PassportElementErrorUnspecified
from .passport_file import PassportFile from .passport_file import PassportFile
from .photo_size import PhotoSize from .photo_size import PhotoSize

View file

@ -5,7 +5,11 @@ from typing import TYPE_CHECKING, Any, List, Optional, Union
from pydantic import Field from pydantic import Field
from aiogram.utils.text_decorations import TextDecoration, html_decoration, markdown_decoration from aiogram.utils.text_decorations import (
TextDecoration,
html_decoration,
markdown_decoration,
)
from ..enums import ContentType from ..enums import ContentType
from .base import UNSET, TelegramObject from .base import UNSET, TelegramObject
@ -2465,7 +2469,7 @@ class Message(TelegramObject):
**kwargs, **kwargs,
) )
def send_copy( def send_copy( # noqa: C901
self: Message, self: Message,
chat_id: Union[str, int], chat_id: Union[str, int],
disable_notification: Optional[bool] = None, disable_notification: Optional[bool] = None,
@ -2538,7 +2542,7 @@ class Message(TelegramObject):
if self.text: if self.text:
return SendMessage(text=text, entities=entities, **kwargs) return SendMessage(text=text, entities=entities, **kwargs)
elif self.audio: if self.audio:
return SendAudio( return SendAudio(
audio=self.audio.file_id, audio=self.audio.file_id,
caption=text, caption=text,
@ -2548,29 +2552,29 @@ class Message(TelegramObject):
caption_entities=entities, caption_entities=entities,
**kwargs, **kwargs,
) )
elif self.animation: if self.animation:
return SendAnimation( return SendAnimation(
animation=self.animation.file_id, caption=text, caption_entities=entities, **kwargs animation=self.animation.file_id, caption=text, caption_entities=entities, **kwargs
) )
elif self.document: if self.document:
return SendDocument( return SendDocument(
document=self.document.file_id, caption=text, caption_entities=entities, **kwargs document=self.document.file_id, caption=text, caption_entities=entities, **kwargs
) )
elif self.photo: if self.photo:
return SendPhoto( return SendPhoto(
photo=self.photo[-1].file_id, caption=text, caption_entities=entities, **kwargs photo=self.photo[-1].file_id, caption=text, caption_entities=entities, **kwargs
) )
elif self.sticker: if self.sticker:
return SendSticker(sticker=self.sticker.file_id, **kwargs) return SendSticker(sticker=self.sticker.file_id, **kwargs)
elif self.video: if self.video:
return SendVideo( return SendVideo(
video=self.video.file_id, caption=text, caption_entities=entities, **kwargs video=self.video.file_id, caption=text, caption_entities=entities, **kwargs
) )
elif self.video_note: if self.video_note:
return SendVideoNote(video_note=self.video_note.file_id, **kwargs) return SendVideoNote(video_note=self.video_note.file_id, **kwargs)
elif self.voice: if self.voice:
return SendVoice(voice=self.voice.file_id, **kwargs) return SendVoice(voice=self.voice.file_id, **kwargs)
elif self.contact: if self.contact:
return SendContact( return SendContact(
phone_number=self.contact.phone_number, phone_number=self.contact.phone_number,
first_name=self.contact.first_name, first_name=self.contact.first_name,
@ -2578,7 +2582,7 @@ class Message(TelegramObject):
vcard=self.contact.vcard, vcard=self.contact.vcard,
**kwargs, **kwargs,
) )
elif self.venue: if self.venue:
return SendVenue( return SendVenue(
latitude=self.venue.location.latitude, latitude=self.venue.location.latitude,
longitude=self.venue.location.longitude, longitude=self.venue.location.longitude,
@ -2588,20 +2592,20 @@ class Message(TelegramObject):
foursquare_type=self.venue.foursquare_type, foursquare_type=self.venue.foursquare_type,
**kwargs, **kwargs,
) )
elif self.location: if self.location:
return SendLocation( return SendLocation(
latitude=self.location.latitude, longitude=self.location.longitude, **kwargs latitude=self.location.latitude, longitude=self.location.longitude, **kwargs
) )
elif self.poll: if self.poll:
return SendPoll( return SendPoll(
question=self.poll.question, question=self.poll.question,
options=[option.text for option in self.poll.options], options=[option.text for option in self.poll.options],
**kwargs, **kwargs,
) )
elif self.dice: # Dice value can't be controlled if self.dice: # Dice value can't be controlled
return SendDice(**kwargs) return SendDice(**kwargs)
else:
raise TypeError("This type of message can't be copied.") raise TypeError("This type of message can't be copied.")
def copy_to( def copy_to(
self, self,
@ -3066,9 +3070,10 @@ class Message(TelegramObject):
if self.chat.type in ("private", "group"): if self.chat.type in ("private", "group"):
return None return None
if not self.chat.username or force_private: chat_value = (
chat_value = f"c/{self.chat.shifted_id}" f"c/{self.chat.shifted_id}"
else: if not self.chat.username or force_private
chat_value = self.chat.username else self.chat.username
)
return f"https://t.me/{chat_value}/{self.message_id}" return f"https://t.me/{chat_value}/{self.message_id}"

View file

@ -15,7 +15,7 @@ def check_signature(token: str, hash: str, **kwargs: Any) -> bool:
:return: :return:
""" """
secret = hashlib.sha256(token.encode("utf-8")) secret = hashlib.sha256(token.encode("utf-8"))
check_string = "\n".join(map(lambda k: f"{k}={kwargs[k]}", sorted(kwargs))) check_string = "\n".join(f"{k}={kwargs[k]}" for k in sorted(kwargs))
hmac_string = hmac.new( hmac_string = hmac.new(
secret.digest(), check_string.encode("utf-8"), digestmod=hashlib.sha256 secret.digest(), check_string.encode("utf-8"), digestmod=hashlib.sha256
).hexdigest() ).hexdigest()

View file

@ -77,4 +77,7 @@ class Backoff:
self._next_delay = self.min_delay self._next_delay = self.min_delay
def __str__(self) -> str: def __str__(self) -> str:
return f"Backoff(tryings={self._counter}, current_delay={self._current_delay}, next_delay={self._next_delay})" return (
f"Backoff(tryings={self._counter}, current_delay={self._current_delay}, "
f"next_delay={self._next_delay})"
)

View file

@ -23,7 +23,8 @@ class ChatActionSender:
Provides simply to use context manager. Provides simply to use context manager.
Technically sender start background task with infinity loop which works Technically sender start background task with infinity loop which works
until action will be finished and sends the `chat action <https://core.telegram.org/bots/api#sendchataction>`_ until action will be finished and sends the
`chat action <https://core.telegram.org/bots/api#sendchataction>`_
every 5 seconds. every 5 seconds.
""" """
@ -110,7 +111,7 @@ class ChatActionSender:
async with self._lock: async with self._lock:
if not self.running: if not self.running:
return return
if not self._close_event.is_set(): if not self._close_event.is_set(): # pragma: no branches
self._close_event.set() self._close_event.set()
await self._closed_event.wait() await self._closed_event.wait()
self._task = None self._task = None

View file

@ -96,7 +96,8 @@ class KeyboardBuilder(Generic[ButtonType]):
""" """
if not isinstance(row, list): if not isinstance(row, list):
raise ValueError( raise ValueError(
f"Row {row!r} should be type 'List[{self._button_type.__name__}]' not type {type(row).__name__}" f"Row {row!r} should be type 'List[{self._button_type.__name__}]' "
f"not type {type(row).__name__}"
) )
if len(row) > MAX_WIDTH: if len(row) > MAX_WIDTH:
raise ValueError(f"Row {row!r} is too long (MAX_WIDTH={MAX_WIDTH})") raise ValueError(f"Row {row!r} is too long (MAX_WIDTH={MAX_WIDTH})")
@ -114,7 +115,8 @@ class KeyboardBuilder(Generic[ButtonType]):
count = 0 count = 0
if not isinstance(markup, list): if not isinstance(markup, list):
raise ValueError( raise ValueError(
f"Markup should be type 'List[List[{self._button_type.__name__}]]' not type {type(markup).__name__!r}" f"Markup should be type 'List[List[{self._button_type.__name__}]]' "
f"not type {type(markup).__name__!r}"
) )
for row in markup: for row in markup:
self._validate_row(row) self._validate_row(row)
@ -206,7 +208,8 @@ class KeyboardBuilder(Generic[ButtonType]):
By default, when the sum of passed sizes is lower than buttons count the last By default, when the sum of passed sizes is lower than buttons count the last
one size will be used for tail of the markup. one size will be used for tail of the markup.
If repeat=True is passed - all sizes will be cycled when available more buttons count than all sizes If repeat=True is passed - all sizes will be cycled when available more buttons
count than all sizes
:param sizes: :param sizes:
:param repeat: :param repeat:

View file

@ -12,8 +12,9 @@ class AsFilterResultOperation(BaseOperation):
self.name = name self.name = name
def resolve(self, value: Any, initial_value: Any) -> Any: def resolve(self, value: Any, initial_value: Any) -> Any:
if value: if not value:
return {self.name: value} return None
return {self.name: value}
class MagicFilter(_MagicFilter): class MagicFilter(_MagicFilter):

View file

@ -81,13 +81,12 @@ class TextDecoration(ABC):
:param entities: Array of MessageEntities :param entities: Array of MessageEntities
:return: :return:
""" """
result = "".join( return "".join(
self._unparse_entities( self._unparse_entities(
add_surrogates(text), add_surrogates(text),
sorted(entities, key=lambda item: item.offset) if entities else [], sorted(entities, key=lambda item: item.offset) if entities else [],
) )
) )
return result
def _unparse_entities( def _unparse_entities(
self, self,

View file

@ -17,7 +17,10 @@ class WebAppUser(TelegramObject):
""" """
id: int id: int
"""A unique identifier for the user or bot. This number may have more than 32 significant bits and some programming languages may have difficulty/silent defects in interpreting it. It has at most 52 significant bits, so a 64-bit integer or a double-precision float type is safe for storing this identifier.""" """A unique identifier for the user or bot. This number may have more than 32 significant bits
and some programming languages may have difficulty/silent defects in interpreting it.
It has at most 52 significant bits, so a 64-bit integer or a double-precision float type
is safe for storing this identifier."""
is_bot: Optional[bool] = None is_bot: Optional[bool] = None
"""True, if this user is a bot. Returns in the receiver field only.""" """True, if this user is a bot. Returns in the receiver field only."""
first_name: str first_name: str
@ -29,24 +32,32 @@ class WebAppUser(TelegramObject):
language_code: Optional[str] = None language_code: Optional[str] = None
"""IETF language tag of the user's language. Returns in user field only.""" """IETF language tag of the user's language. Returns in user field only."""
photo_url: Optional[str] = None photo_url: Optional[str] = None
"""URL of the users profile photo. The photo can be in .jpeg or .svg formats. Only returned for Web Apps launched from the attachment menu.""" """URL of the users profile photo. The photo can be in .jpeg or .svg formats.
Only returned for Web Apps launched from the attachment menu."""
class WebAppInitData(TelegramObject): class WebAppInitData(TelegramObject):
""" """
This object contains data that is transferred to the Web App when it is opened. It is empty if the Web App was launched from a keyboard button. This object contains data that is transferred to the Web App when it is opened.
It is empty if the Web App was launched from a keyboard button.
Source: https://core.telegram.org/bots/webapps#webappinitdata Source: https://core.telegram.org/bots/webapps#webappinitdata
""" """
query_id: Optional[str] = None query_id: Optional[str] = None
"""A unique identifier for the Web App session, required for sending messages via the answerWebAppQuery method.""" """A unique identifier for the Web App session, required for sending messages
via the answerWebAppQuery method."""
user: Optional[WebAppUser] = None user: Optional[WebAppUser] = None
"""An object containing data about the current user.""" """An object containing data about the current user."""
receiver: Optional[WebAppUser] = None receiver: Optional[WebAppUser] = None
"""An object containing data about the chat partner of the current user in the chat where the bot was launched via the attachment menu. Returned only for Web Apps launched via the attachment menu.""" """An object containing data about the chat partner of the current user in the chat where
the bot was launched via the attachment menu.
Returned only for Web Apps launched via the attachment menu."""
start_param: Optional[str] = None start_param: Optional[str] = None
"""The value of the startattach parameter, passed via link. Only returned for Web Apps when launched from the attachment menu via link. The value of the start_param parameter will also be passed in the GET-parameter tgWebAppStartParam, so the Web App can load the correct interface right away.""" """The value of the startattach parameter, passed via link.
Only returned for Web Apps when launched from the attachment menu via link.
The value of the start_param parameter will also be passed in the GET-parameter
tgWebAppStartParam, so the Web App can load the correct interface right away."""
auth_date: datetime auth_date: datetime
"""Unix time when the form was opened.""" """Unix time when the form was opened."""
hash: str hash: str

View file

@ -88,7 +88,8 @@ class BaseRequestHandler(ABC):
) -> None: ) -> None:
""" """
:param dispatcher: instance of :class:`aiogram.dispatcher.dispatcher.Dispatcher` :param dispatcher: instance of :class:`aiogram.dispatcher.dispatcher.Dispatcher`
:param handle_in_background: immediately respond to the Telegram instead of waiting end of handler process :param handle_in_background: immediately respond to the Telegram instead of
waiting end of handler process
""" """
self.dispatcher = dispatcher self.dispatcher = dispatcher
self.handle_in_background = handle_in_background self.handle_in_background = handle_in_background
@ -166,7 +167,8 @@ class SimpleRequestHandler(BaseRequestHandler):
) -> None: ) -> None:
""" """
:param dispatcher: instance of :class:`aiogram.dispatcher.dispatcher.Dispatcher` :param dispatcher: instance of :class:`aiogram.dispatcher.dispatcher.Dispatcher`
:param handle_in_background: immediately respond to the Telegram instead of waiting end of handler process :param handle_in_background: immediately respond to the Telegram instead of
waiting end of handler process
:param bot: instance of :class:`aiogram.client.bot.Bot` :param bot: instance of :class:`aiogram.client.bot.Bot`
""" """
super().__init__(dispatcher=dispatcher, handle_in_background=handle_in_background, **data) super().__init__(dispatcher=dispatcher, handle_in_background=handle_in_background, **data)
@ -184,7 +186,8 @@ class SimpleRequestHandler(BaseRequestHandler):
class TokenBasedRequestHandler(BaseRequestHandler): class TokenBasedRequestHandler(BaseRequestHandler):
""" """
Handler that supports multiple bots, the context will be resolved from path variable 'bot_token' Handler that supports multiple bots, the context will be resolved
from path variable 'bot_token'
""" """
def __init__( def __init__(
@ -196,7 +199,8 @@ class TokenBasedRequestHandler(BaseRequestHandler):
) -> None: ) -> None:
""" """
:param dispatcher: instance of :class:`aiogram.dispatcher.dispatcher.Dispatcher` :param dispatcher: instance of :class:`aiogram.dispatcher.dispatcher.Dispatcher`
:param handle_in_background: immediately respond to the Telegram instead of waiting end of handler process :param handle_in_background: immediately respond to the Telegram instead of
waiting end of handler process
:param bot_settings: kwargs that will be passed to new Bot instance :param bot_settings: kwargs that will be passed to new Bot instance
""" """
super().__init__(dispatcher=dispatcher, handle_in_background=handle_in_background, **data) super().__init__(dispatcher=dispatcher, handle_in_background=handle_in_background, **data)

213
docs/contributing.rst Normal file
View file

@ -0,0 +1,213 @@
============
Contributing
============
You're welcome to contribute to aiogram!
*aiogram* is an open-source project, and anyone can contribute to it in any possible way
Developing
==========
Before making any changes in the framework code, it is necessary to fork the project and clone
the project to your PC and know how to do a pull-request.
How to work with pull-request you can read in the `GitHub docs <https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request>`_
Also in due to this project is written in Python, you will need Python to be installed
(is recommended to use latest Python versions, but any version starting from 3.8 can be used)
Use virtualenv
--------------
You can create a virtual environment in a directory using :code:`venv` module (it should be pre-installed by default):
.. code-block::bash
python -m venv .venv
This action will create a :code:`.venv` directory with the Python binaries and then you will
be able to install packages into that isolated environment.
Activate the environment
------------------------
Linux/ macOS:
.. code-block:: bash
source .venv/bin/activate
Windows PoweShell
.. code-block:: powershell
.\.venv\Scripts\Activate.ps1
To check it worked, use described command, it should show the :code:`pip` location inside
the isolated environment
Linux, macOS:
.. code-block::
which pip
Windows PowerShell
.. code-block::
Get-Command pip
Also make you shure you have the latest pip version in your virtual environment to avoid
errors on next steps:
.. code-block::
python -m pip install --upgrade pip
Setup project
-------------
After activating the environment install `aiogram` from sources and their dependencies:
.. code-block:: bash
pip install -e ."[dev,test,docs,fast,redis,proxy,i18n]"
It will install :code:`aiogram` in editable mode into your virtual environment and all dependencies.
Making changes in code
----------------------
At this point you can make any changes in the code that you want, it can be any fixes,
implementing new features or experimenting.
Format the code (code-style)
----------------------------
Note that this project is Black-formatted, so you should follow that code-style,
too be sure You're correctly doing this let's reformat the code automatically:
.. code-block:: bash
black aiogram tests examples
isort aiogram tests examples
Run tests
---------
All changes should be tested:
.. code-block:: bash
pytest tests
Also if you are doing something with Redis-storage, you will need to test everything works with Redis:
.. code-block:: bash
pytest --redis redis://<host>:<port>/<db> tests
Docs
----
We are using `Sphinx` to render docs in different languages, all sources located in `docs` directory,
you can change the sources and to test it you can start live-preview server and look what you are doing:
.. code-block:: bash
sphinx-autobuild --watch aiogram/ docs/ docs/_build/
Docs translations
-----------------
Translation of the documentation is very necessary and cannot be done without the help of the
community from all over the world, so you are welcome to translate the documentation
into different languages.
Before start, let's up to date all texts:
.. code-block:: bash
cd docs
make gettext
sphinx-intl update -p _build/gettext -l <language_code>
Change the :code:`<language_code>` in example below to the target language code, after that
you can modify texts inside :code:`docs/locale/<language_code>/LC_MESSAGES` as :code:`*.po` files
by using any text-editor or specialized utilites for GNU Gettext,
for example via `poedit <https://poedit.net/>`_.
To view results:
.. code-block:: bash
sphinx-autobuild --watch aiogram/ docs/ docs/_build/ -D language=<language_code>
Describe changes
----------------
Describe your changes in one or more sentences so that bot developers know what's changed
in their favorite framework - create `<code>.<category>.rst` file and write the description.
:code:`<code>` is Issue or Pull-request number, after release link to this issue will
be published to the *Changelog* page.
:code:`<category>` is a changes category marker, it can be one of:
- :code:`feature` - when you are implementing new feature
- :code:`bugfix` - when you fix a bug
- :code:`doc` - when you improve the docs
- :code:`removal` - when you remove something from the framework
- :code:`misc` - when changed something inside the Core or project configuration
If you have troubles with changing category feel free to ask Core-contributors to help with choosing it.
Complete
--------
After you have made all your changes, publish them to the repository and create a pull request
as mentioned at the beginning of the article and wait for a review of these changes.
Star on GitHub
==============
You can "star" repository on GitHub - https://github.com/aiogram/aiogram (click the star button at the top right)
Adding stars makes it easier for other people to find this project and understand how useful it is.
Guides
======
You can write guides how to develop Bots on top of aiogram and publish it into YouTube, Medium,
GitHub Books, any Courses platform or any other platform that you know.
This will help more people learn about the framework and learn how to use it
Take answers
============
The developers is always asks for any question in our chats or any other platforms like GitHub Discussions,
StackOverflow and others, feel free to answer to this questions.
Funding
=======
The development of the project is free and not financed by commercial organizations,
it is my personal initiative (`@JRootJunior <https://t.me/JRootJunior>`_) and
I am engaged in the development of the project in my free time.
So, if you want to financially support the project, or, for example, give me a pizza or a beer,
you can do it on `OpenCollective <https://opencollective.com/aiogram>`_
or `Patreon <https://www.patreon.com/aiogram>`_.

View file

@ -16,3 +16,4 @@ Contents
dispatcher/index dispatcher/index
utils/index utils/index
changelog changelog
contributing

View file

@ -8,7 +8,12 @@ from aiogram import Bot, Dispatcher, F, Router, html
from aiogram.filters import Command from aiogram.filters import Command
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup from aiogram.fsm.state import State, StatesGroup
from aiogram.types import KeyboardButton, Message, ReplyKeyboardMarkup, ReplyKeyboardRemove from aiogram.types import (
KeyboardButton,
Message,
ReplyKeyboardMarkup,
ReplyKeyboardRemove,
)
form_router = Router() form_router = Router()

View file

@ -48,7 +48,7 @@ async def command_add_bot(message: Message, command: CommandObject, bot: Bot) ->
return message.answer("Invalid token") return message.answer("Invalid token")
await new_bot.delete_webhook(drop_pending_updates=True) await new_bot.delete_webhook(drop_pending_updates=True)
await new_bot.set_webhook(OTHER_BOTS_URL.format(bot_token=command.args)) await new_bot.set_webhook(OTHER_BOTS_URL.format(bot_token=command.args))
await message.answer(f"Bot @{bot_user.username} successful added") return await message.answer(f"Bot @{bot_user.username} successful added")
async def on_startup(dispatcher: Dispatcher, bot: Bot): async def on_startup(dispatcher: Dispatcher, bot: Bot):

View file

@ -36,7 +36,6 @@ async def send_message_handler(request: Request):
except ValueError: except ValueError:
return json_response({"ok": False, "err": "Unauthorized"}, status=401) return json_response({"ok": False, "err": "Unauthorized"}, status=401)
print(data)
reply_markup = None reply_markup = None
if data["with_webview"] == "1": if data["with_webview"] == "1":
reply_markup = InlineKeyboardMarkup( reply_markup = InlineKeyboardMarkup(

View file

@ -1,37 +0,0 @@
[mypy]
plugins = pydantic.mypy
python_version = 3.8
show_error_codes = True
show_error_context = True
pretty = True
ignore_missing_imports = False
warn_unused_configs = True
disallow_subclassing_any = True
disallow_any_generics = True
disallow_untyped_calls = True
disallow_untyped_defs = True
disallow_incomplete_defs = True
check_untyped_defs = True
disallow_untyped_decorators = True
no_implicit_optional = True
warn_redundant_casts = True
warn_unused_ignores = True
warn_return_any = True
follow_imports_for_stubs = True
namespace_packages = True
show_absolute_path = True
[mypy-aiofiles]
ignore_missing_imports = True
[mypy-async_lru]
ignore_missing_imports = True
[mypy-uvloop]
ignore_missing_imports = True
[mypy-redis.*]
ignore_missing_imports = True
[mypy-babel.*]
ignore_missing_imports = True

2486
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,18 +1,19 @@
[tool.poetry] [build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "aiogram" name = "aiogram"
version = "3.0.0-beta.7" description = 'Modern and fully asynchronous framework for Telegram Bot API'
description = "Modern and fully asynchronous framework for Telegram Bot API" readme = "README.rst"
requires-python = ">=3.8"
license = "MIT"
authors = [ authors = [
"Alex Root Junior <jroot.junior@gmail.com>", { name = "Alex Root Junior", email = "jroot.junior@gmail.com" },
] ]
maintainers = [ maintainers = [
"Alex Root Junior <jroot.junior@gmail.com>", { name = "Alex Root Junior", email = "jroot.junior@gmail.com" },
] ]
license = "MIT"
readme = "README.rst"
homepage = "https://aiogram.dev/"
documentation = "https://docs.aiogram.dev/"
repository = "https://github.com/aiogram/aiogram/"
keywords = [ keywords = [
"telegram", "telegram",
"bot", "bot",
@ -38,77 +39,245 @@ classifiers = [
"Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Communications :: Chat", "Topic :: Communications :: Chat",
] ]
packages = [ dependencies = [
{ include = "aiogram" } "magic-filter~=1.0.9",
"aiohttp~=3.8.3",
"pydantic~=1.10.4",
"aiofiles~=22.1.0",
"certifi>=2022.9.24",
]
dynamic = ["version"]
[tool.hatch.version]
path = "aiogram/__init__.py"
[project.optional-dependencies]
fast = [
"uvloop>=0.17.0; (sys_platform == 'darwin' or sys_platform == 'linux') and platform_python_implementation != 'PyPy'",
]
redis = [
"redis~=4.3.4",
]
proxy = [
"aiohttp-socks~=0.7.1",
]
i18n = [
"Babel~=2.9.1",
]
test = [
"pytest~=7.1.3",
"pytest-html~=3.1.1",
"pytest-asyncio~=0.19.0",
"pytest-lazy-fixture~=0.6.3",
"pytest-mock~=3.9.0",
"pytest-mypy~=0.10.0",
"pytest-cov~=4.0.0",
"pytest-aiohttp~=1.0.4",
"aresponses~=2.1.6",
]
docs = [
"Sphinx~=5.2.3",
"sphinx-intl~=2.0.1",
"sphinx-autobuild~=2021.3.14",
"sphinx-copybutton~=0.5.0",
"furo~=2022.9.29",
"sphinx-prompt~=1.5.0",
"Sphinx-Substitution-Extensions~=2022.2.16",
"towncrier~=22.8.0",
"pygments~=2.4",
"pymdown-extensions~=9.6",
"markdown-include~=0.7.0",
"Pygments~=2.13.0",
"sphinxcontrib-towncrier~=0.3.1a3",
]
dev = [
"black~=22.8",
"isort~=5.11",
"ruff~=0.0.215",
"mypy~=0.981",
"toml~=0.10.2",
"pre-commit~=2.20.0",
"packaging~=21.3",
"typing-extensions~=4.3.0",
] ]
[project.urls]
Homepage = "https://aiogram.dev/"
Documentation = "https://docs.aiogram.dev/"
Repository = "https://github.com/aiogram/aiogram/"
[tool.poetry.dependencies] [tool.hatch.envs.default]
python = "^3.8" features = [
magic-filter = "^1.0.9" "dev",
aiohttp = "^3.8.3" "fast",
pydantic = "^1.10.2" "redis",
aiofiles = "^22.1.0" "proxy",
# Fast "i18n",
uvloop = { version = "^0.17.0", markers = "sys_platform == 'darwin' or sys_platform == 'linux'", optional = true } ]
# i18n post-install-commands = [
Babel = { version = "^2.9.1", optional = true } "pre-commit install",
# Proxy ]
aiohttp-socks = { version = "^0.7.1", optional = true }
# Redis [tool.hatch.envs.default.scripts]
redis = { version = "^4.3.4", optional = true } reformat = [
certifi = "^2022.9.24" "black aiogram tests",
"isort aiogram tests",
]
lint = "ruff aiogram"
[tool.hatch.envs.docs]
features = [
"fast",
"redis",
"proxy",
"i18n",
"docs",
]
[tool.hatch.envs.docs.scripts]
serve = "sphinx-autobuild --watch aiogram/ --watch CHANGELOG.rst --watch README.rst docs/ docs/_build/ {args}"
[tool.hatch.envs.dev]
python = "3.10"
features = [
"dev",
"fast",
"redis",
"proxy",
"i18n",
"test",
]
extra-dependencies = [
"butcher @ git+https://github.com/aiogram/butcher.git@v0.1.9"
]
[tool.hatch.envs.test]
features = [
"fast",
"redis",
"proxy",
"i18n",
"test",
]
[tool.hatch.envs.test.scripts]
cov = [
"pytest --cov-config pyproject.toml --cov=aiogram --html=reports/py{matrix:python}/tests/index.html {args}",
"coverage html -d reports/py{matrix:python}/coverage",
]
cov-redis = [
"pytest --cov-config pyproject.toml --cov=aiogram --html=reports/py{matrix:python}/tests/index.html --redis {env:REDIS_DNS:'redis://localhost:6379'} {args}",
"coverage html -d reports/py{matrix:python}/coverage",
]
view-cov = "google-chrome-stable reports/py{matrix:python}/coverage/index.html"
[tool.poetry.group.docs.dependencies] [[tool.hatch.envs.test.matrix]]
Sphinx = "^5.2.3" python = ["38", "39", "310", "311"]
sphinx-intl = "^2.0.1"
sphinx-autobuild = "^2021.3.14" [tool.ruff]
sphinx-copybutton = "^0.5.0" line-length = 99
furo = "^2022.9.29" select = [
sphinx-prompt = "^1.5.0" # "C", # TODO: mccabe - code complecity
Sphinx-Substitution-Extensions = "^2022.2.16" "C4",
towncrier = "^22.8.0" "E",
pygments = "^2.4" "F",
pymdown-extensions = "^9.6" "T10",
markdown-include = "^0.7.0" "T20",
Pygments = "^2.13.0" "Q",
sphinxcontrib-towncrier = "^0.3.1a3" "RET",
]
ignore = [
"F401"
]
src = ["aiogram", "tests"]
exclude = [
".git",
"build",
"dist",
"venv",
".venv",
"docs",
"tests",
"dev",
"scripts",
"*.egg-info",
]
target-version = "py310"
[tool.ruff.isort]
known-first-party = [
"aiogram",
"finite_state_machine",
"handlers",
"routes",
]
[tool.ruff.per-file-ignores]
"aiogram/client/bot.py" = ["E501"]
"aiogram/types/*" = ["E501"]
"aiogram/methods/*" = ["E501"]
"aiogram/enums/*" = ["E501"]
[tool.poetry.group.test.dependencies] [tool.pytest.ini_options]
pytest = "^7.1.3" asyncio_mode = "auto"
pytest-html = "^3.1.1" testpaths = [
pytest-asyncio = "^0.19.0" "tests",
pytest-lazy-fixture = "^0.6.3" ]
pytest-mock = "^3.9.0"
pytest-mypy = "^0.10.0"
pytest-cov = "^4.0.0"
pytest-aiohttp = "^1.0.4"
aresponses = "^2.1.6"
[tool.coverage.run]
branch = false
parallel = true
omit = [
"aiogram/__about__.py",
]
[tool.poetry.group.dev.dependencies] [tool.coverage.report]
black = "^22.8.0" exclude_lines = [
isort = "^5.10.1" "if __name__ == .__main__.:",
flake8 = "^5.0.4" "pragma: no cover",
mypy = "^0.981" "if TYPE_CHECKING:",
toml = "^0.10.2" "@abstractmethod",
pre-commit = "^2.20.0" "@overload",
packaging = "^21.3" ]
typing-extensions = "^4.3.0"
butcher = { git = "https://github.com/aiogram/butcher.git", rev = "v0.1.8", python = "3.10" }
[tool.mypy]
plugins = "pydantic.mypy"
python_version = "3.8"
show_error_codes = true
show_error_context = true
pretty = true
ignore_missing_imports = false
warn_unused_configs = true
disallow_subclassing_any = true
disallow_any_generics = true
disallow_untyped_calls = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
disallow_untyped_decorators = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_return_any = true
follow_imports_for_stubs = true
namespace_packages = true
show_absolute_path = true
[tool.poetry.extras] [[tool.mypy.overrides]]
fast = ["uvloop"] module = [
redis = ["redis"] "aiofiles",
proxy = ["aiohttp-socks"] "async_lru",
i18n = ["Babel"] "uvloop",
"redis.*",
"babel.*",
]
ignore_missing_imports = true
disallow_untyped_defs = true
[tool.black] [tool.black]
line-length = 99 line-length = 99
target-version = ['py38', 'py39', 'py310'] target-version = ['py38', 'py39', 'py310', 'py311']
exclude = ''' exclude = '''
( (
\.eggs \.eggs
@ -122,21 +291,7 @@ exclude = '''
''' '''
[tool.isort] [tool.isort]
multi_line_output = 3 profile = "black"
include_trailing_comma = true
force_grid_wrap = 0
use_parentheses = true
line_length = 99
known_third_party = [
"aiofiles",
"aiohttp",
"aiohttp_socks",
"aresponses",
"packaging",
"pkg_resources",
"pydantic",
"pytest"
]
[tool.towncrier] [tool.towncrier]
package = "aiogram" package = "aiogram"
@ -172,7 +327,3 @@ showcontent = true
directory = "misc" directory = "misc"
name = "Misc" name = "Misc"
showcontent = true showcontent = true
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

View file

@ -1,4 +0,0 @@
[pytest]
asyncio_mode = auto
testpaths =
tests

View file

@ -1,16 +0,0 @@
aiofiles==22.1.0 ; python_version >= "3.8" and python_version < "4.0"
aiohttp==3.8.3 ; python_version >= "3.8" and python_version < "4.0"
aiosignal==1.2.0 ; python_version >= "3.8" and python_version < "4.0"
async-timeout==4.0.2 ; python_version >= "3.8" and python_version < "4.0"
attrs==22.1.0 ; python_version >= "3.8" and python_version < "4.0"
babel==2.10.3 ; python_version >= "3.8" and python_version < "4.0"
certifi==2022.9.24 ; python_version >= "3.8" and python_version < "4.0"
charset-normalizer==2.1.1 ; python_version >= "3.8" and python_version < "4.0"
frozenlist==1.3.1 ; python_version >= "3.8" and python_version < "4.0"
idna==3.4 ; python_version >= "3.8" and python_version < "4.0"
magic-filter==1.0.9 ; python_version >= "3.8" and python_version < "4.0"
multidict==6.0.2 ; python_version >= "3.8" and python_version < "4.0"
pydantic==1.10.2 ; python_version >= "3.8" and python_version < "4.0"
pytz==2022.5 ; python_version >= "3.8" and python_version < "4.0"
typing-extensions==4.4.0 ; python_version >= "3.8" and python_version < "4.0"
yarl==1.8.1 ; python_version >= "3.8" and python_version < "4.0"

View file

@ -1,65 +0,0 @@
aiofiles==22.1.0 ; python_version >= "3.8" and python_version < "4.0"
aiohttp-socks==0.7.1 ; python_version >= "3.8" and python_version < "4.0"
aiohttp==3.8.3 ; python_version >= "3.8" and python_version < "4.0"
aiosignal==1.2.0 ; python_version >= "3.8" and python_version < "4.0"
alabaster==0.7.12 ; python_version >= "3.8" and python_version < "4.0"
async-timeout==4.0.2 ; python_version >= "3.8" and python_version < "4.0"
attrs==22.1.0 ; python_version >= "3.8" and python_version < "4.0"
babel==2.10.3 ; python_version >= "3.8" and python_version < "4.0"
beautifulsoup4==4.11.1 ; python_version >= "3.8" and python_version < "4.0"
certifi==2022.9.24 ; python_version >= "3.8" and python_version < "4.0"
charset-normalizer==2.1.1 ; python_version >= "3.8" and python_version < "4.0"
click-default-group==1.2.2 ; python_version >= "3.8" and python_version < "4.0"
click==8.1.3 ; python_version >= "3.8" and python_version < "4.0"
colorama==0.4.6 ; python_version >= "3.8" and python_version < "4.0"
deprecated==1.2.13 ; python_version >= "3.8" and python_version < "4.0"
docutils==0.19 ; python_version >= "3.8" and python_version < "4.0"
frozenlist==1.3.1 ; python_version >= "3.8" and python_version < "4.0"
furo==2022.9.29 ; python_version >= "3.8" and python_version < "4.0"
idna==3.4 ; python_version >= "3.8" and python_version < "4.0"
imagesize==1.4.1 ; python_version >= "3.8" and python_version < "4.0"
importlib-metadata==5.0.0 ; python_version >= "3.8" and python_version < "3.10"
incremental==22.10.0 ; python_version >= "3.8" and python_version < "4.0"
jinja2==3.1.2 ; python_version >= "3.8" and python_version < "4.0"
livereload==2.6.3 ; python_version >= "3.8" and python_version < "4.0"
magic-filter==1.0.9 ; python_version >= "3.8" and python_version < "4.0"
markdown-include==0.7.0 ; python_version >= "3.8" and python_version < "4.0"
markdown==3.4.1 ; python_version >= "3.8" and python_version < "4.0"
markupsafe==2.1.1 ; python_version >= "3.8" and python_version < "4.0"
multidict==6.0.2 ; python_version >= "3.8" and python_version < "4.0"
packaging==21.3 ; python_version >= "3.8" and python_version < "4.0"
pydantic==1.10.2 ; python_version >= "3.8" and python_version < "4.0"
pygments==2.13.0 ; python_version >= "3.8" and python_version < "4.0"
pymdown-extensions==9.7 ; python_version >= "3.8" and python_version < "4.0"
pyparsing==3.0.9 ; python_version >= "3.8" and python_version < "4.0"
python-socks[asyncio]==2.0.3 ; python_version >= "3.8" and python_version < "4.0"
pytz==2022.5 ; python_version >= "3.8" and python_version < "4.0"
redis==4.3.4 ; python_version >= "3.8" and python_version < "4.0"
requests==2.28.1 ; python_version >= "3.8" and python_version < "4"
setuptools==65.5.0 ; python_version >= "3.8" and python_version < "4.0"
six==1.16.0 ; python_version >= "3.8" and python_version < "4.0"
snowballstemmer==2.2.0 ; python_version >= "3.8" and python_version < "4.0"
soupsieve==2.3.2.post1 ; python_version >= "3.8" and python_version < "4.0"
sphinx-autobuild==2021.3.14 ; python_version >= "3.8" and python_version < "4.0"
sphinx-basic-ng==1.0.0b1 ; python_version >= "3.8" and python_version < "4.0"
sphinx-copybutton==0.5.0 ; python_version >= "3.8" and python_version < "4.0"
sphinx-intl==2.0.1 ; python_version >= "3.8" and python_version < "4.0"
sphinx-prompt==1.5.0 ; python_version >= "3.8" and python_version < "4.0"
sphinx-substitution-extensions==2022.2.16 ; python_version >= "3.8" and python_version < "4.0"
sphinx==5.3.0 ; python_version >= "3.8" and python_version < "4.0"
sphinxcontrib-applehelp==1.0.2 ; python_version >= "3.8" and python_version < "4.0"
sphinxcontrib-devhelp==1.0.2 ; python_version >= "3.8" and python_version < "4.0"
sphinxcontrib-htmlhelp==2.0.0 ; python_version >= "3.8" and python_version < "4.0"
sphinxcontrib-jsmath==1.0.1 ; python_version >= "3.8" and python_version < "4.0"
sphinxcontrib-qthelp==1.0.3 ; python_version >= "3.8" and python_version < "4.0"
sphinxcontrib-serializinghtml==1.1.5 ; python_version >= "3.8" and python_version < "4.0"
sphinxcontrib-towncrier==0.3.1a3 ; python_version >= "3.8" and python_version < "4.0"
tomli==2.0.1 ; python_version >= "3.8" and python_version < "4.0"
tornado==6.2 ; python_version >= "3.8" and python_version < "4.0"
towncrier==22.8.0 ; python_version >= "3.8" and python_version < "4.0"
typing-extensions==4.4.0 ; python_version >= "3.8" and python_version < "4.0"
urllib3==1.26.12 ; python_version >= "3.8" and python_version < "4"
uvloop==0.17.0 ; python_version >= "3.8" and python_version < "4.0" and sys_platform == "darwin" or python_version >= "3.8" and python_version < "4.0" and sys_platform == "linux"
wrapt==1.14.1 ; python_version >= "3.8" and python_version < "4.0"
yarl==1.8.1 ; python_version >= "3.8" and python_version < "4.0"
zipp==3.10.0 ; python_version >= "3.8" and python_version < "3.10"

View file

@ -5,7 +5,11 @@ from _pytest.config import UsageError
from redis.asyncio.connection import parse_url as parse_redis_url from redis.asyncio.connection import parse_url as parse_redis_url
from aiogram import Bot, Dispatcher from aiogram import Bot, Dispatcher
from aiogram.fsm.storage.memory import DisabledEventIsolation, MemoryStorage, SimpleEventIsolation from aiogram.fsm.storage.memory import (
DisabledEventIsolation,
MemoryStorage,
SimpleEventIsolation,
)
from aiogram.fsm.storage.redis import RedisEventIsolation, RedisStorage from aiogram.fsm.storage.redis import RedisEventIsolation, RedisStorage
from tests.mocked_bot import MockedBot from tests.mocked_bot import MockedBot

View file

@ -1,6 +1,10 @@
from aiogram import Bot from aiogram import Bot
from aiogram.methods import AnswerInlineQuery, Request from aiogram.methods import AnswerInlineQuery, Request
from aiogram.types import InlineQueryResult, InlineQueryResultPhoto, InputTextMessageContent from aiogram.types import (
InlineQueryResult,
InlineQueryResultPhoto,
InputTextMessageContent,
)
from tests.mocked_bot import MockedBot from tests.mocked_bot import MockedBot

View file

@ -1,5 +1,11 @@
from aiogram.methods import AnswerShippingQuery from aiogram.methods import AnswerShippingQuery
from aiogram.types import LabeledPrice, ShippingAddress, ShippingOption, ShippingQuery, User from aiogram.types import (
LabeledPrice,
ShippingAddress,
ShippingOption,
ShippingQuery,
User,
)
class TestInlineQuery: class TestInlineQuery:

View file

@ -5,7 +5,15 @@ from typing import Sequence, Type
import pytest import pytest
from aiogram.filters import Text from aiogram.filters import Text
from aiogram.types import CallbackQuery, Chat, InlineQuery, Message, Poll, PollOption, User from aiogram.types import (
CallbackQuery,
Chat,
InlineQuery,
Message,
Poll,
PollOption,
User,
)
class TestText: class TestText:

View file

@ -1,7 +1,11 @@
import pytest import pytest
from aiogram.fsm.storage.base import DEFAULT_DESTINY, StorageKey from aiogram.fsm.storage.base import DEFAULT_DESTINY, StorageKey
from aiogram.fsm.storage.redis import DefaultKeyBuilder, RedisEventIsolation, RedisStorage from aiogram.fsm.storage.redis import (
DefaultKeyBuilder,
RedisEventIsolation,
RedisStorage,
)
PREFIX = "test" PREFIX = "test"
BOT_ID = 42 BOT_ID = 42

View file

@ -7,7 +7,12 @@ from aiogram.fsm.context import FSMContext
from aiogram.fsm.storage.base import StorageKey from aiogram.fsm.storage.base import StorageKey
from aiogram.fsm.storage.memory import MemoryStorage from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.types import Update, User from aiogram.types import Update, User
from aiogram.utils.i18n import ConstI18nMiddleware, FSMI18nMiddleware, I18n, SimpleI18nMiddleware from aiogram.utils.i18n import (
ConstI18nMiddleware,
FSMI18nMiddleware,
I18n,
SimpleI18nMiddleware,
)
from aiogram.utils.i18n.context import get_i18n, gettext, lazy_gettext from aiogram.utils.i18n.context import get_i18n, gettext, lazy_gettext
from tests.conftest import DATA_DIR from tests.conftest import DATA_DIR
from tests.mocked_bot import MockedBot from tests.mocked_bot import MockedBot
@ -51,24 +56,24 @@ class TestI18nCore:
@pytest.mark.parametrize( @pytest.mark.parametrize(
"locale,case,result", "locale,case,result",
[ [
[None, dict(singular="test"), "test"], [None, {"singular": "test"}, "test"],
[None, dict(singular="test", locale="uk"), "тест"], [None, {"singular": "test", "locale": "uk"}, "тест"],
["en", dict(singular="test", locale="uk"), "тест"], ["en", {"singular": "test", "locale": "uk"}, "тест"],
["uk", dict(singular="test", locale="uk"), "тест"], ["uk", {"singular": "test", "locale": "uk"}, "тест"],
["uk", dict(singular="test"), "тест"], ["uk", {"singular": "test"}, "тест"],
["it", dict(singular="test"), "test"], ["it", {"singular": "test"}, "test"],
[None, dict(singular="test", n=2), "test"], [None, {"singular": "test", "n": 2}, "test"],
[None, dict(singular="test", n=2, locale="uk"), "тест"], [None, {"singular": "test", "n": 2, "locale": "uk"}, "тест"],
["en", dict(singular="test", n=2, locale="uk"), "тест"], ["en", {"singular": "test", "n": 2, "locale": "uk"}, "тест"],
["uk", dict(singular="test", n=2, locale="uk"), "тест"], ["uk", {"singular": "test", "n": 2, "locale": "uk"}, "тест"],
["uk", dict(singular="test", n=2), "тест"], ["uk", {"singular": "test", "n": 2}, "тест"],
["it", dict(singular="test", n=2), "test"], ["it", {"singular": "test", "n": 2}, "test"],
[None, dict(singular="test", plural="test2", n=2), "test2"], [None, {"singular": "test", "plural": "test2", "n": 2}, "test2"],
[None, dict(singular="test", plural="test2", n=2, locale="uk"), "test2"], [None, {"singular": "test", "plural": "test2", "n": 2, "locale": "uk"}, "test2"],
["en", dict(singular="test", plural="test2", n=2, locale="uk"), "test2"], ["en", {"singular": "test", "plural": "test2", "n": 2, "locale": "uk"}, "test2"],
["uk", dict(singular="test", plural="test2", n=2, locale="uk"), "test2"], ["uk", {"singular": "test", "plural": "test2", "n": 2, "locale": "uk"}, "test2"],
["uk", dict(singular="test", plural="test2", n=2), "test2"], ["uk", {"singular": "test", "plural": "test2", "n": 2}, "test2"],
["it", dict(singular="test", plural="test2", n=2), "test2"], ["it", {"singular": "test", "plural": "test2", "n": 2}, "test2"],
], ],
) )
def test_gettext(self, i18n: I18n, locale: str, case: Dict[str, Any], result: str): def test_gettext(self, i18n: I18n, locale: str, case: Dict[str, Any], result: str):
@ -110,8 +115,17 @@ class TestSimpleI18nMiddleware:
middleware = SimpleI18nMiddleware(i18n=i18n) middleware = SimpleI18nMiddleware(i18n=i18n)
middleware.setup(router=dp) middleware.setup(router=dp)
assert middleware not in dp.update.outer_middleware
assert middleware in dp.message.outer_middleware assert middleware in dp.message.outer_middleware
assert middleware in dp.callback_query.outer_middleware
async def test_setup_exclude(self, i18n: I18n):
dp = Dispatcher()
middleware = SimpleI18nMiddleware(i18n=i18n)
middleware.setup(router=dp, exclude={"message"})
assert middleware not in dp.update.outer_middleware
assert middleware not in dp.message.outer_middleware
assert middleware in dp.callback_query.outer_middleware
async def test_get_unknown_locale(self, i18n: I18n): async def test_get_unknown_locale(self, i18n: I18n):
dp = Dispatcher() dp = Dispatcher()
@ -131,6 +145,19 @@ class TestSimpleI18nMiddleware:
) )
assert locale == i18n.default_locale assert locale == i18n.default_locale
async def test_custom_keys(self, i18n: I18n):
async def handler(event, data):
return data
middleware = SimpleI18nMiddleware(
i18n=i18n, i18n_key="translator", middleware_key="middleware"
)
context: dict[str, Any] = await middleware(handler, None, {})
assert "translator" in context
assert context["translator"] == i18n
assert "middleware" in context
assert context["middleware"] == middleware
class TestConstI18nMiddleware: class TestConstI18nMiddleware:
async def test_middleware(self, i18n: I18n): async def test_middleware(self, i18n: I18n):
@ -138,13 +165,17 @@ class TestConstI18nMiddleware:
result = await middleware( result = await middleware(
next_call, next_call,
Update(update_id=42), Update(update_id=42),
{"event_from_user": User(id=42, is_bot=False, language_code="it", first_name="Test")}, {
"event_from_user": User(
id=42, is_bot=False, language_code="it", first_name="Test"
),
},
) )
assert result == "тест" assert result == "тест"
class TestFSMI18nMiddleware: class TestFSMI18nMiddleware:
async def test_middleware(self, i18n: I18n, bot: MockedBot): async def test_middleware(self, i18n: I18n, bot: MockedBot, extra):
middleware = FSMI18nMiddleware(i18n=i18n) middleware = FSMI18nMiddleware(i18n=i18n)
storage = MemoryStorage() storage = MemoryStorage()
state = FSMContext( state = FSMContext(
@ -160,3 +191,16 @@ class TestFSMI18nMiddleware:
assert i18n.current_locale == "uk" assert i18n.current_locale == "uk"
result = await middleware(next_call, Update(update_id=42), data) result = await middleware(next_call, Update(update_id=42), data)
assert result == "тест" assert result == "тест"
async def test_without_state(self, i18n: I18n, bot: MockedBot, extra):
middleware = FSMI18nMiddleware(i18n=i18n)
data = {
"event_from_user": User(id=42, is_bot=False, language_code="it", first_name="Test"),
}
result = await middleware(next_call, Update(update_id=42), data)
assert i18n.current_locale == "en"
assert result == "test"
assert i18n.current_locale == "en"
result = await middleware(next_call, Update(update_id=42), data)
assert i18n.current_locale == "en"

View file

@ -7,7 +7,11 @@ from aiogram.types import (
KeyboardButton, KeyboardButton,
ReplyKeyboardMarkup, ReplyKeyboardMarkup,
) )
from aiogram.utils.keyboard import InlineKeyboardBuilder, KeyboardBuilder, ReplyKeyboardBuilder from aiogram.utils.keyboard import (
InlineKeyboardBuilder,
KeyboardBuilder,
ReplyKeyboardBuilder,
)
class MyCallback(CallbackData, prefix="test"): class MyCallback(CallbackData, prefix="test"):

View file

@ -3,7 +3,11 @@ from typing import List, Optional
import pytest import pytest
from aiogram.types import MessageEntity, User from aiogram.types import MessageEntity, User
from aiogram.utils.text_decorations import TextDecoration, html_decoration, markdown_decoration from aiogram.utils.text_decorations import (
TextDecoration,
html_decoration,
markdown_decoration,
)
class TestTextDecoration: class TestTextDecoration: