Skip to content

Commit beaf00c

Browse files
authored
Sync tear down (#161)
* Added test for async teardown concurrency. * Added sync_teardown for Resource provider. * Modified container and added tests. * Added sync teardown to resource, singleton & container. * Updated singleton provider documentation including tear down. * Updated resource provider documentation including tear down.
1 parent b49d607 commit beaf00c

File tree

9 files changed

+315
-88
lines changed

9 files changed

+315
-88
lines changed

docs/providers/resources.md

Lines changed: 106 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,130 @@
1-
# Resource
2-
- resolve the dependency only once and cache the resolved instance for future injections;
3-
- unlike `Singleton` has finalization logic;
4-
- generator or async generator can be used;
5-
- context manager derived from `typing.ContextManager` or `typing.AsyncContextManager` can be used;
1+
# Resource Provider
62

7-
## How it works
8-
```python
9-
import typing
3+
A **Resource** is a special provider that:
4+
5+
- **Resolves** its dependency only **once** and **caches** the resolved instance for future injections.
6+
- **Includes** teardown (finalization) logic, unlike a plain `Singleton`.
7+
- **Supports** generator or async generator functions for creation (allowing a `yield` plus teardown in `finally`).
8+
- **Also** allows usage of classes that implement standard Python context managers (`typing.ContextManager` or `typing.AsyncContextManager`), but *does not* automatically integrate with `container_context`.
9+
10+
This makes `Resource` ideal for dependencies that need:
11+
12+
1. A **single creation** step,
13+
2. A **single finalization** step,
14+
3. **Thread/async safety**—all consumers receive the same resource object, and concurrency is handled.
15+
16+
---
1017

11-
from that_depends import BaseContainer, providers
18+
## How It Works
1219

20+
### Defining a Sync or Async Resource
21+
22+
You can define your creation logic as either a **generator** or a **context manager** class (sync or async).
23+
24+
**Synchronous generator** example:
25+
26+
```python
27+
import typing
28+
from that_depends.providers import Resource
1329

1430
def create_sync_resource() -> typing.Iterator[str]:
15-
# resource initialization
31+
print("Creating sync resource")
1632
try:
1733
yield "sync resource"
1834
finally:
19-
pass # resource teardown
35+
print("Tearing down sync resource")
36+
```
2037

38+
**Asynchronous generator** example:
39+
40+
```python
41+
import typing
42+
from that_depends.providers import Resource
2143

2244
async def create_async_resource() -> typing.AsyncIterator[str]:
23-
# resource initialization
45+
print("Creating async resource")
2446
try:
2547
yield "async resource"
2648
finally:
27-
pass # resource teardown
49+
print("Tearing down async resource")
50+
```
2851

52+
You then attach them to a container:
53+
54+
```python
55+
from that_depends import BaseContainer
2956

3057
class MyContainer(BaseContainer):
31-
sync_resource = providers.Resource(create_sync_resource)
32-
async_resource = providers.Resource(create_async_resource)
58+
sync_resource = Resource(create_sync_resource)
59+
async_resource = Resource(create_async_resource)
60+
```
61+
62+
---
63+
64+
## Resolving and Teardown
65+
66+
Once defined, you can explicitly **resolve** the resource and **tear it down**:
67+
68+
```python
69+
# Synchronous resource usage
70+
value_sync = MyContainer.sync_resource.sync_resolve()
71+
print(value_sync) # "sync resource"
72+
MyContainer.sync_resource.sync_tear_down()
73+
74+
# Asynchronous resource usage
75+
import asyncio
76+
77+
async def main():
78+
value_async = await MyContainer.async_resource.async_resolve()
79+
print(value_async) # "async resource"
80+
await MyContainer.async_resource.tear_down()
81+
82+
asyncio.run(main())
3383
```
3484

35-
## Concurrency safety
36-
`Resource` is safe to use in threading and asyncio concurrency:
85+
- **`sync_resolve()`** or **`async_resolve()`**: Creates (if needed) and returns the resource instance.
86+
- **`sync_tear_down()`** or **`tear_down()`**: Closes/cleans up the resource (triggering your `finally` block or exiting the context manager) and resets the cached instance to `None`. A subsequent resolve call will then recreate it.
87+
88+
---
89+
90+
## Concurrency Safety
91+
92+
`Resource` is **safe** to use under **threading** and **asyncio** concurrency. Internally, a lock ensures only one resource instance is created per container:
93+
94+
- Multiple threads calling `sync_resolve()` simultaneously will produce a **single** instance for that container.
95+
- Multiple coroutines calling `async_resolve()` simultaneously will likewise produce **only one** instance for that container in an async environment.
96+
3797
```python
38-
# calling async_resolve concurrently in different coroutines will create only one instance
98+
# Even if multiple coroutines call async_resolve in parallel,
99+
# only one instance is created at a time:
39100
await MyContainer.async_resource.async_resolve()
40101

41-
# calling sync_resolve concurrently in different threads will create only one instance
102+
# Similarly, multiple threads calling sync_resolve concurrently
103+
# still yield just one instance until teardown:
42104
MyContainer.sync_resource.sync_resolve()
43105
```
106+
107+
---
108+
109+
## Using Context Managers Directly
110+
111+
If your resource is a standard **context manager** or **async context manager** class, `Resource` will handle entering and exiting it under the hood. For example:
112+
113+
```python
114+
import typing
115+
from that_depends.providers import Resource
116+
117+
class SyncFileManager:
118+
def __enter__(self) -> str:
119+
print("Opening file")
120+
return "/path/to/file"
121+
def __exit__(self, exc_type, exc_val, exc_tb):
122+
print("Closing file")
123+
124+
sync_file_resource = Resource(SyncFileManager)
125+
126+
# usage
127+
file_path = sync_file_resource.sync_resolve()
128+
print(file_path)
129+
sync_file_resource.sync_tear_down()
130+
```

docs/providers/singleton.md

Lines changed: 108 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
# Singleton
2-
- resolve the dependency only once and cache the resolved instance for future injections;
1+
# Singleton Provider
2+
3+
A **Singleton** provider creates its instance once and caches it for all future injections or resolutions. When the instance is first requested (via `sync_resolve()` or `async_resolve()`), the underlying factory is called. On subsequent calls, the cached instance is returned without calling the factory again.
4+
5+
## How it Works
36

4-
## How it works
57
```python
68
import random
79

@@ -18,68 +20,109 @@ class MyContainer(BaseContainer):
1820
singleton = providers.Singleton(some_function)
1921

2022

21-
# provider will call `some_func` and cache the return value
22-
MyContainer.singleton.sync_resolve() # 0.3
23-
# provider with return the cached value
24-
MyContainer.singleton.sync_resolve() # 0.3
23+
# The provider will call `some_function` once and cache the return value
24+
25+
# 1) Synchronous resolution
26+
MyContainer.singleton.sync_resolve() # e.g. 0.3
27+
MyContainer.singleton.sync_resolve() # 0.3 (cached)
2528

26-
# async_resolve can be used also
27-
await MyContainer.singleton.async_resolve() # 0.3
29+
# 2) Asynchronous resolution
30+
await MyContainer.singleton.async_resolve() # 0.3 (same cached value)
2831

29-
# and injection to function arguments can be used also
32+
# 3) Injection example
3033
@inject
3134
async def with_singleton(number: float = Provide[MyContainer.singleton]):
32-
... # number == 0.3
35+
# number == 0.3
36+
...
3337
```
3438

35-
## Concurrency safety
36-
`Singleton` is safe to use in threading and asyncio concurrency:
39+
### Teardown Support
40+
If you need to reset the singleton (for example, in tests or at application shutdown), you can call:
41+
```python
42+
await MyContainer.singleton.tear_down()
43+
```
44+
This clears the cached instance, causing a new one to be created the next time `sync_resolve()` or `async_resolve()` is called.
45+
*(If you only ever use synchronous resolution, you can call `MyContainer.singleton.sync_tear_down()` instead.)*
46+
47+
---
48+
49+
## Concurrency Safety
50+
51+
`Singleton` is **thread-safe** and **async-safe**:
52+
53+
1. **Async Concurrency**
54+
If multiple coroutines call `async_resolve()` concurrently, the factory function is guaranteed to be called only once. All callers receive the same cached instance.
55+
56+
2. **Thread Concurrency**
57+
If multiple threads call `sync_resolve()` at the same time, the factory is only called once. All threads receive the same cached instance.
58+
3759
```python
38-
# calling async_resolve concurrently in different coroutines will create only one instance
39-
await MyContainer.singleton.async_resolve()
60+
import threading
61+
import asyncio
62+
63+
# In async code:
64+
async def main():
65+
# calling async_resolve concurrently in different coroutines
66+
results = await asyncio.gather(
67+
MyContainer.singleton.async_resolve(),
68+
MyContainer.singleton.async_resolve(),
69+
)
70+
# Both results point to the same instance
4071

41-
# calling sync_resolve concurrently in different threads will create only one instance
42-
MyContainer.singleton.sync_resolve()
72+
# In threaded code:
73+
def thread_task():
74+
instance = MyContainer.singleton.sync_resolve()
75+
...
76+
77+
threads = [threading.Thread(target=thread_task) for _ in range(5)]
78+
for t in threads:
79+
t.start()
4380
```
44-
## ThreadLocalSingleton
4581

46-
For cases when you need to have a separate instance for each thread, you can use `ThreadLocalSingleton` provider. It will create a new instance for each thread and cache it for future injections in the same thread.
82+
---
83+
84+
## ThreadLocalSingleton Provider
85+
86+
If you want each *thread* to have its own, separately cached instance, use **ThreadLocalSingleton**. This provider creates a new instance per thread and reuses that instance on subsequent calls *within the same thread*.
4787

4888
```python
49-
from that_depends.providers import ThreadLocalSingleton
50-
import threading
5189
import random
90+
import threading
91+
from that_depends.providers import ThreadLocalSingleton
92+
5293

53-
# Define a factory function
5494
def factory() -> int:
95+
"""Return a random int between 1 and 100."""
5596
return random.randint(1, 100)
5697

57-
# Create a ThreadLocalSingleton instance
98+
99+
# ThreadLocalSingleton caches an instance per thread
58100
singleton = ThreadLocalSingleton(factory)
59101

60-
# Same thread, same instance
61-
instance1 = singleton.sync_resolve() # 56
62-
instance2 = singleton.sync_resolve() # 56
102+
# In a single thread:
103+
instance1 = singleton.sync_resolve() # e.g. 56
104+
instance2 = singleton.sync_resolve() # 56 (cached in the same thread)
63105

64-
# Example usage in multiple threads
106+
# In multiple threads:
65107
def thread_task():
66-
instance = singleton.sync_resolve()
67-
return instance
68-
69-
# Create and start threads
70-
threads = [threading.Thread(target=thread_task) for i in range(2)]
71-
for thread in threads:
72-
thread.start()
73-
for thread in threads:
74-
results = thread.join()
75-
76-
# Results will be different for each thread
77-
print(results) # [56, 78]
108+
return singleton.sync_resolve()
109+
110+
thread1 = threading.Thread(target=thread_task)
111+
thread2 = threading.Thread(target=thread_task)
112+
thread1.start()
113+
thread2.start()
114+
115+
# thread1 and thread2 each get a different cached value
78116
```
79117

118+
You can still use `.async_resolve()` with `ThreadLocalSingleton`, which will also maintain isolation per thread. However, note that this does *not* isolate instances per asynchronous Task – only per OS thread.
119+
120+
---
80121

81122
## Example with `pydantic-settings`
82-
Let's say we are storing our application configuration using [pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/):
123+
124+
Consider a scenario where your application configuration is defined via [**pydantic-settings**](https://docs.pydantic.dev/latest/concepts/pydantic_settings/). Often, you only want to parse this configuration (e.g., from environment variables) once, then reuse it throughout the application.
125+
83126
```python
84127
from pydantic_settings import BaseSettings
85128
from pydantic import BaseModel
@@ -96,41 +139,51 @@ class Settings(BaseSettings):
96139
db: DatabaseConfig = DatabaseConfig()
97140
```
98141

99-
Because we do not want to resolve the configuration each time it is used in our application, we provide it using the `Singleton` provider.
142+
### Defining the Container
143+
144+
Below, we define a container with a **Singleton** provider for our settings. We also define a separate async factory that connects to the database using those settings.
100145

101146
```python
102147
from that_depends import BaseContainer, providers
103148

104-
105-
async def get_db_connection(address: str, port:int, db_name: str) -> Connection:
149+
async def get_db_connection(address: str, port: int, db_name: str):
150+
# e.g., create an async DB connection
106151
...
107152

108153
class MyContainer(BaseContainer):
154+
# We'll parse settings only once
109155
config = providers.Singleton(Settings)
110-
# provide connection arguments and create a connection provider
156+
157+
# We'll pass the config's DB fields into an async factory for a DB connection
111158
db_connection = providers.AsyncFactory(
112-
get_db_connection, config.db.address, config.db.port, config.db_name
159+
get_db_connection,
160+
config.db.address,
161+
config.db.port,
162+
config.db.db_name,
113163
)
114164
```
115165

116-
Now we can inject our database connection where it's required using `@inject`:
166+
### Injecting or Resolving in Code
167+
168+
You can now inject these values directly into your functions with the `@inject` decorator:
117169

118170
```python
119171
from that_depends import inject, Provide
120172

121173
@inject
122-
async def with_db_connection(conn: Connection = Provide[MyContainer.db_connection]):
174+
async def with_db_connection(conn = Provide[MyContainer.db_connection]):
175+
# conn is the created DB connection
123176
...
124177
```
125178

126-
Of course, we can also resolve the whole configuration without accessing attributes by running:
179+
Or you can manually resolve them when needed:
127180

128181
```python
129-
# sync resolution
130-
config = MyContainer.config.sync_resolve()
131-
# async resolution
132-
config = await MyContainer.config.async_resolve()
133-
# inject the configuration into a function
134-
async def with_config(config: Settings = Provide[MyContainer.config]):
135-
assert config.auth_key == "my_auth_key"
182+
# Synchronously resolve the config
183+
cfg = MyContainer.config.sync_resolve()
184+
185+
# Asynchronously resolve the DB connection
186+
connection = await MyContainer.db_connection.async_resolve()
136187
```
188+
189+
By using `Singleton` for `Settings`, you avoid re-parsing the environment or re-initializing the configuration on each request.

0 commit comments

Comments
 (0)