Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/introduction/design-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ Each cache item, the cached instance of one `Factory` in one container, has its
shared by the whole container tree, so an override set on any container is
seen by every container in it. Set overrides during setup, never from
competing threads.
- Closing one container concurrently is unsupported: never overlap two
`close_async()` calls on it, or call `close_sync()` while a `close_async()`
is still running.
- Free-threaded CPython (PEP 703) is supported at `2 - Beta`. It is tested
under real multithreading on the `3.14t` build. It is Beta rather than Stable
for one specific reason: modern-di relies on object-publication ordering
Expand Down
4 changes: 2 additions & 2 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ adr-check:
test *args:
uv run --no-sync pytest {{ args }}

# Run the thread_race tests only, collecting just the files that hold them. Passes args through.
# Run the thread_race tests only, selected by marker across the whole suite. Passes args through.
test-race *args:
uv run --no-sync pytest -m thread_race tests/test_free_threading.py tests/providers/test_cached_factory.py tests/registries/test_providers_registry.py {{ args }}
uv run --no-sync pytest -m thread_race {{ args }}

# The gated full run: 100% line and branch coverage of modern_di required. CI runs this.
test-ci:
Expand Down
71 changes: 71 additions & 0 deletions tests/providers/test_cached_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -817,3 +817,74 @@ class SlowGroup(Group):
await container.close_async()

assert events == ["second", "first-cancelled", "first"]


def test_app_singleton_resolved_through_a_child_is_finalized_by_the_app_container() -> None:
calls: list[_First] = []

class G(Group):
first = providers.Factory(creator=_First, cache=providers.CacheSettings(finalizer=calls.append))

app_container = Container(groups=[G])
request_container = app_container.build_child_container(scope=Scope.REQUEST)
instance = request_container.resolve(_First)

request_container.close_sync()

assert calls == []
assert app_container.resolve(_First) is instance
app_container.close_sync()
assert calls == [instance]


async def test_async_finalizers_left_by_close_sync_run_newest_first() -> None:
events: list[str] = []

async def finalize_first(_: _First) -> None:
events.append("first")

async def finalize_second(_: _Second) -> None:
events.append("second")

class G(Group):
first = providers.Factory(creator=_First, cache=providers.CacheSettings(finalizer=finalize_first))
second = providers.Factory(creator=_Second, cache=providers.CacheSettings(finalizer=finalize_second))

container = Container(groups=[G])
container.resolve(_First)
container.resolve(_Second)

with pytest.raises(FinalizerError):
container.close_sync()
await container.close_async()

assert events == ["second", "first"]


async def test_close_async_after_a_cancelled_close_does_not_refinalize_a_clear_cache_false_instance() -> None:
calls: list[str] = []

async def blocking_finalizer(_: _First) -> None:
if "first-cancelled" not in calls:
calls.append("first-cancelled")
await asyncio.Event().wait()
calls.append("first")

async def keep_finalizer(_: _Second) -> None:
calls.append("second")

class G(Group):
first = providers.Factory(creator=_First, cache=providers.CacheSettings(finalizer=blocking_finalizer))
second = providers.Factory(
creator=_Second, cache=providers.CacheSettings(clear_cache=False, finalizer=keep_finalizer)
)

container = Container(groups=[G])
container.resolve(_First)
container.resolve(_Second)

with pytest.raises(TimeoutError):
await asyncio.wait_for(container.close_async(), timeout=0.05)
await container.close_async()

assert calls == ["second", "first-cancelled", "first"]
3 changes: 3 additions & 0 deletions tests/test_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import typing
from concurrent.futures import ThreadPoolExecutor

import pytest

from modern_di.cache import CacheItem, close_async, fetch_cache_item
from modern_di.providers import CacheSettings, Factory
from modern_di.types import UNSET
Expand Down Expand Up @@ -112,6 +114,7 @@ def test_each_cache_item_owns_its_lock() -> None:
assert first.lock is not second.lock


@pytest.mark.thread_race
def test_concurrent_fetches_of_one_provider_share_one_item() -> None:
n = 8
cache_items: dict[int, CacheItem] = {}
Expand Down
Loading