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". Отакої

10 марта 2019 г.

Сортировка списка с unicode strings

Думал, что после стольких лет Python уже не удивит меня, однако пословица Век живи, век учись стала для меня как никогда актуальной вчера. Задача была весьма простая: отсортировать список с строками, где строка - это украинское имя, то есть вроде бы все должно быть предельно просто используя Python 3.7.2:

data = ['Андрій', 'Ігор', 'Євген', 'Віталій']
assert sorted(data) == ['Андрій', 'Віталій', 'Євген', 'Ігор']

Но нет, not so fast!

$ python
Python 3.7.2 (default, Jan  4 2019, 12:23:06) 
[Clang 10.0.0 (clang-1000.10.44.4)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> data = ['Андрій', 'Ігор', 'Євген', 'Віталій']
>>> sorted(data)
['Євген', 'Ігор', 'Андрій', 'Віталій']

Интересно. Значит что-то не так с локалью. Нужно установить правильную локаль и попробовать еще раз.

$ python
Python 3.7.2 (default, Jan  4 2019, 12:23:06) 
[Clang 10.0.0 (clang-1000.10.44.4)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import locale
>>> locale.setlocale(locale.LC_ALL, 'uk_UA.UTF-8')
'uk_UA.UTF-8'
>>> locale.setlocale(locale.LC_COLLATE, 'uk_UA.UTF-8')
'uk_UA.UTF-8'
>>> data = ['Андрій', 'Ігор', 'Євген', 'Віталій']
>>> sorted(data)
['Євген', 'Ігор', 'Андрій', 'Віталій']

Хм. Странно. Значит нужно пойти почитать доку locale и найти там функцию, которая будет делать locale compare. Точно же есть такая.

$ python
Python 3.7.2 (default, Jan  4 2019, 12:23:06) 
[Clang 10.0.0 (clang-1000.10.44.4)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import locale
>>> locale.setlocale(locale.LC_ALL, 'uk_UA.UTF-8')
'uk_UA.UTF-8'
>>> locale.setlocale(locale.LC_COLLATE, 'uk_UA.UTF-8')
'uk_UA.UTF-8'
>>> data = ['Андрій', 'Ігор', 'Євген', 'Віталій']
>>> sorted(data, key=locale.strxfrm)
['Ігор', 'Євген', 'Андрій', 'Віталій']
>>> import functools
>>> sorted(data, key=functools.cmp_to_key(locale.strcoll))
['Ігор', 'Євген', 'Андрій', 'Віталій']

Та ладно. Не верю. Все должно работать. Аргх.

После того, как эмоции уляглись, вспоминаю про ICU и думаю, ну ок, точно есть биндинги ICU для Python и там должна быть функция, которую можно будет скормить в key для правильной сортировки значений в списке.

  1. Устанавливаем ICU в систему,
    • Для macOS:
      $ brew install icu4c
      $ export PATH="/usr/local/opt/icu4c/bin:$PATH"
    • Для Ubuntu Linux:
      # apt install libicu-dev icu-devtools
  2. Устанавливаем PyICU,
    $ poetry add PyICU
    или по старинке:
    $ pip install PyICU
$ python
Python 3.7.2 (default, Jan  4 2019, 12:23:06) 
[Clang 10.0.0 (clang-1000.10.44.4)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import icu
>>> collator = icu.Collator.createInstance(icu.Locale('uk_UA.UTF-8'))
>>> data = ['Андрій', 'Ігор', 'Євген', 'Віталій']
>>> sorted(data, key=collator.getSortKey)
['Андрій', 'Віталій', 'Євген', 'Ігор']

Фух! Работает! Так что да, чистая правда: век живи, век учись!

ps. В комментариях @xnull поделился еще одним способом сортировки при помощи pyuca библиотеки, которая не требует установки в систему никаких дополнительных зависимостей.

$ poetry add pyuca  # pip install pyuca
$ python
Python 3.7.2 (default, Jan  2 2019, 13:30:18) 
[Clang 10.0.0 (clang-1000.10.44.4)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import pyuca
>>> coll = pyuca.Collator()
>>> data = ['Євген', 'Ігор', 'Андрій', 'Віталій']
>>> sorted(data, key=coll.sort_key)
['Андрій', 'Віталій', 'Євген', 'Ігор']

16 июля 2018 г.

Настройка Sublime Text 3 для Python разработки

Выбор редактора (среды для разработки), наверное, главное событие в жизни каждого разработчика. Я тоже поначалу не мог долго определиться какой редактор использовать, но потом, лет 8-9 назад установил Sublime Text и все - вопрос с редактором был закрыт раз и навсегда. Да, за эти года я иногда пробовал научиться работать в vim, но не смог, ну а на новомодные Atom, PyCharm или VS Code я вообще не смотрел. Поэтому я хотел бы рассказать про то, как я использую Sublime Text 3 и почему мне не нужен IDE в 2018 году.

Главная причина, почему мне не нужен IDE - это, конечно, база расширений для Саблайма, которая находится на packagecontrol.io и покрывает любой из языков, на которых я программирую. Так как блог о Python, то я расскажу только о расширениях для Python, но у меня также есть установленные плагины для JavaScript, flow, TypeScript, Elm и Rust.

В Python же все начинается с установки MagicPython от Юрия Селиванова, расширения, которое добавляет поддержку подсветки всех всех новомодных фич Python, и хоть авторы Sublime Text пытаются улучшать поддержку базового Python от релиза к релизу, нам это не нужно после установки и настройки MagicPython.

Дальше. Было бы неплохо иметь автодополнение для Python, да? Поэтому следующим делом, устанавливаем SublimeJEDI от Сережи Русских. Благодаря движку JEDI, который используется в IPython, к примеру, на выходе мы получаем автодополнение нашей мечты. Но для этого в настройках проекта нам нужно сделать две вещи,

  1. Задать путь к python_interpreter, в общем случае это "~/Projects/<project>/.venv/bin/python", чтоб JEDI использовал все зависимости, установленные в проекте
  2. Настроить python_package_paths. Опять же в общем случае это ["~/Projects/<project>"]

Все, теперь мы получили автодополнение к любому import стейтменту и много других фич, опять же благодаря движку JEDI.

Идем дальше. Линтинг. Тут нам нужны SublimeLinter-flake8 и SublimeLinter-contrib-mypy (а куда без type annotations в 2018 то году?). По дефолту, они будут пытаться использовать стандартный Python, установленный в системе, но так как в моем случае набор flake8 плагинов от проекта к проекту отличается, я задаю executable для этих линтеров в файле настройки проекта (<project>.sublime-project) как,

  1. "SublimeLinter.linters.flake8.executable": "~/Projects/<project>/.venv/bin/flake8" для настройки SublimeLinter-flake8
  2. "SublimeLinter.linters.mypy.executable": "~/Projects/<project>/.venv/bin/mypy" для настройки SublimeLinter-contrib-mypy

Отдельно вы возможно захотите настроить сам SublimeLinter, но так или иначе про ошибки линтера мы узнаем куда быстрее и прямо в редакторе.

По большому счету с главными расширениями, которые превращают Sublime Text 3 в удобную среду разработки на Python мы закончили, но мне также хотелось бы упомянуть еще некоторые плагины, которые могут точно пригодиться,

  • sublack - автоформатирование Python файлов при помощи black. Нужная вещь, когда вы работаете в команде и не хотите на код ревью тратить время о спорах насчет форматирования Python кода.
  • editorconfig-sublimetext - также в 2018 году не зачем спорить о табах vs пробелах или отступах. Единократно кладем .editorconfig в корень проекта, а дальше Sublime Text 3 автоматически применяет необходимые настройки форматирования для любых файлов.
  • DocBlockr - упрощает работу с комментариями в коде. Конечно, этот плагин больше применим для JavaScript кода, но и для Python иногда бывает полезен.
  • GitGutter - просмотр того, как изменился блок кода, прямо в Sublime Text. Очень нужная вещь, для разработчиков, которые не любят бессмысленный закомментированный старый код.
  • Terminus - и хоть я постоянно держу открытым Terminal.app иногда бывает удобно открыть терминал прямо в соседней вкладке Sublime Text.

Ну и напоследок чуть о внешнем виде. Все это время я использую схему подсветки кода Tomorrow-Night, так что очень был рад, когда настроил Boxy Theme для внешнего вида Sublime Text, используя это же цветовое решение. В итоге, мой Sublime Text выглядит следующим образом,

А вот шаблон настроек, который я использую для своих проектов,

{
    "folders":
    [
        {
            "file_exclude_patterns": [
                ".install*",
                "*.lock",
                "*-lock.json"
            ],
            "folder_exclude_patterns": [
                ".*cache",
                "node_modules"
            ],
            "path": "/Users/playpauseandstop/Projects/<project>"
        }
    ],
    "settings": {
        "python_interpreter": "/Users/playpauseandstop/Projects/<project>/.venv/bin/python",
        "python_package_paths": [
            "/Users/playpauseandstop/Projects/<project>"
        ],
        "SublimeLinter.linters.flake8.executable": "/Users/playpauseandstop/Projects/<project>/.venv/bin/flake8",
        "SublimeLinter.linters.mypy.executable": "/Users/playpauseandstop/Projects/<project>/.venv/bin/mypy"
    }
}

Вот и получается, что Sublime Text 3 с таким набором расширений - это прекрасный редактор для программирования на Python.

1 июля 2018 г.

Fast Python. В поисках медленного кода при помощи стандартного профайлера

Думаю все часто сталкивались с ситуацией, когда бизнес приходит и говорит: спасайте, страница Х нашего прекрасного веб-приложения работает медленно. Или, создание репортов стало занимать в два раза больше времени после последнего деплоя. И хорошо, когда ты наверняка знаешь в чем причина и спокойно переходишь к оптимизации медленного кода, но что делать, если ты не уверен, где проблема? Вот про это мы сегодня и поговрим: использование стандартного профайлера для поиска медленного кода.

Сразу оговорюсь, что есть намного более универсальные библиотеки для профайлинга Python приложений, как например, vprof или pyflame, но я хотел бы рассказать именно про cProfile.

Итак, что надо сделать, чтоб отпрофилировать наш код? На самом деле просто запомнить, как запускать профайлер:

$ python -m cProfile -o script.prof script.py

Ну а в Python 3.7 все стало еще приятней, потому что появилась возможность задавать -m для профайлера как:

$ python -m cProfile -o project.prof -m project

После того как скрипт выполнится, мы получим script.prof файл, который теперь можно эксплорить при помощи KCacheGrind. Для этого нужно перевести его в формат, который понимает KCacheGrind, что делает тулза под названием: pyprof2calltree.

$ pyprof2calltree -i script.prof -k

переконвертирует script.prof в дерево вызовов и откроет приятный глазу интерфейс KCacheGrind.

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

  1. В начале project/__main__.py создаю профайлер и запускаю его, если в окружении есть переменная USE_PROFILER
  2. Оборачиваю web.run_app(...) в try/finally и в finally печатаю содержимое профайлера в файл
  3. Потом запускаю приложение как USE_PROFILER=1 python -m project
  4. И наконец делаю нагрузку на необходимый URL при помощи wrk, например как, wrk -t 4 -c 100 -d 30s --latency http://<URL>/api/slow
  5. Все теперь можно выключать приложение и запускать KCacheGrind

Если теперь перевести это все на код, то получится вот такой апдейт для project/__main__.py,

import cProfile
import os

from app import create_app


USE_PROFILER = os.environ.get('USE_PROFILER') == '1'
profiler = cProfiler.Profile()


if __name__ == '__main__':
    if USE_PROFILER:
        profiler.enable()

    try:
        sys.exit(web.run_app(create_app()))
    finally:
        if USE_PROFILER:
            filename = 'project.prof'
            print(f'Dumping profiler to {filename}')
            profiler.dump_stats(filename)
            profiler.disable()

В итоге, в KCacheGrind, находим интересующую вьюху (на моем скриншоте это retrieve_tweets) и ищем почему же она работает медленно.

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

28 июня 2017 г.

Fast Python. Парсинг ISO дат

Преобразование ISO-даты из строки в объект datetime.datetime (или datetime.date), наверное, одна из самых распространенных и постоянных задач в web-разработке на Python. Количество способов сделать это просто поражает воображение,

In [1]: value = '2017-06-28T16:59:27+0000'

In [2]: import datetime

In [3]: datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S%z')
Out[3]: datetime.datetime(2017, 6, 28, 16, 59, 27, tzinfo=datetime.timezone.utc)

In [4]: from dateutil.parser import parse

In [5]: parse(value)
Out[5]: datetime.datetime(2017, 6, 28, 16, 59, 27, tzinfo=tzutc())

In [6]: import dateparser

In [7]: dateparser.parse(value)
Out[7]: datetime.datetime(2017, 6, 28, 16, 59, 27, tzinfo=)

Использовались функции: datetime.datetime.strptime, dateutil.parser.parse и dateparser.parse.

А ведь еще есть,

In [8]: import arrow

In [9]: arrow.get(value, 'YYYY-MM-DDTHH:mm:ssZZ')
Out[9]: <Arrow [2017-06-28T16:59:27+00:00]>

In [10]: import maya

In [11]: maya.parse(value)
Out[11]: <MayaDT epoch=1498669167.0>

-//-: arrow.get и maya.parse.

И это только вершина айсберга, потому что библиотек для работ с датами в Python не просто много, а очень много (что говорить, если у Django вообще есть своя parse_datetime функция, основанная на регекспе). У той или иной библиотеки работы с датами есть свои плюсы и особенности, но что делать, если нужно быстро конвертировать строки с датами в стандартные объекты datetime.datetime? Как всегда ответ прост: использовать что-то написанное на C с биндингами в Python, в нашем случае - это ciso8601,

In [12]: ciso8601.parse_datetime(value)
Out[12]: datetime.datetime(2017, 6, 28, 16, 59, 27, tzinfo=)

Результаты %timeit - красноречивы,

In [13]: %timeit datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S%z')
17.9 µs ± 516 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

In [14]: %timeit parse(value)
99 µs ± 1.1 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)

In [15]: %timeit dateparser.parse(value)
1.61 ms ± 39.2 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

In [16]: %timeit ciso8601.parse_datetime(value)
2.08 µs ± 67.6 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

Итого: Если нужно быстро парсить ISO даты: используйте ciso8601.parse_datetime, если нужно быстро парсить не ISO даты: сначала попробуйте стандартный datetime.datetime.strptime, потом dateutil.parser.parse и только в случае особой и острой необходимости dateparser.parse и прочие библиотеки, основанные на нем.

ps. Результаты приведены для Python 3.6.1, на Python 2.7.13 соблюдаются такие же показатели, но в 2.7.13 в стандартном strptime не было возможности разобрать +0000, потому что ValueError: 'z' is a bad directive in format '%Y-%m-%dT%H:%M:%S%z'.