8 января 2020 г.

Базовая настройка aiohttp.web приложений

Словил себя на мысли, что несмотря на то, что большинство моих веб-приложений работают на aiohttp.web, их настройка происходит в лучших Django традициях,

  1. Определение настроек в settings.py модуле, как DEBUG = to_bool(os.getenv("DEBUG") or False)
  2. Импорт целого модуля и сохранение его в aiohttp.web приложении, как types.MappingTypeProxy структура
  3. Использование сохраненного маппинга во вью-функциях, как, например, request.config_dict["settings"]["DEBUG"]

И если этот подход работал like a magic 5 лет назад, не говорит о том, что он идеально вписывается в Python в 2020 году. Главные проблемы с тем, как это было сделано ранее, это:

  • Инстинктивно хочется сделать from app import settings и потом просто settings.DEBUG, вместо того чтоб идти за настройками в request.config_dict
  • Работать с настройками, которые хранятся как Mapping[str, Any] — это выстрел себе в обе ноги. Никакой mypy не подскажет тебе, есть ли у тебя в настройках ключ VERY_IMPORTANT_KEY и не ошибься ли ты с тем, что считаешь, что PRICE_MULTIPLICATOR это decimal.Decimal, а не float.

Что же делать? Как обычно есть два пути, взять что-то готовое или написать свое. Второй путь, куда более заманчивый, поэтому план такой,

  1. Нужно хранить настройке в какой-то структуре данных, а что может быть лучше @attr.dataclass для этого?
  2. Нужно упростить получение настроек из окружения для attr.ib (аттрибутов) структуры данных
  3. Ну и куда же без mypy, так что нужно убедиться, что структура данных с настройками будет поддерживать любые типы данных и работать без магических # type: ignore

План рабочий, поэтому на его реализацию много времени не понадобилось. Начал с env_factory:

import os
from typing import Optional, overload, TypeVar

import attr
from rororo.settings import to_bool


T = TypeVar("T")


@overload
def env_factory(name: str) -> Optional[str]:
    ...


@overload
def env_factory(name: str, default: T) -> T:
    ...


def env_factory(name: str, default: T = None) -> Union[Optional[str], T]:
    def getenv() -> Union[Optional[str], T]:
        value = os.getenv(name)
        if default is None:
            return value

        if value is None:
            return default

        expected_type = type(default)
        if isinstance(value, expected_type):
            return value

        try:
            if expected_type is bool:
                return to_bool(value)  # type: ignore
            return expected_type(value)  # type: ignore
        except (TypeError, ValueError):
            raise ValueError(
                f"Unable to convert {name} env var to {expected_type}."
            )

    return attr.Factory(getenv)

Продолжил имплементацией хранения базовых настроек,

import attr

from rororo.settings import setup_locale, setup_logging, setup_timezone


@attr.dataclass(frozen=True, slots=True)
class BaseSettings:
    # Base aiohttp settings
    host: str = env_factory("AIOHTTP_HOST", "localhost")
    port: int = env_factory("AIOHTTP_PORT", 8080)

    # Base application settings
    debug: bool = env_factory("DEBUG", False)
    level: Level = env_factory("LEVEL", "dev")

    # Date & time settings
    time_zone: str = env_factory("TIME_ZONE", "UTC")

    # Locale settings
    first_weekday: int = env_factory("FIRST_WEEKDAY", 0)
    locale: str = env_factory("LOCALE", "en_US.UTF-8")

    # Sentry settings
    sentry_dsn: Optional[str] = env_factory("SENTRY_DSN")
    sentry_release: Optional[str] = env_factory("SENTRY_RELEASE")

    def apply(
        self,
        *,
        loggers: Iterable[str] = None,
        remove_root_handlers: bool = False,
    ) -> None:
        if loggers:
            setup_logging(
                default_logging_dict(*loggers),
                remove_root_handlers=remove_root_handlers,
            )

        setup_locale(self.locale, self.first_weekday)
        setup_timezone(self.time_zone)

И в конце концов осталось только начать использовать это все в create_app фабриках и во вью-функциях.

def create_app(argv: List[str] = None, **options: Any) -> web.Application:
     settings = Settings()
     settings.apply()

     app = web.Application(...)
     app["settings"] = settings

     return app
async def index(request: web.Request) -> web.Response:
     settings: Settings = request.config_dict["settings"]  # For JEDI autocomplete
     if settings.debug:
         print("Hello, world!")
     return web.json_response(True)

В итоге настройки становится проще инициализировать, переопределять, дебажить. А mypy говорит отдельное спасибо и точно оберегает от любых погрешностей, работая с базовым функционалом приложения.

ps. Но в заключение хочется отметить, что жаль, что web.Application не ожидает от пользователя никаких настроек и поэтому каждому разработчику приходится ломать голову в поисках того или иного решения для такой тривиальной задачи.

pss. Если вы заметили, то env_factory содержит два # type: ignore комментария. Хотел бы объяснить зачем они там. Первый на линии с to_bool вызовом из-за того, что mypy не понимает, что T is bool и поэтому выдает ошибку Incompatible return value type. Ну а во втором случае mypy ругается на Too many arguments for "object". Отакої

blog comments powered by Disqus