Skip to content
Merged

v4 #210

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
6 changes: 4 additions & 2 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ install:
lint:
uv run ruff format
uv run ruff check --fix
uv run mypy .
uv run mypy . --disable-error-code=unused-ignore
uv run pyrefly check --no-progress-bar

lint-ci:
uv run ruff format --check
uv run ruff check --no-fix
uv run mypy .
uv run mypy . --disable-error-code=unused-ignore
uv run pyrefly check --no-progress-bar

test *args:
uv run --no-sync pytest {{ args }}
Expand Down
92 changes: 92 additions & 0 deletions docs/migration/v4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Migrating from 3.\* to 4.\*

## How to Read This Guide

This guide is intended to help you migrate existing functionality from `that-depends` version `3.*` to `4.*`.
The goal is to enable you to migrate as quickly as possible while making only the minimal necessary changes to your codebase.

This migration intentionally focuses only on the **simplest change needed to preserve existing behaviour**.

If you want to learn more about the new internals introduced in `4.*`, please refer to the [documentation](https://that-depends.readthedocs.io/) and the [release notes](https://github.com/modern-python/that-depends/releases).

---

## Changes in the API

### **Collection providers now return read-only container types**

In `4.*`, collection providers no longer resolve to mutable built-in containers:

- `providers.List(...)` now resolves to a `Sequence` implemented as a `tuple`
- `providers.Dict(...)` now resolves to a `Mapping` implemented as a `mappingproxy`

If your existing code only reads from these values, you likely do not need to change anything.

If your existing code mutates the resolved value, the **simplest way to preserve the old behaviour** is to convert the result at the call site:

```python
items = list(MyContainer.items.resolve_sync())
mapping = dict(MyContainer.mapping.resolve_sync())
```

---

## Behaviour-Preserving Migration Examples

### **`providers.List(...)`**

Previously in `3.*`, code like this returned a `list`:

```python
items = MyContainer.items.resolve_sync()
items.append("new-item")
```

In `4.*`, `items` is a read-only sequence, so mutating it directly will no longer work.

To preserve the previous behaviour, change it to:

```python
items = list(MyContainer.items.resolve_sync())
items.append("new-item")
```

The same applies to async resolution:

```python
items = list(await MyContainer.items.resolve())
items.append("new-item")
```

---

### **`providers.Dict(...)`**

Previously in `3.*`, code like this returned a mutable `dict`:

```python
mapping = MyContainer.mapping.resolve_sync()
mapping["extra"] = "value"
```

In `4.*`, `mapping` is a read-only mapping, so item assignment will no longer work.

To preserve the previous behaviour, change it to:

```python
mapping = dict(MyContainer.mapping.resolve_sync())
mapping["extra"] = "value"
```

The same applies to async resolution:

```python
mapping = dict(await MyContainer.mapping.resolve())
mapping["extra"] = "value"
```

---

## Further Help

If you continue to experience issues during migration, consider creating a [discussion](https://github.com/modern-python/that-depends/discussions) or opening an [issue](https://github.com/modern-python/that-depends/issues).
2 changes: 1 addition & 1 deletion docs/overrides/main.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{% extends "base.html" %}

{% block announce %}
<strong>that-depends</strong> 3.0 just released! If you are upgrading, check out the <a href="{{config.repo}}/migration/v3/">migration guide</a>.
<strong>that-depends</strong> 4.0 just released! If you are upgrading, check out the <a href="{{config.repo}}/migration/v4/">migration guide</a>.
{% endblock %}
8 changes: 4 additions & 4 deletions docs/providers/collections.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ There are several collection providers: `List` and `Dict`

## List
- List provider contains other providers.
- Resolves into list of dependencies.
- Resolves into an immutable sequence of dependencies.

```python
import random
Expand All @@ -16,12 +16,12 @@ class DIContainer(BaseContainer):


DIContainer.numbers_sequence.resolve_sync()
# [0.3035656170071561, 0.8280498192037787]
# (0.3035656170071561, 0.8280498192037787)
```

## Dict
- Dict provider is a collection of named providers.
- Resolves into dict of dependencies.
- Resolves into a read-only mapping of dependencies.

```python
import random
Expand All @@ -34,5 +34,5 @@ class DIContainer(BaseContainer):


DIContainer.numbers_map.resolve_sync()
# {'key1': 0.6851384528299208, 'key2': 0.41044920948045294}
# mappingproxy({'key1': 0.6851384528299208, 'key2': 0.41044920948045294})
```
4 changes: 2 additions & 2 deletions docs/providers/context-resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
To interact with both types of contexts, there are two separate interfaces:

1. Use the `container_context()` context manager to interact with the global context and manage `ContextResource` providers.
2. Directly manage a `ContextResource` context by using the `SupportsContext` interface, which both containers
2. Directly manage a `ContextResource` context by using the `SupportsContext` protocol, which both containers
and `ContextResource` providers implement.

---
Expand Down Expand Up @@ -185,7 +185,7 @@ async with container_context(MyContainer.async_resource):
...
```

It is not necessary to use `container_context()` to do this. Instead, you can use the `SupportsContext` interface described
It is not necessary to use `container_context()` to do this. Instead, you can use the `SupportsContext` protocol described
[here](#quick-reference).

### Context Hierarchy
Expand Down
2 changes: 2 additions & 0 deletions examples/benchmark/RESULTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ Based on this [benchmark](injection.py):

| version / iterations | 10^4 | 10^5 | 10^6 |
|----------------------|--------|--------|---------|
| 4.0 | 0.0399 | 0.3950 | 3.8107 |
| 3.9.2 | 0.0920 | 0.9804 | 9.1009 |
| 3.2.0 | 0.1039 | 1.0563 | 10.3576 |
| 3.0.0.a2 | 0.0870 | 0.9430 | 8.8815 |
| 3.0.0.a1 | 0.1399 | 1.4136 | 14.1829 |
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ nav:
- Migration:
- 1.* to 2.*: migration/v2.md
- 2.* to 3.*: migration/v3.md
- 3.* to 4.*: migration/v4.md
- Development:
- Contributing: dev/contributing.md
- Decisions: dev/main-decisions.md
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ dev = [
"mkdocs>=1.6.1",
"pytest-randomly",
"mkdocs-llmstxt>=0.4.0",
"pydantic<2.12.4" # causes tests to fail because of: TypeError: _eval_type() got an unexpected keyword argument 'prefer_fwd_module'
"pyrefly>=0.61.1",
]

[build-system]
Expand All @@ -62,6 +62,7 @@ module-root = ""
python_version = "3.10"
strict = true


[tool.ruff]
fix = true
unsafe-fixes = true
Expand Down
16 changes: 16 additions & 0 deletions tests/experimental/test_container_2.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ def __hash__(self) -> int:
return 0 # pragma: nocover


class _BrokenContextObject(providers.Object[int]):
def get_scope(self) -> ContextScopes | None:
msg = "_missing_scope"
raise AttributeError(msg)


async def _async_creator() -> AsyncIterator[float]:
yield random.random()

Expand All @@ -30,6 +36,9 @@ def _sync_creator() -> Iterator[_RandomWrapper]:
yield _RandomWrapper()


broken_context_provider = _BrokenContextObject(1)


class Container2(BaseContainer):
"""Test Container 2."""

Expand Down Expand Up @@ -139,6 +148,13 @@ async def test_lazy_provider_not_implemented() -> None:
await lazy_provider.tear_down()


def test_lazy_provider_not_implemented_when_context_method_raises_attribute_error() -> None:
lazy_provider = LazyProvider("tests.experimental.test_container_2.broken_context_provider")

with pytest.raises(NotImplementedError):
lazy_provider.get_scope()


def test_lazy_provider_attr_getter() -> None:
lazy_provider = LazyProvider("tests.experimental.test_container_2.Container2.sync_context_provider")
with lazy_provider.context_sync(force=True):
Expand Down
1 change: 1 addition & 0 deletions tests/providers/test_attr_getter.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ class _Container(BaseContainer):

attr_getter = _Container.child.v

attr_getter._register_arguments()
attr_getter._register_arguments()

assert attr_getter.resolve_sync() == _Container.parent.resolve_sync()
Expand Down
53 changes: 49 additions & 4 deletions tests/providers/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@
from typing_extensions import override

from that_depends import BaseContainer
from that_depends.providers.base import AbstractProvider
from that_depends.providers.base import AbstractProvider, _resolve_arguments, _resolve_arguments_sync
from that_depends.providers.local_singleton import ThreadLocalSingleton
from that_depends.providers.mixin import SupportsTeardown
from that_depends.providers.resources import Resource
from that_depends.providers.singleton import AsyncSingleton, Singleton
from that_depends.utils import is_set


class DummyProvider(SupportsTeardown, AbstractProvider[int]):
Expand Down Expand Up @@ -43,6 +44,18 @@ def resolve_sync(self) -> int:
return self._instance # pragma: no cover


async def test_resolve_arguments_with_multiple_values() -> None:
provider = DummyProvider()

assert await _resolve_arguments((provider, "value"), (True, False)) == [1, "value"]


def test_resolve_arguments_sync_with_multiple_values() -> None:
provider = DummyProvider()

assert _resolve_arguments_sync((provider, "value"), (True, False)) == [1, "value"]


def test_add_child_provider() -> None:
provider_a = DummyProvider()
provider_b = DummyProvider()
Expand Down Expand Up @@ -75,6 +88,28 @@ def test_register_with_mixed_items() -> None:
assert parent in child_1._children, "Expected child_1._children to contain parent"


def test_invalidate_scope_init_order_handles_duplicate_descendants() -> None:
root = DummyProvider()
left = DummyProvider()
right = DummyProvider()
shared = DummyProvider()

root.add_child_provider(left)
root.add_child_provider(right)
left.add_child_provider(shared)
right.add_child_provider(shared)

for provider in (root, left, right, shared):
provider._scope_context_init_order = ()
provider._scope_init_order = ()

root._invalidate_scope_init_order()

for provider in (root, left, right, shared):
assert provider._scope_context_init_order is None
assert provider._scope_init_order is None


def test_sync_tear_down_propagation() -> None:
parent = DummyProvider()
child_1 = DummyProvider()
Expand Down Expand Up @@ -168,6 +203,16 @@ def test_thread_local_singleton_registration_and_deregistration(dummy_singleton:
assert thread_local not in dummy_singleton._children, "ThreadLocalSingleton should be deregistered after teardown."


def test_get_scope_init_order_is_cached_and_includes_parents(dummy_singleton: Singleton[int]) -> None:
singleton = Singleton(lambda value: value + 1, dummy_singleton.cast)

first = singleton._get_scope_init_order()
second = singleton._get_scope_init_order()

assert first == (dummy_singleton, singleton)
assert second is first


def test_resource_registration_and_deregistration(dummy_singleton: Singleton[int]) -> None:
resource = Resource(_resource_generator, dummy_singleton.cast)

Expand Down Expand Up @@ -233,7 +278,7 @@ def test_propagate_off() -> None:
parent.tear_down_sync(propagate=False)

assert child in parent._children
assert child._instance is not None
assert is_set(child._instance)


async def test_async_tear_down_propagation_with_singleton() -> None:
Expand All @@ -244,7 +289,7 @@ async def test_async_tear_down_propagation_with_singleton() -> None:

await parent.tear_down()

assert child._instance is None
assert not is_set(child._instance)


async def test_async_propagate_off() -> None:
Expand All @@ -255,7 +300,7 @@ async def test_async_propagate_off() -> None:

await parent.tear_down(propagate=False)

assert child._instance is not None
assert is_set(child._instance)


async def test_provider_registration_in_different_scope_async() -> None:
Expand Down
6 changes: 5 additions & 1 deletion tests/providers/test_collections.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ async def test_list_provider() -> None:
sync_resource = await DIContainer.sync_resource()
async_resource = await DIContainer.async_resource()

assert sequence == [sync_resource, async_resource]
assert sequence == (sync_resource, async_resource)
with pytest.raises(TypeError):
typing.cast(typing.Any, sequence)[0] = sync_resource


def test_list_failed_sync_resolve() -> None:
Expand All @@ -48,6 +50,8 @@ async def test_dict_provider() -> None:

assert mapping == {"sync_resource": sync_resource, "async_resource": async_resource}
assert mapping == DIContainer.mapping.resolve_sync()
with pytest.raises(TypeError):
typing.cast(typing.Any, mapping)["sync_resource"] = sync_resource


@pytest.mark.parametrize("provider", [DIContainer.sequence, DIContainer.mapping])
Expand Down
Loading
Loading