soupape 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
soupape-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,277 @@
1
+ Metadata-Version: 2.3
2
+ Name: soupape
3
+ Version: 0.1.0
4
+ Summary:
5
+ Requires-Dist: peritype>=0.1.5
6
+ Requires-Python: >=3.13
7
+ Description-Content-Type: text/markdown
8
+
9
+ # Soupape
10
+
11
+ Soupape is a dependency injection and inversion of control library in pure Python.
12
+ It allows you to manage the dependencies of your services in your application in a clean and efficient way.
13
+ Soupape is a standalone library that does not rely on any framework and can be used in any Python project.
14
+
15
+ ## Installation
16
+
17
+ ```shell
18
+ $ pip install soupape # or use your preferred package manager
19
+ ```
20
+
21
+ ## Features
22
+
23
+ ### Service Registration and Injection
24
+
25
+ Let's first write some services.
26
+
27
+ ```python
28
+ from typing import Any
29
+
30
+ from my_app.models import User
31
+
32
+
33
+ class HttpService:
34
+ async def get(self, url: str) -> dict[str, Any]: ...
35
+
36
+
37
+ class UserService:
38
+ async def get_user(self, user_id: int) -> User: ...
39
+
40
+
41
+ class AuthService:
42
+ def __init__(self, http: HttpService, user_service: UserService) -> None:
43
+ self.http = http
44
+ self.user_service = user_service
45
+
46
+ async def authenticate(self, token: str) -> User: ...
47
+ ```
48
+
49
+ Now, we can register them in the service collection.
50
+
51
+ ```python
52
+ from soupape import ServiceCollection
53
+
54
+ from my_app.services import AuthService, HttpService, UserService
55
+
56
+
57
+ def define_services() -> ServiceCollection:
58
+ services = ServiceCollection()
59
+ services.add_singleton(HttpService)
60
+ services.add_scoped(UserService)
61
+ services.add_scoped(AuthService)
62
+ return services
63
+
64
+
65
+ async def main():
66
+ services = define_services()
67
+ async with AsyncInjector(services) as injector:
68
+ async with injector.get_scoped_injector() as scoped_injector:
69
+ auth_service = await scoped_injector.require(AuthService)
70
+ token = ... # obtain token from somewhere
71
+ user = await auth_service.authenticate(token)
72
+ ```
73
+
74
+ Let's break down what we did here:
75
+ - We created a 'HttpService' as a singleton, meaning there will be only one instance of it throughout the main injector's lifetime.
76
+ - We created 'UserService' and 'AuthService' as scoped services, meaning a new instance will be created for each scoped injector.
77
+
78
+ A `SyncInjector` also exists for synchronous code only.
79
+ In the example above, a synchronous injector could be used because none of the services require asynchronous initialization.
80
+ See below for more details on initialization.
81
+
82
+ ### Type hints
83
+
84
+ Soupape uses type hints to resolve dependencies.
85
+ This library makes all type hints mandatory for service constructors and resolver functions.
86
+
87
+ Errors will be raised if type hints are missing.
88
+
89
+ ### Service Lifetimes
90
+
91
+ Soupape supports three service lifetimes:
92
+ - **Transient**:
93
+ - A new instance is created every time the service is requested.
94
+ - **Singleton**:
95
+ - A single instance is created and shared throughout the lifetime of the main injector.
96
+ - A singleton service instance is kept alive in the main injector, even when it is created in a scoped injection session.
97
+ - Singleton services are disposed of when the main injector is closed.
98
+ - Singleton services can only depend on singleton or transient services.
99
+ - **Scoped**:
100
+ - The main injector cannot create scoped services, only scoped injectors can.
101
+ - A new instance is created in the scoped injection session.
102
+ - When using multi-level scoped injectors, a scoped service instance is kept alive in the scoped injection session where it was created.
103
+ A child injection session will use the instances from its parent sessions.
104
+ Be careful which scoped injector you request a scoped service from.
105
+ - Scoped services are disposed of when the scoped injection session they were created in is closed.
106
+ - Scoped services can depend on singleton, transient, or scoped services.
107
+
108
+ ### Context manager services
109
+
110
+ When registered through the default resolver, services can implement the context manager protocol (sync or async) to manage resources.
111
+
112
+ The `__enter__` (or `__aenter__`) method will be called when the service is created, and the `__exit__` (or `__aexit__`) method will be called when the injection session that created the service is closed.
113
+
114
+ The `SyncInjector` will raise an error during service injection if any dependency implements the async context manager protocol.
115
+ The `AsyncInjector` can handle both sync and async context managers.
116
+ If a service implements both protocols, the async one will be used and the sync one will be ignored.
117
+
118
+ ```python
119
+ from typing import Self
120
+ from types import TracebackType
121
+
122
+ from soupape import AsyncInjector, ServiceCollection
123
+
124
+
125
+ class ServiceWithResources:
126
+ def __init__(self) -> None:
127
+ self.resource = None
128
+
129
+ async def __aenter__(self) -> Self:
130
+ self.resource = await acquire_resource()
131
+ return self
132
+
133
+ async def __aexit__(
134
+ self,
135
+ exc_type: type[BaseException] | None,
136
+ exc_value: BaseException | None,
137
+ traceback: TracebackType | None
138
+ ) -> None:
139
+ await release_resource(self.resource)
140
+ self.resource = None
141
+
142
+
143
+ services = ServiceCollection()
144
+ services.add_scoped(ServiceWithResources)
145
+
146
+
147
+ async def main():
148
+ async with AsyncInjector(services) as injector:
149
+ async with injector.get_scoped_injector() as scoped_injector:
150
+ service = await scoped_injector.require(ServiceWithResources)
151
+ assert service.resource is not None
152
+ assert service.resource is None
153
+ ```
154
+
155
+ When using custom resolver functions, see below, you are responsible for managing the context manager protocol if needed.
156
+
157
+ ### Post init methods
158
+
159
+ Another way to organize service initialization is to use post init methods.
160
+
161
+ A post init method can be synchronous or asynchronous.
162
+ These methods will be called after the service is created, but before it is returned to the caller.
163
+ They will be called in the order they are defined in the class.
164
+ Post init methods in parent classes will be called before those in child classes.
165
+
166
+ ```python
167
+ from soupape import AsyncInjector, ServiceCollection, post_init
168
+
169
+
170
+ class ServiceWithPostInit:
171
+ def __init__(self) -> None:
172
+ self.state = 'created'
173
+
174
+ @post_init
175
+ async def _init_state(self) -> None:
176
+ self.state = 'initialized
177
+ ```
178
+
179
+ When using custom resolver functions, post init methods will be ignored.
180
+
181
+ ### Custom resolver functions
182
+
183
+ You can register your services using your own resolver functions.
184
+ It can be useful when you need to pass some parameters to the service constructor that are not managed by the injector.
185
+
186
+ ```python
187
+ from soupape import AsyncInjector, ServiceCollection
188
+
189
+ from my_app.models import User
190
+
191
+
192
+ class UserRepository:
193
+ async def get_user(self, user_id: int) -> User: ...
194
+
195
+
196
+ class CurrentUserService:
197
+ def __init__(self, current_user: User) -> None:
198
+ self._current_user = current_user
199
+
200
+ def get_user(self) -> User:
201
+ return self._current_user
202
+
203
+
204
+ async def current_user_service_resolver(
205
+ user_repository: UserRepository
206
+ ) -> CurrentUserService:
207
+ user_id = ... # obtain user id from somewhere
208
+ current_user = await user_repository.get_user(user_id)
209
+ return CurrentUserService(current_user)
210
+
211
+
212
+ services = ServiceCollection()
213
+ services.add_scoped(UserRepository)
214
+ services.add_scoped(current_user_service_resolver)
215
+
216
+ async def main():
217
+ async with AsyncInjector(services) as injector:
218
+ async with injector.get_scoped_injector() as scoped_injector:
219
+ current_user_service = await scoped_injector.require(CurrentUserService)
220
+ user = current_user_service.get_user()
221
+ ```
222
+
223
+ Again, type hints are mandatory for the resolver function parameters and return type.
224
+ The registration and the dependency resolution are linked through the return type hint of the resolver function that must match.
225
+
226
+ When using custom resolver functions, Soupape does not manage the context manager protocol for you.
227
+
228
+ You can use a context manager in the resolver function, as shown below.
229
+
230
+ ```python
231
+ async def service_with_resources_resolver() -> ServiceWithResources:
232
+ async with ServiceWithResources() as service:
233
+ return service
234
+
235
+ services = ServiceCollection()
236
+ services.add_scoped(service_with_resources_resolver)
237
+ ```
238
+
239
+ ### Generator resolver functions
240
+
241
+ Resolver functions can use the `yield` statement instead of context managers to execute instructions after the injection session is closed.
242
+
243
+ ```python
244
+ from collections.abc import AsyncGenerator
245
+
246
+
247
+ class Service:
248
+ def __init__(self) -> None:
249
+ self.state = 'created'
250
+
251
+ async def initialize(self) -> None:
252
+ self.state = 'initialized'
253
+
254
+ async def cleanup(self) -> None:
255
+ self.state = 'closed'
256
+
257
+
258
+ async def service_resolver() -> AsyncGenerator[Service]:
259
+ service = Service()
260
+ await service.initialize()
261
+ try:
262
+ yield service
263
+ finally:
264
+ await service.cleanup()
265
+
266
+
267
+ services = ServiceCollection()
268
+ services.add_scoped(service_resolver)
269
+
270
+
271
+ async def main():
272
+ async with AsyncInjector(services) as injector:
273
+ async with injector.get_scoped_injector() as scoped_injector:
274
+ service = await scoped_injector.require(Service)
275
+ assert service.state == 'initialized'
276
+ assert service.state == 'closed'
277
+ ```
@@ -0,0 +1,269 @@
1
+ # Soupape
2
+
3
+ Soupape is a dependency injection and inversion of control library in pure Python.
4
+ It allows you to manage the dependencies of your services in your application in a clean and efficient way.
5
+ Soupape is a standalone library that does not rely on any framework and can be used in any Python project.
6
+
7
+ ## Installation
8
+
9
+ ```shell
10
+ $ pip install soupape # or use your preferred package manager
11
+ ```
12
+
13
+ ## Features
14
+
15
+ ### Service Registration and Injection
16
+
17
+ Let's first write some services.
18
+
19
+ ```python
20
+ from typing import Any
21
+
22
+ from my_app.models import User
23
+
24
+
25
+ class HttpService:
26
+ async def get(self, url: str) -> dict[str, Any]: ...
27
+
28
+
29
+ class UserService:
30
+ async def get_user(self, user_id: int) -> User: ...
31
+
32
+
33
+ class AuthService:
34
+ def __init__(self, http: HttpService, user_service: UserService) -> None:
35
+ self.http = http
36
+ self.user_service = user_service
37
+
38
+ async def authenticate(self, token: str) -> User: ...
39
+ ```
40
+
41
+ Now, we can register them in the service collection.
42
+
43
+ ```python
44
+ from soupape import ServiceCollection
45
+
46
+ from my_app.services import AuthService, HttpService, UserService
47
+
48
+
49
+ def define_services() -> ServiceCollection:
50
+ services = ServiceCollection()
51
+ services.add_singleton(HttpService)
52
+ services.add_scoped(UserService)
53
+ services.add_scoped(AuthService)
54
+ return services
55
+
56
+
57
+ async def main():
58
+ services = define_services()
59
+ async with AsyncInjector(services) as injector:
60
+ async with injector.get_scoped_injector() as scoped_injector:
61
+ auth_service = await scoped_injector.require(AuthService)
62
+ token = ... # obtain token from somewhere
63
+ user = await auth_service.authenticate(token)
64
+ ```
65
+
66
+ Let's break down what we did here:
67
+ - We created a 'HttpService' as a singleton, meaning there will be only one instance of it throughout the main injector's lifetime.
68
+ - We created 'UserService' and 'AuthService' as scoped services, meaning a new instance will be created for each scoped injector.
69
+
70
+ A `SyncInjector` also exists for synchronous code only.
71
+ In the example above, a synchronous injector could be used because none of the services require asynchronous initialization.
72
+ See below for more details on initialization.
73
+
74
+ ### Type hints
75
+
76
+ Soupape uses type hints to resolve dependencies.
77
+ This library makes all type hints mandatory for service constructors and resolver functions.
78
+
79
+ Errors will be raised if type hints are missing.
80
+
81
+ ### Service Lifetimes
82
+
83
+ Soupape supports three service lifetimes:
84
+ - **Transient**:
85
+ - A new instance is created every time the service is requested.
86
+ - **Singleton**:
87
+ - A single instance is created and shared throughout the lifetime of the main injector.
88
+ - A singleton service instance is kept alive in the main injector, even when it is created in a scoped injection session.
89
+ - Singleton services are disposed of when the main injector is closed.
90
+ - Singleton services can only depend on singleton or transient services.
91
+ - **Scoped**:
92
+ - The main injector cannot create scoped services, only scoped injectors can.
93
+ - A new instance is created in the scoped injection session.
94
+ - When using multi-level scoped injectors, a scoped service instance is kept alive in the scoped injection session where it was created.
95
+ A child injection session will use the instances from its parent sessions.
96
+ Be careful which scoped injector you request a scoped service from.
97
+ - Scoped services are disposed of when the scoped injection session they were created in is closed.
98
+ - Scoped services can depend on singleton, transient, or scoped services.
99
+
100
+ ### Context manager services
101
+
102
+ When registered through the default resolver, services can implement the context manager protocol (sync or async) to manage resources.
103
+
104
+ The `__enter__` (or `__aenter__`) method will be called when the service is created, and the `__exit__` (or `__aexit__`) method will be called when the injection session that created the service is closed.
105
+
106
+ The `SyncInjector` will raise an error during service injection if any dependency implements the async context manager protocol.
107
+ The `AsyncInjector` can handle both sync and async context managers.
108
+ If a service implements both protocols, the async one will be used and the sync one will be ignored.
109
+
110
+ ```python
111
+ from typing import Self
112
+ from types import TracebackType
113
+
114
+ from soupape import AsyncInjector, ServiceCollection
115
+
116
+
117
+ class ServiceWithResources:
118
+ def __init__(self) -> None:
119
+ self.resource = None
120
+
121
+ async def __aenter__(self) -> Self:
122
+ self.resource = await acquire_resource()
123
+ return self
124
+
125
+ async def __aexit__(
126
+ self,
127
+ exc_type: type[BaseException] | None,
128
+ exc_value: BaseException | None,
129
+ traceback: TracebackType | None
130
+ ) -> None:
131
+ await release_resource(self.resource)
132
+ self.resource = None
133
+
134
+
135
+ services = ServiceCollection()
136
+ services.add_scoped(ServiceWithResources)
137
+
138
+
139
+ async def main():
140
+ async with AsyncInjector(services) as injector:
141
+ async with injector.get_scoped_injector() as scoped_injector:
142
+ service = await scoped_injector.require(ServiceWithResources)
143
+ assert service.resource is not None
144
+ assert service.resource is None
145
+ ```
146
+
147
+ When using custom resolver functions, see below, you are responsible for managing the context manager protocol if needed.
148
+
149
+ ### Post init methods
150
+
151
+ Another way to organize service initialization is to use post init methods.
152
+
153
+ A post init method can be synchronous or asynchronous.
154
+ These methods will be called after the service is created, but before it is returned to the caller.
155
+ They will be called in the order they are defined in the class.
156
+ Post init methods in parent classes will be called before those in child classes.
157
+
158
+ ```python
159
+ from soupape import AsyncInjector, ServiceCollection, post_init
160
+
161
+
162
+ class ServiceWithPostInit:
163
+ def __init__(self) -> None:
164
+ self.state = 'created'
165
+
166
+ @post_init
167
+ async def _init_state(self) -> None:
168
+ self.state = 'initialized
169
+ ```
170
+
171
+ When using custom resolver functions, post init methods will be ignored.
172
+
173
+ ### Custom resolver functions
174
+
175
+ You can register your services using your own resolver functions.
176
+ It can be useful when you need to pass some parameters to the service constructor that are not managed by the injector.
177
+
178
+ ```python
179
+ from soupape import AsyncInjector, ServiceCollection
180
+
181
+ from my_app.models import User
182
+
183
+
184
+ class UserRepository:
185
+ async def get_user(self, user_id: int) -> User: ...
186
+
187
+
188
+ class CurrentUserService:
189
+ def __init__(self, current_user: User) -> None:
190
+ self._current_user = current_user
191
+
192
+ def get_user(self) -> User:
193
+ return self._current_user
194
+
195
+
196
+ async def current_user_service_resolver(
197
+ user_repository: UserRepository
198
+ ) -> CurrentUserService:
199
+ user_id = ... # obtain user id from somewhere
200
+ current_user = await user_repository.get_user(user_id)
201
+ return CurrentUserService(current_user)
202
+
203
+
204
+ services = ServiceCollection()
205
+ services.add_scoped(UserRepository)
206
+ services.add_scoped(current_user_service_resolver)
207
+
208
+ async def main():
209
+ async with AsyncInjector(services) as injector:
210
+ async with injector.get_scoped_injector() as scoped_injector:
211
+ current_user_service = await scoped_injector.require(CurrentUserService)
212
+ user = current_user_service.get_user()
213
+ ```
214
+
215
+ Again, type hints are mandatory for the resolver function parameters and return type.
216
+ The registration and the dependency resolution are linked through the return type hint of the resolver function that must match.
217
+
218
+ When using custom resolver functions, Soupape does not manage the context manager protocol for you.
219
+
220
+ You can use a context manager in the resolver function, as shown below.
221
+
222
+ ```python
223
+ async def service_with_resources_resolver() -> ServiceWithResources:
224
+ async with ServiceWithResources() as service:
225
+ return service
226
+
227
+ services = ServiceCollection()
228
+ services.add_scoped(service_with_resources_resolver)
229
+ ```
230
+
231
+ ### Generator resolver functions
232
+
233
+ Resolver functions can use the `yield` statement instead of context managers to execute instructions after the injection session is closed.
234
+
235
+ ```python
236
+ from collections.abc import AsyncGenerator
237
+
238
+
239
+ class Service:
240
+ def __init__(self) -> None:
241
+ self.state = 'created'
242
+
243
+ async def initialize(self) -> None:
244
+ self.state = 'initialized'
245
+
246
+ async def cleanup(self) -> None:
247
+ self.state = 'closed'
248
+
249
+
250
+ async def service_resolver() -> AsyncGenerator[Service]:
251
+ service = Service()
252
+ await service.initialize()
253
+ try:
254
+ yield service
255
+ finally:
256
+ await service.cleanup()
257
+
258
+
259
+ services = ServiceCollection()
260
+ services.add_scoped(service_resolver)
261
+
262
+
263
+ async def main():
264
+ async with AsyncInjector(services) as injector:
265
+ async with injector.get_scoped_injector() as scoped_injector:
266
+ service = await scoped_injector.require(Service)
267
+ assert service.state == 'initialized'
268
+ assert service.state == 'closed'
269
+ ```
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "soupape"
3
+ version = "0.1.0"
4
+ description = ""
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = ["peritype>=0.1.5"]
8
+
9
+ [build-system]
10
+ requires = ["uv_build>=0.8.4,<0.9.0"]
11
+ build-backend = "uv_build"
12
+
13
+ [tool.uv.build-backend]
14
+ package = "soupape"
15
+ module-root = ""
16
+
17
+ [dependency-groups]
18
+ dev = ["ruff>=0.12.7"]
19
+ test = [
20
+ "pytest>=8.4.1",
21
+ "pytest-asyncio>=1.3.0",
22
+ "pytest-cov>=6.2.1",
23
+ "typing-extensions>=4.15.0",
24
+ ]
25
+
26
+ [tool.ruff]
27
+ line-length = 120
28
+
29
+ [tool.ruff.lint]
30
+ extend-select = ["E", "W", "F", "B", "Q", "I", "N", "RUF", "UP"]
31
+ fixable = ["ALL"]
32
+
33
+ [tool.ruff.lint.per-file-ignores]
34
+ "**/__init__.py" = ["I001"]
@@ -0,0 +1,4 @@
1
+ from soupape.types import Injector as Injector
2
+ from soupape.collection import ServiceCollection as ServiceCollection
3
+ from soupape.post_init import post_init as post_init
4
+ from soupape.injector import AsyncInjector as AsyncInjector, SyncInjector as SyncInjector