Главная страница
Навигация по странице:

  • Ещё о практических аспектах типизации Опциональные данные

  • Типизированный_Python_для_профессиональной_разработки. Профессиональной разработки


    Скачать 3.38 Mb.
    НазваниеПрофессиональной разработки
    АнкорPython
    Дата15.12.2022
    Размер3.38 Mb.
    Формат файлаpdf
    Имя файлаТипизированный_Python_для_профессиональной_разработки.pdf
    ТипДокументы
    #847528
    страница4 из 5
    1   2   3   4   5
    Использование интерфейсов и протоколов
    В теории объектно-ориентированного программирования есть понятия интерфейсов и абстрактных классов. Эти классы созданы для того, чтобы быть отнаследованными в других классах. Интерфейс и абстрактный класс созданы для того, чтобы показать, какими свойствами и методами должны обладать все их дочерние классы. Разница интерфейса и абстрактного класса в том, что интерфейс не содержит реализации, а абстрактный класс может помимо абстрактных методов содержать и часть реализованных методов.
    Использование интерфейсов и абстрактных классов — хорошая затея, если мы хотим заложить на будущее возможность замены компонентов системы на другие.
    Расширяемость системы это хорошо.
    Например, допустим, мы хотим реализовать сохранение истории всех запросов погоды. Чтобы при каждом запуске нашей программы куда-то сохранялись её
    результаты, ив будущем можно было проанализировать эту информацию.
    Куда мы можем сохранить эту информацию В плоский txt файл. В файл JSON. В базу данных SQL. В NoSQL базу данных. Отправить куда-то посетив какой-то веб- сервис. Вариантов много и потенциально в будущем возможно нам захочется заменить текущий выбранный вариант на какой-то другой. Давайте реализуем модуль history.py
    , который будет отвечать за сохранение истории
    Здесь
    WeatherStorage
    — это интерфейс в терминах объектно-ориентированного программирования, этот интерфейс который описывает те методы, которые обязательно должны присутствовать у любого хранилища погоды. Собственно говоря, у любого хранилища погоды должен быть как минимум метод save
    , который принимает на вход погоду, которую он должен сохранить.
    В интерфейсе
    WeatherStorage нет реализации (на то они интерфейс, он только объявляет метод save
    , который должен быть определён в любом классе,
    реализующем этот интерфейс.
    Функция save_weather будет вызываться более высокоуровневым управляющим кодом для сохранения погоды в хранилище. Эта функция принимает на вход погоду weather
    , которую надо сохранить, и реальный экземпляр хранилища storage
    , которое реализует интерфейс Чтобы показать, что метод save интерфейса не реализован, мы возбуждаем в нём исключение
    NotImplementedError
    , эта ошибка говорит о том, что вызываемый метод не реализован. Таким образом, если мы создадим хранилище, отнаследованное от этого интерфейса, не реализуем в нём метод save и вызовем его, то у нас упадёт в рантайме исключение Проблема такого подхода в том, что ошибка, относящаяся к проверке типов (всели методы интерфейса реализованы в наследующем его классе) падает только в рантайме. Хотелось бы, чтобы такая проверка выполнялась в IDE и статическим from weather_api_service import
    Weather class
    WeatherStorage
    :
    """Interface for any storage saving weather"""
    def save
    (
    self
    ,
    weather
    :
    Weather
    )
    ->
    None
    :
    raise
    NotImplementedError def save_weather
    (
    weather
    :
    Weather
    ,
    storage
    :
    WeatherStorage
    )
    ->
    None
    :
    """Saves weather in the storage"""
    storage save
    (
    weather
    )
    class
    PlainFileWeatherStorage
    (
    WeatherStorage
    ):
    pass srorage
    =
    PlainFileWeatherStorage
    ()
    storage save
    ()
    # Тут в runtime упадёт ошибка NotImplementedError
    анализатором кода, а не падала в рантайме. Наша задача, напомню, сделать так,
    чтобы до рантайма ошибки не доходили.
    Какой есть ещё вариант определения интерфейсов в Python? Есть вариант с использованием встроенного модуля ABC документация, созданного как раз для работы с такими абстрактными классами и интерфейсами:
    Экземпляр класса, наследующего таким образом объявленный интерфейс, не получится создать без явной реализации всех методов, объявленных с декоратором. То есть вот такой код в runtime упадёт сразу в момент создания экземпляра такого класса:
    Опять же — код падает в runtime, пользователи видят ошибку, плохо. Как перенести проверку на корректность использования интерфейсов и абстрактных классов на и статический анализатор кода?
    Способ появился в Python 3.8 благодаря
    PEP 544
    , ион называется протоколами abc import
    ABC
    ,
    abstractmethod class
    WeatherStorage
    (
    ABC
    ):
    """Interface for any storage saving weather"""
    @abstractmethod def save
    (
    self
    ,
    weather
    :
    Weather
    )
    ->
    None
    :
    pass class
    PlainFileWeatherStorage
    (
    WeatherStorage
    ):
    pass
    # Тут упадет ошибка в рантайме, так как вне определен метод save storage
    =
    PlainFileWeatherStorage
    ()

    Воу! Класс
    PlainFileWeatherStorage никак не связан сне отнаследован от него, хотя и реализует его интерфейс в неявном виде, то есть просто определяет все функции, которые должны быть реализованы в этом интерфейсе. Сам интерфейс
    WeatherStorage отнаследован от класса что делает его так называемым протоколом. В функции save_weather тип аргумента storage по-прежнему установлен в этот интерфейс Получается, что класс
    PlainFileWeatherStorage неявно реализует протокол/
    интерфейс
    WeatherStorage
    . Если выработали с языком программирования Go — в нём интерфейсы реализованы схожим образом, это так называемая структурная типизация
    Почему использование такого подхода в приоритете Потому что проверкой корректности использования интерфейсов занимается IDE и статический анализатор кода вроде mypy
    . Речь идёт уже не о проверке в runtime, речь идет о проверке корректности реализации до этапа, в котором участвуют пользователи программы.
    Это то, что нам нужно!
    Таким образом, наш модуль history.py принимает следующий вид typing import protocol class
    WeatherStorage
    (
    Protocol
    ):
    """Interface for any storage saving weather"""
    def save
    (
    self
    ,
    weather
    :
    Weather
    )
    ->
    None
    :
    pass class
    PlainFileWeatherStorage
    :
    def save
    (
    self
    ,
    weather
    :
    реализация сохранения погоды save_weather
    (
    weather
    :
    Weather
    ,
    storage
    :
    WeatherStorage
    )
    ->
    None
    :
    """Saves weather in the storage"""
    storage save
    (
    weather
    )

    PlainFileWeatherStorage это реализованное хранилище, отнаследованное от нашего интерфейса, то есть реализующее его методы. Помимо метода save этот класс реализует ещё конструктор, который сохраняет в поле self._file путь до файла, в который будет записываться информация о погоде.
    Для перевода объекта погоды типа
    Weather в строку используется функция format_weather
    , которую мы реализовали ранее в модуле Этот код — абсолютно валиден сточки зрения проверки системы типов.
    Вызовем теперь логику сохранения погоды в главном файле weather
    :
    from datetime import datetime from pathlib import
    Path from typine import
    Protocol from weather_api_service import
    Weather from weather_formatter import format_weather class
    WeatherStorage
    (
    Protocol
    ):
    """Interface for any storage saving weather"""
    def save
    (
    self
    ,
    weather
    :
    Weather
    )
    ->
    None
    :
    raise
    NotImplementedError class
    PlainFileWeatherStorage
    :
    """Store weather in plain text file"""
    def
    __init__
    (
    self
    ,
    file
    :
    Path
    ):
    self
    _file
    =
    file def save
    (
    self
    ,
    weather
    :
    Weather
    )
    ->
    None
    :
    now
    =
    datetime now
    ()
    formatted_weather
    =
    format_weather
    (
    weather
    )
    with open
    (
    self
    _file
    ,
    "a"
    )
    as f
    :
    f write
    (
    f"
    {
    now
    }
    \n
    {
    formatted_weather
    }
    \n"
    )
    def save_weather
    (
    weather
    :
    Weather
    ,
    storage
    :
    WeatherStorage
    )
    ->
    None
    :
    """Saves weather in the storage"""
    storage save
    (
    weather
    )
    Здесь мы создаём экземпляр объекта
    PlainFileWeatherStorage и передаём его на вход функции save_weather
    . Всё работает python3.10
    from pathlib import
    Path from exceptions import
    ApiServiceError
    ,
    CantGetCoordinates from coordinates import get_gps_coordinates from history import
    PlainFileWeatherStorage
    ,
    save_weather from weather_api_service import get_weather from weather_formatter import format_weather def main
    ():
    try
    :
    coordinates
    =
    get_gps_coordinates
    ()
    except
    Не смог получить GPS координаты exit
    (
    1
    )
    try
    :
    weather
    =
    get_weather
    (
    coordinates
    )
    except
    Не смог получить погоду в API сервиса погоды exit
    (
    1
    )
    save_weather
    (
    weather
    ,
    PlainFileWeatherStorage
    (
    Path cwd
    ()
    /
    "history.txt"
    )
    )
    print
    (
    format_weather
    (
    weather
    ))
    if
    __name__
    ==
    "__main__"
    :
    main
    ()
    Для вывода содержимого текстового файла на скриншоте вместо cat использовался bat
    — продвинутый вариант
    :)
    Теперь, если мы захотим изменить хранилище, мы можем создать новое хранилище,
    например, JSON хранилище, реализовав в нём все методы интерфейса, и передать это новое хранилище в save_weather
    . Всё продолжит работать и будет корректно сточки зрения типов. Причём нам не придётся ничего менять в функции save_weather
    , так как она опирается только на интерфейс,
    определённый в классе
    WeatherStorage
    history.py
    , добавленный код:
    Здесь мы воспользовались структурой
    TypedDict
    , типизированным словарём. Это удобно для нашего сценария, так как каждая запись погоды в JSON файл будет представлять собой как раз структуру словаря, состоящую из двух полей — date для даты и времени получения погоды и weather для описания погоды. Метод предназначен для чтения данных погоды из JSON файла ион возвращает не list[dict]
    , а list[HistoryRecord]
    , максимально конкретный тип данных. Аналогично метод
    _write принимает в качестве аргумента не list[dict]
    , а import json from typing import
    Protocol
    ,
    TypedDict class
    HistoryRecord
    (
    TypedDict
    ):
    date
    :
    str weather
    :
    str class
    JSONFileWeatherStorage
    :
    """Store weather in JSON file"""
    def
    __init__
    (
    self
    ,
    jsonfile
    :
    Path
    ):
    self
    _jsonfile
    =
    jsonfile self
    _init_storage
    ()
    def save
    (
    self
    ,
    weather
    :
    Weather
    )
    ->
    None
    :
    history
    =
    self
    _read_history
    ()
    history append
    ({
    "date"
    :
    str
    (
    datetime now
    ()),
    "weather"
    :
    format_weather
    (
    weather
    )
    })
    self
    _write
    (
    history
    )
    def
    _init_storage
    (
    self
    )
    ->
    None
    :
    if not self
    _jsonfile exists
    ():
    self
    _jsonfile write_text
    (
    "[]"
    )
    def
    _read_history
    (
    self
    )
    ->
    list
    [
    HistoryRecord
    ]:
    with open
    (
    self
    _jsonfile
    ,
    "r"
    )
    as f
    :
    return json load
    (
    f
    )
    def
    _write
    (
    self
    ,
    history
    :
    list
    [
    HistoryRecord
    ])
    ->
    None
    :
    with open
    (
    self
    _jsonfile
    ,
    "w"
    )
    as f
    :
    json dump
    (
    history
    ,
    f
    ,
    ensure_ascii
    =
    False
    ,
    indent
    =
    4
    )
    тоже list[HistoryRecord]
    . Везде используем максимально точную конкретную структуру данных, изменённый код:
    Всё работает:
    В процессе сохранения файла тоже может возникнуть ошибка. Например,
    директория может быть закрыта для записей и тд. Такие ошибки тоже нужно обработать. Напишите эту обработку самостоятельно в качестве тренировки history import
    JSONFileWeatherStorage
    ,
    save_weather def main
    ():
    # пропущено save_weather
    (
    weather
    ,
    JSONFileWeatherStorage
    (
    Path cwd
    ()
    /
    "history.json"
    )
    )
    print
    (
    format_weather
    (
    weather
    ))
    if
    __name__
    ==
    "__main__"
    :
    main
    ()
    Анализ получившейся архитектуры кода
    Давайте посмотрим свежим взглядом на получившуюся архитектуру кода.
    Мы имеем 4 слоя приложения Модуль weather
    , запускающий приложение и связывающий остальные слои.
    Важно обратить внимание этот файл не содержит никакой логики реализации,
    никакой бизнес-логики. Это точка входа в приложение. Она не знает ничего о деталях реализации всех остальных нижележащих слоёв приложения Модуль gps_coordinates отвечает за получение координат из внешней команды whereami
    . Сюда инкапсулирована вся логика по работе с координатами. Эта логика ничего не знает о том, для чего эти координаты будут использованы затем в приложении. Модуль определяет структуру данных для хранения и передачи в приложение координат.
    Если нам понадобится получать координаты откуда-то иначе — мы перепишем логику этого модуля, никак не затронув все остальные модули приложения.
    Связь этого модуля с остальными — слабая, и это хорошо Модуль weather_api_service инкапсулирует в себе логику получения погоды по координатам. Он не знает, откуда были получены координаты, поступившие в этот модуль. Он не знает, как будет использоваться погода дальше в приложении. В этом модуле определена структура для хранения и передачи данных погоды в приложение.
    Если нам понадобится получать погоду в другом API сервисе — мы заменим логику этого модуля и это никак не затронет остальные модули приложения.
    Связь этого модуля с остальными — слабая, и это хорошо Модуль weather_formatter отвечает за преобразование погоды в строку. Он ничего не знает о том, откуда погода была получена, была ли она получена по координатам GPS или по названию населённого пункта или как-то иначе, он не знает ничего кроме того, как преобразовать данные погоды в строку, всё. Связь этого модуля с остальными слабая, и это хорошо. В любой момент мы можем изменить логику форматирования данных погоды (добавив вне иконки погоды, например, никак не затронув при этом все остальные модули приложения Модуль history инкапсулирует в себе логику по сохранению истории погоды.
    Этот модуль также независим от остальных модулей. Более того, реализована
    гибкая схема смены хранилища на любое другое через механизм интерфейсов.
    Ответственность зато, какое хранилище будет использовано для сохранения данных, лежит вовне этого модуля. Можно, например, данные погоды днём сохранять в текстовый плоский TXT файла данные ночной погоды — в Для этого не придётся ничего менять в самом модуле history
    . Чем меньше поводов менять код какого-то модуля, класса, функции — тем лучше.
    Получается, что мы реализовали всё приложение в виде слабозависимых друг от друга модулей. При этом эти модули могут использоваться ив составе других приложений, они reusable, то есть переиспользуемые. Скажем, модуль получения координат может использоваться в программе вычисления расстояния от текущей точки, где мы находимся, до Рима. Почему нет. Для этого не понадобится изменять этот модуль. Отлично!
    Если мы откроем код любого модуля, любой функции — нам сразу станет понятно,
    какие данные принимаются на входи какие возвращаются на выход, причём понятно максимально точно. Это Облегчает чтение кода — все типы данных в явном виде и максимально конкретно прописаны, не надо их предугадывать Гарантируется отсутствие ошибок использования типов — IDE подсветит, если мы что-то используем не так, как нужно также на ошибки укажет инструмент статического анализа вроде mypy
    , о котором мы поговорим ниже IDE поможет писать код всем, использующим наши разработанные модули.
    Будет работать автодополнение по полями методам классов с учетом типов,
    которые мы указали.
    Финальный вариант исходного кода программы размещён на Github:
    https://github.com/alexey-goloburdin/weather
    Статические анализаторы mypy и pyright
    mypy это инструмент, который устанавливается отдельно как pip пакет и запускается в проекте как часть тестов или CI/CD процесса. Перед сборкой и раскаткой приложения на сервер запускается проверка исходного Python кода си если mypy находит ошибки, то процесс останавливается, разработчики исправляют найденные ошибки и процесс повторяется. Это приводит к тому, что до продакшн, то есть до рантайма и до живых пользователей соответственно ошибок долетает меньше, потому что многое выявляется на более ранних этапах.
    В директории проекта создадим и активируем виртуальное окружение, установим в него mypy и запустим проверку нашего кода:
    Как видим, mypy не нашёл проблем в нашем коде. Внесём специально ошибку в код и убедимся, что mypy её найдёт:
    python3.10 -m venv env
    ./env/bin/activate pip install mypy mypy ./weather
    Запуск mypy можно встроить в процесс CI/CD, чтобы процесс разворачивания приложения на серверах не запускался, если проверки mypy не прошли. Таким образом доне смогут дойти ошибки, связанные с некорректным использованием типов данных, и это здорово — надёжность приложения значительно возрастает!
    И ещё важно отметить, что используя mypy, вы можете проверять корректность своих тайп хинтингов, которые вы указали. Пока учишься могут быть вопросы,
    правильно ли указан тип — вот можно указать типу параметра функции, вызвать эту функцию сданными и посмотреть, как поведёт себя проверятор типов, встроенный в, и как поведёт себя Помимо mypy пользуется популярностью анализатор pyright
    . Они работают по- разному, например, такой код валиден сточки зрения mypy
    (и сточки зрения анализатора, встроенного в PyCharm IDE, но невалиден сточки зрения Анализаторы кода продолжают развиваться и дорабатываться, они неидеальны, но проставлять подсказки типов — обязательное условие для серьёзных проектов.
    Анализаторы кода достаточно умны уже сейчас и станут ещё умнее в будущем self name
    :
    str
    =
    "Petr"
    def yo
    (
    self
    ):
    self name
    =
    {}

    Ещё о практических аспектах типизации
    Опциональные данные
    Для указания опциональных данных можно пользоваться вертикальной чертой:
    Здесь параметр name функции print_hello является опциональным, что отражено а) в type hinting (напомню, вертикальная черта в подсказках типов означает ИЛИ) б)
    задано значение по умолчанию Контейнеры — Iterable, Sequence, Mapping и другие
    Как указать тип для контейнера сданными, например, для списка юзеров?
    До python3.10 список для указания типа надо было импортировать из typing
    , но сейчас можно list не импортировать и просто сразу использовать, что удобно. То def print_hello
    (
    name
    :
    str
    |
    None
    =
    None
    )
    ->
    None
    :
    print
    (
    f"hello,
    {
    name
    }
    "
    if name is not
    None else
    "hello anon!"
    )
    from datetime import datetime from dataclasses import dataclass
    @dataclass class
    User
    :
    birthday
    :
    datetime users
    =
    [
    User
    (
    birthday
    =
    datetime fromisoformat
    (
    "1988-01-01"
    )),
    User
    (
    birthday
    =
    datetime fromisoformat
    (
    "1985-07-29"
    )),
    User
    (
    birthday
    =
    datetime fromisoformat
    (
    "2000-10-10"
    ))
    ]
    def get_younger_user
    (
    users
    :
    list
    [
    User
    ])
    ->
    User
    :
    if not users
    :
    raise
    ValueError
    (
    "empty users!"
    )
    sorded_users
    =
    sorted
    (
    users
    ,
    key
    =
    lambda x
    :
    x birthday
    )
    return sorded_users
    [
    0
    ]
    print
    (
    get_younger_user
    (
    users
    ))
    # User(birthday=datetime.datetime(1985, 7, 29, 0, 0))
    есть Python продолжает движение в сторону ещё более простого и удобного использования подсказок типов.
    Обратите внимание — технически можно указать просто users: list
    , но тогда IDE и статический анализатор кода вроде mypy не будут знать, что находится внутри этого списка, и это нехорошо. Мы же изначально знаем, что там именно тип данных объекты класса
    User
    , и, значит, это надо в явном виде указать.
    Так, отлично, а давайте подумаем, а обязательно ли функция поиска самого молодого юзера должна принимать на вход именно список юзеров? Ведь по сути главное, чтобы просто можно было проитерироваться по пользователям. Может, мы захотим потом передать сюда не список пользователей, а кортеж с пользователями,
    или еще что-то? Если мы передадим вместо списка кортеж — будет ошибка типов сейчас:
    Код работает (повторимся, что интерпретатор не проверяет типы в type hinting), но проверка типов в редакторе (и mypy
    ) ругается, это нехорошо.
    Если мы посмотрим документацию по функции sorted
    , то увидим, что первый элемент там назван iterable, то есть итерируемый, то, почему можно проитерироваться. То есть мы можем передать любую итерируемую структуру datetime import datetime from dataclasses import dataclass
    @dataclass class
    User
    :
    birthday
    :
    datetime users
    =
    (
    # сменили на tuple
    User
    (
    birthday
    =
    datetime fromisoformat
    (
    "1988-01-01"
    )),
    User
    (
    birthday
    =
    datetime fromisoformat
    (
    "1985-07-29"
    )),
    User
    (
    birthday
    =
    datetime fromisoformat
    (
    "2000-10-10"
    ))
    )
    def get_younger_user
    (
    users
    :
    list
    [
    User
    ])
    ->
    Возвращает самого молодого пользователя из списка sorded_users
    =
    sorted
    (
    users
    ,
    key
    =
    lambda x
    :
    x birthday
    )
    return sorded_users
    [
    0
    ]
    print
    (
    get_younger_user
    (
    users
    ))
    # тут видна ошибка в pyright!
    И теперь всё в порядке. Мы можем передать любую итерируемую структуру,
    элементами которой являются экземпляры А если нам надо обращаться внутри функции по индексу к элементам последовательности Подойдёт ли
    Iterable
    ? Нет, так как
    Iterable подразумевает возможность итерироваться по контейнеру, то есть обходить его в цикле, но это не предполагает обязательной возможности обращаться по индексу. Для этого есть
    Sequence
    :
    Теперь всё в порядке. В
    Sequence можно обращаться к элементам по индексу.
    Ещё один важный вопрос тут. А зачем использовать
    Iterable или
    Sequence
    , если можно просто перечислить разные типы контейнеров Ну их же ограниченное количество — там list
    , tuple
    , set
    , dict.
    Для чего нам тогда общие типы
    Iterable и
    Sequence
    ?
    На самом деле таких типов контейнеров, по которым можно итерироваться, вовсе неограниченное число. Например, можно создать свой контейнер, по которому можно будет итерироваться, но при этом этот тип не будет наследовать ничего из вышеперечисленного типа list
    , dict и тп:
    from typing import
    Iterable def get_younger_user
    (
    users
    :
    Iterable
    [
    User
    ])
    ->
    User
    |
    None
    :
    if not users
    :
    return
    None sorded_users
    =
    sorted
    (
    users
    ,
    key
    =
    lambda x
    :
    x birthday
    )
    return sorded_users
    [
    0
    ]
    from typing import
    Sequence def get_younger_user
    (
    users
    :
    Sequence
    [
    User
    ])
    ->
    User Возвращает самого молодого пользователя из списка not users
    :
    return
    None print
    (
    users
    [
    0
    ])
    sorded_users
    =
    sorted
    (
    users
    ,
    key
    =
    lambda x
    :
    x birthday
    )
    return sorded_users
    [
    0
    ]
    Способов создать такую структуру, по которой можно итерироваться или обращаться по индексам, в Python много, это один из способов. Важно просто понимать, что если вам надо показать структуру, по которой, например, можно итерироваться, тоне стоит ограничивать набор таких структур простым перечислением списка, кортежа и чего-то ещё. Используйте обобщённые типы,
    созданные специально для этого, например,
    Iterable или
    Sequence
    , потому что они покроют действительно всё, в том числе и свои кастомные (самописные)
    реализации контейнеров.
    Ну и напоследок — как определить тип словаря, ключами которого являются строки,
    а значениями, например, объекты типа И также, если нет смысла ограничиваться именно словарём и подойдёт любая структура, к которой можно обращаться по ключам — то есть обобщённый тип typing import
    Sequence class
    Users
    :
    def
    __init__
    (
    self
    ,
    users
    :
    Sequence
    [
    User
    ]):
    self
    _users
    =
    users def
    __getitem__
    (
    self
    ,
    key
    :
    int
    )
    ->
    User
    :
    return self
    _users
    [
    key
    ]
    users
    =
    Users
    ((
    # сменили на tuple
    User
    (
    birthday
    =
    datetime fromisoformat
    (
    "1988-01-01"
    )),
    User
    (
    birthday
    =
    datetime fromisoformat
    (
    "1985-07-29"
    )),
    User
    (
    birthday
    =
    datetime fromisoformat
    (
    "2000-10-10"
    ))
    ))
    for u in users
    :
    print
    (
    u
    )
    some_users_dict
    :
    dict
    [
    str
    ,
    User
    ]
    =
    {
    "alex"
    :
    User
    (
    birthday
    =
    datetime fromisoformat
    (
    "1990-01-01"
    )),
    "petr"
    :
    User
    (
    birthday
    =
    datetime fromisoformat
    (
    "1988-10-23"
    ))
    }

    1   2   3   4   5


    написать администратору сайта