modern-python / that-depends

DI-framework, inspired by python-dependency-injector, but without wiring. Python 3.12 is supported
https://that-depends.readthedocs.io/
MIT License
126 stars 9 forks source link

Переопределение зависимостей в тестах [question] #52

Closed nightblure closed 1 month ago

nightblure commented 1 month ago

Привет!

Хорошая работа, поставил звездочку. Сам делаю свою облегченную копию dependency-injector и хочу задать вопрос по поводу переопределения зависимостей в тестах

Немного контекста: пользуясь инжекторами мы вынуждены указывать параметрам функций дефолтные аргументы (обычно эти функции это хендлеры эндпоинтов). И как мы знаем, сначала происходит импорт всех файлов с кодом и функции в питоне создаются всего один раз, при этом туда "намертво" прикрепляются дефолтные значения типа param=Provide[SomeContainer.some_provider] или param=SomeContainer.some_provider

И вопрос следующий: есть ли в вашем фреймворке фича переопределения зависимостей? В dependency-injector этот метод называется override_providers (можно посмотреть пример кода в https://python-dependency-injector.ets-labs.org/examples/fastapi-sqlalchemy.html). Это чуть ли не основная фича ioc/di-контейнера имхо, но здесь я ее не нашел, возможно, недосмотрел. Нашел только метод переопределения у самого провайдера, но не понимаю зачем это нужно и в каких кейсах используется, если у вас нет фичи "связывания" или "wire" из того же dependency-injector. Wire здесь упомянут потому, что без него кажется мы никак не сможем переопределить в тестах нужные зависимости, потому что как я упомянул выше дефолтные значения аргументов были уже определены и их без условных вайров/патчей никак не подменить

lesnik512 commented 1 month ago

@nightblure привет, спасибо)

по идее здесь аналогично инжектору работает. Доку еще не написал, но можно в тестах посмотреть https://github.com/modern-python/that-depends/blob/main/tests/providers/test_providers_overriding.py

Получается в провайдер подкладывается мок и в последующих резолвингах будет отдаваться этот мок.

lesnik512 commented 1 month ago

override провайдером делать не стал, нам не нужно было такое. Туда только объект готовый можно положить, который вернется, как есть. Если нужно в райтайме собирать контейнер, то можно попробовать динамически добавить провайдеры https://github.com/modern-python/that-depends/blob/main/tests/test_dynamic_container.py Или selector использовать https://that-depends.readthedocs.io/providers/selector.html

nightblure commented 1 month ago

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

lesnik512 commented 1 month ago

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

nightblure commented 1 month ago

@lesnik512 привет)

Я немного потестил и пришел к выводу, что отчасти мой вопрос выше неактуален. Отчасти, потому что мой кейс вроде бы работает как ожидается, кроме случаев с синглтонами. И здесь либо у меня неправильные ожидания, либо что-то работает не так

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

from dataclasses import dataclass

from that_depends import Provide, BaseContainer, providers, inject

@dataclass
class Settings:
    redis_url: str = 'url_1'

class Redis:
    def __init__(self, url: str):
        self.url = url

class DIContainer(BaseContainer):
    MOCK_REDIS_URL = 'url_mock'

    settings = providers.Singleton(Settings)
    redis = providers.Singleton(Redis, url=settings.redis_url)  # CASES DOESNT WORK
    # redis = providers.Factory(Redis, url=settings.redis_url)  # CASES WORKS

def get_mock_settings():
    mock_settings = Settings(redis_url=DIContainer.MOCK_REDIS_URL)
    return mock_settings

@inject
def func(redis=Provide[DIContainer.redis]):
    return redis.url

def case_1():
    mock_settings = get_mock_settings()
    DIContainer.settings.override(mock_settings)

    redis_url = func()
    assert redis_url == DIContainer.MOCK_REDIS_URL

    DIContainer.settings.reset_override()

    redis_url = func()
    assert redis_url == 'url_1'  # ASSERTION ERROR

def case_2():
    assert DIContainer.redis.sync_resolve().url == 'url_1'

    mock_settings = get_mock_settings()
    DIContainer.settings.override(mock_settings)

    redis_url = func()
    assert redis_url == DIContainer.MOCK_REDIS_URL  # ASSERTION ERROR

if __name__ == '__main__':
    case_1()
    # case_2()
lesnik512 commented 1 month ago

@nightblure с ходу не понял) завтра попробую это в тест кейс оформить

lesnik512 commented 1 month ago

@nightblure понял, про что ты. Ушел думать)

lesnik512 commented 1 month ago

считаю, что это валидное поведение. Просто если что-то оверрайдишь, нужно еще tear_down делать, чтобы сбросить состояние контейнера

import typing
from dataclasses import dataclass

from that_depends import Provide, BaseContainer, providers, inject

DEFAULT_REDIS_URL: typing.Final = 'url_1'
MOCK_REDIS_URL: typing.Final = 'url_2'

@dataclass
class Settings:
    redis_url: str = DEFAULT_REDIS_URL

class Redis:
    def __init__(self, url: str):
        self.url = url

class DIContainer(BaseContainer):
    settings = providers.Singleton(Settings)
    redis = providers.Singleton(Redis, url=settings.redis_url)

@inject
def func(redis: Redis = Provide[DIContainer.redis]):
    return redis.url

async def test_case_1():
    DIContainer.settings.override(Settings(redis_url=MOCK_REDIS_URL))

    assert func() == MOCK_REDIS_URL

    DIContainer.settings.reset_override()
    await DIContainer.tear_down()

    assert func() == DEFAULT_REDIS_URL  # ASSERTION ERROR

async def test_case_2():
    DIContainer.reset_override()
    await DIContainer.tear_down()

    assert DIContainer.redis.sync_resolve().url == DEFAULT_REDIS_URL

    DIContainer.settings.override(Settings(redis_url=MOCK_REDIS_URL))
    await DIContainer.tear_down()

    redis_url = func()
    assert redis_url == MOCK_REDIS_URL  # ASSERTION ERROR
nightblure commented 1 month ago

да, все так) здесь требуется дополнительный явный "сброс" состояния. но это поведение показалось мне не очень логичным с пользовательской точки зрения по следующим причинам:


моя логика опирается только на нейминг методов, и я понимаю, что нейминг можно интерпретировать по-разному и я могу заблуждаться)

lesnik512 commented 1 month ago

Согласен, что кто-то может на этом подорваться) но делать автоматический tear down при overriding'е тоже не очень решение - провайдер не знает ничего о контейнере и других зависимостях.

Думаю, достаточно будет эти риски подсветить в доке по оверрайдингу.

nightblure commented 1 month ago

валидное решение) спасибо! по ишью тогда вопросов больше нет