Skip to content

Commit 48865be

Browse files
authored
Merge pull request #300 from python-ellar/session_client_config
fix(testing): handle uvicorn SystemExit in EllarUvicornServer startup
2 parents 094e9c1 + f98f5bf commit 48865be

File tree

9 files changed

+382
-33
lines changed

9 files changed

+382
-33
lines changed

docs/overview/modules.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,10 +289,23 @@ class ApplicationModule(ModuleBase):
289289
The `JWTModule` provides `JWTService` which is injected into `CustomModule`. By declaring `ForwardRefModule(JWTModule)` in `CustomModule`, the `JWTService` will be properly resolved during instantiation of `CustomModule`, regardless of the order in which the modules are configured in the application.
290290

291291
This pattern is particularly useful when:
292+
292293
- You want to avoid direct module instantiation
293294
- You need to configure a module differently in different parts of your application
294295
- You want to maintain loose coupling between modules
295296

297+
### When not to use `ForwardRefModule`
298+
If the @Module class is registered in the application module, ApplicationModule, there is no need to use `ForwardRefModule`. Services/Providers exported from the module will be available in the application module. And all other modules will be able to resolve the dependencies from the application module.
299+
300+
```python
301+
from ellar.common import Module
302+
from ellar.core.modules import ForwardRefModule, ModuleBase
303+
304+
@Module(modules=[ModuleA])
305+
class ApplicationModule(ModuleBase):
306+
pass
307+
```
308+
296309
### Forward Reference by Class
297310

298311
In the following example, we have two modules, `ModuleA` and `ModuleB`. `ModuleB`
@@ -321,6 +334,7 @@ class ApplicationModule(ModuleBase):
321334
```
322335

323336
In this example:
337+
324338
- `ModuleB` references `ModuleA` using `ForwardRefModule`, meaning `ModuleB` knows about `ModuleA` but doesn't instantiate it.
325339
- When `ApplicationModule` is built, both `ModuleA` and `ModuleB` are instantiated. During this build process, `ModuleB` can reference the instance of `ModuleA`, ensuring that all dependencies are resolved properly.
326340

@@ -352,6 +366,7 @@ class ApplicationModule(ModuleBase):
352366
```
353367

354368
In this second example:
369+
355370
- `ModuleB` references `ModuleA` by its name, `"moduleA"`.
356371
- During the build process of `ApplicationModule`, the name reference is resolved, ensuring that `ModuleA` is instantiated and injected into `ModuleB` correctly.
357372

ellar/app/main.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import json
22
import logging
3-
import logging.config
43
import typing as t
4+
from contextlib import _AsyncGeneratorContextManager
55

66
from ellar.auth.handlers import AuthenticationHandlerType
77
from ellar.common import (
@@ -185,7 +185,7 @@ def request_context(
185185
)
186186

187187
@t.no_type_check
188-
def with_injector_context(self) -> t.AsyncGenerator[EllarInjector, t.Any]:
188+
def with_injector_context(self) -> _AsyncGeneratorContextManager[t.Any]:
189189
return injector_context(self.injector)
190190

191191
@t.no_type_check

ellar/auth/session/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,15 @@ def session_cookie_options(self) -> SessionCookieOption: # pragma: no cover
2323
return SessionCookieOption()
2424

2525
def deserialize_session(
26-
self, session_data: t.Optional[str]
26+
self,
27+
session_data: t.Optional[str],
28+
config: t.Optional[SessionCookieOption] = None,
2729
) -> SessionCookieObject: # pragma: no cover
2830
return SessionCookieObject()
2931

3032
def serialize_session(
3133
self,
3234
session: t.Union[str, SessionCookieObject],
35+
config: t.Optional[SessionCookieOption] = None,
3336
) -> str: # pragma: no cover
3437
return ""

ellar/auth/session/base.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,37 +17,48 @@ def session_cookie_options(self) -> SessionCookieOption:
1717
def serialize_session(
1818
self,
1919
session: t.Union[str, SessionCookieObject],
20+
config: t.Optional[SessionCookieOption] = None,
2021
) -> str:
2122
"""
2223
:param session: Collection ExtraEndpointArg
24+
:param config: SessionCookieOption
2325
:return: string
2426
"""
2527

2628
@abstractmethod
27-
def deserialize_session(self, session_data: t.Optional[str]) -> SessionCookieObject:
29+
def deserialize_session(
30+
self,
31+
session_data: t.Optional[str],
32+
config: t.Optional[SessionCookieOption] = None,
33+
) -> SessionCookieObject:
2834
"""
2935
:param session_data:
3036
:return: SessionCookieObject
37+
:param config: SessionCookieOption
3138
"""
3239

33-
def get_cookie_header_value(self, data: t.Any, delete: bool = False) -> str:
34-
security_flags = "httponly; samesite=" + self.session_cookie_options.SAME_SITE
35-
if self.session_cookie_options.SECURE:
40+
def get_cookie_header_value(
41+
self,
42+
data: t.Any,
43+
delete: bool = False,
44+
config: t.Optional[SessionCookieOption] = None,
45+
) -> str:
46+
session_config = config or self.session_cookie_options
47+
security_flags = "httponly; samesite=" + session_config.SAME_SITE
48+
if session_config.SECURE:
3649
security_flags += "; secure"
3750

3851
if not delete:
3952
max_age = (
40-
f"Max-Age={self.session_cookie_options.MAX_AGE}; "
41-
if self.session_cookie_options.MAX_AGE
42-
else ""
53+
f"Max-Age={session_config.MAX_AGE}; " if session_config.MAX_AGE else ""
4354
)
4455
else:
4556
max_age = "Max-Age=0; "
4657

4758
header_value = "{session_cookie}={data}; path={path}; {max_age}{security_flags}".format( # E501
48-
session_cookie=self.session_cookie_options.NAME,
59+
session_cookie=session_config.NAME,
4960
data=data,
50-
path=self.session_cookie_options.PATH,
61+
path=session_config.PATH,
5162
max_age=max_age,
5263
security_flags=security_flags,
5364
)

ellar/auth/session/strategy.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,22 +40,29 @@ def session_cookie_options(self) -> SessionCookieOption:
4040
def serialize_session(
4141
self,
4242
session: t.Union[str, SessionCookieObject],
43+
config: t.Optional[SessionCookieOption] = None,
4344
) -> str:
45+
session_config = config or self._session_config
4446
if isinstance(session, SessionCookieObject):
4547
data = b64encode(json.dumps(dict(session)).encode("utf-8"))
4648
data = self._signer.sign(data)
4749

48-
return self.get_cookie_header_value(data.decode("utf-8"))
50+
return self.get_cookie_header_value(
51+
data.decode("utf-8"), config=session_config
52+
)
4953

50-
return self.get_cookie_header_value(session, delete=True)
54+
return self.get_cookie_header_value(session, delete=True, config=session_config)
5155

52-
def deserialize_session(self, session_data: t.Optional[str]) -> SessionCookieObject:
56+
def deserialize_session(
57+
self,
58+
session_data: t.Optional[str],
59+
config: t.Optional[SessionCookieOption] = None,
60+
) -> SessionCookieObject:
61+
session_config = config or self._session_config
5362
if session_data:
5463
data = session_data.encode("utf-8")
5564
try:
56-
data = self._signer.unsign(
57-
data, max_age=self.session_cookie_options.MAX_AGE
58-
)
65+
data = self._signer.unsign(data, max_age=session_config.MAX_AGE)
5966
return SessionCookieObject(json.loads(b64decode(data)))
6067
except BadSignature:
6168
pass

ellar/common/params/params.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ class ParamTypes(Enum):
3232
body = "body"
3333

3434

35-
class ParamFieldInfo(FieldInfo):
35+
class ParamFieldInfo(FieldInfo): # type: ignore[misc]
3636
in_: ParamTypes = ParamTypes.query
3737
resolver: t.Type[BaseRouteParameterResolver] = QueryParameterResolver
3838
bulk_resolver: t.Type[BulkParameterResolver] = BulkParameterResolver
@@ -143,7 +143,7 @@ def __repr__(self) -> str:
143143
return f"{self.__class__.__name__}({self.default})"
144144

145145

146-
class PathFieldInfo(ParamFieldInfo):
146+
class PathFieldInfo(ParamFieldInfo): # type: ignore[misc]
147147
in_ = ParamTypes.path
148148
resolver: t.Type[BaseRouteParameterResolver] = PathParameterResolver
149149

@@ -209,12 +209,12 @@ def __init__(
209209
)
210210

211211

212-
class QueryFieldInfo(ParamFieldInfo):
212+
class QueryFieldInfo(ParamFieldInfo): # type: ignore[misc]
213213
in_ = ParamTypes.query
214214
resolver: t.Type[BaseRouteParameterResolver] = QueryParameterResolver
215215

216216

217-
class HeaderFieldInfo(ParamFieldInfo):
217+
class HeaderFieldInfo(ParamFieldInfo): # type: ignore[misc]
218218
in_ = ParamTypes.header
219219
resolver: t.Type[BaseRouteParameterResolver] = HeaderParameterResolver
220220

@@ -282,12 +282,12 @@ def __init__(
282282
)
283283

284284

285-
class CookieFieldInfo(ParamFieldInfo):
285+
class CookieFieldInfo(ParamFieldInfo): # type: ignore[misc]
286286
in_ = ParamTypes.cookie
287287
resolver: t.Type[BaseRouteParameterResolver] = CookieParameterResolver
288288

289289

290-
class BodyFieldInfo(ParamFieldInfo):
290+
class BodyFieldInfo(ParamFieldInfo): # type: ignore[misc]
291291
in_ = ParamTypes.body
292292
MEDIA_TYPE: str = "application/json"
293293
resolver: t.Type[BaseRouteParameterResolver] = BodyParameterResolver
@@ -363,11 +363,11 @@ def __repr__(self) -> str:
363363
return f"{self.__class__.__name__}({self.default})"
364364

365365

366-
class WsBodyFieldInfo(BodyFieldInfo):
366+
class WsBodyFieldInfo(BodyFieldInfo): # type: ignore[misc]
367367
resolver: t.Type[BaseRouteParameterResolver] = WsBodyParameterResolver
368368

369369

370-
class FormFieldInfo(ParamFieldInfo):
370+
class FormFieldInfo(ParamFieldInfo): # type: ignore[misc]
371371
in_ = ParamTypes.body
372372
resolver: t.Type[BaseRouteParameterResolver] = FormParameterResolver
373373
MEDIA_TYPE: str = "application/form-data"
@@ -456,6 +456,6 @@ def create_resolver(
456456
return self.resolver(model_field)
457457

458458

459-
class FileFieldInfo(FormFieldInfo):
459+
class FileFieldInfo(FormFieldInfo): # type: ignore[misc]
460460
resolver: t.Type[BaseRouteParameterResolver] = FileParameterResolver
461461
MEDIA_TYPE: str = "multipart/form-data"

ellar/testing/uvicorn_server.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,17 @@ def __init__(self, app: ASGIApp, host: str = "127.0.0.1", port: int = 8000):
2525
super().__init__(config=uvicorn.Config(app, host=host, port=port))
2626

2727
async def startup(self, sockets: t.Optional[t.List[t.Any]] = None) -> None:
28-
"""Override uvicorn startup"""
29-
await super().startup(sockets)
30-
self.config.setup_event_loop()
31-
self._startup_done.set()
28+
"""Override uvicorn startup to prevent sys.exit on errors"""
29+
try:
30+
await super().startup(sockets)
31+
self._startup_done.set()
32+
except SystemExit as exc:
33+
# Convert sys.exit() calls into RuntimeError for testing
34+
# This happens when uvicorn fails to bind to a port
35+
await self.lifespan.shutdown()
36+
raise RuntimeError(
37+
f"Server failed to start on {self.config.host}:{self.config.port}"
38+
) from exc
3239

3340
async def run_server(self) -> None:
3441
"""Start up server asynchronously"""

0 commit comments

Comments
 (0)