Skip to content

Commit d8e3eef

Browse files
committed
increase test coverage on exception module
1 parent 5adca57 commit d8e3eef

File tree

10 files changed

+243
-182
lines changed

10 files changed

+243
-182
lines changed

docs/overview/modules.md

Lines changed: 34 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ class BookModule(ModuleBase):
7373

7474

7575
## `Additional Module Configurations`
76+
7677
### `Module Events`
7778
Every registered Module receives two event calls during its instantiation and when application is ready.
7879

@@ -87,11 +88,43 @@ class ModuleEventSample(ModuleBase):
8788
"""Called before creating Module object"""
8889

8990
def application_ready(self, app: App) -> None:
90-
"""Called when application is ready"""
91+
"""Called when application is ready - this is similar to @on_startup event"""
9192

9293
```
9394
`before_init` receives current app `Config` as a parameter and `application_ready` function receives `App` instance as parameter.
9495

96+
#### `Starlette Application Events`
97+
We can register multiple event handlers for dealing with code that needs to run before
98+
the application starts `up`, or when the application is shutting `down`.
99+
This is the way we support `Starlette` start up events in `Ellar`
100+
101+
```python
102+
103+
from ellar.common import Module, on_shutdown, on_startup
104+
from ellar.core import ModuleBase
105+
106+
@Module()
107+
class ModuleRequestEventsSample(ModuleBase):
108+
@on_startup
109+
def on_startup_func(cls):
110+
pass
111+
112+
@on_startup()
113+
async def on_startup_func_2(cls):
114+
pass
115+
116+
@on_shutdown
117+
def on_shutdown_func(cls):
118+
pass
119+
120+
@on_shutdown()
121+
async def on_shutdown_func_2(cls):
122+
pass
123+
```
124+
These will be registered to the application router during `ModuleRequestEventsSample` computation at runtime.
125+
Also, the events can be `async` as in the case of `on_shutdown_func_2` and `on_startup_func_2`
126+
127+
95128
### `Module Exceptions`
96129
In Ellar, custom exceptions can be registered through modules.
97130
During module meta-data computation, Ellar reads additional properties such as these from registered modules
@@ -132,35 +165,6 @@ class ModuleTemplateFilterSample(ModuleBase):
132165
def double_filter_dec(cls, n):
133166
return n * 2
134167
```
135-
### `Module Request Events`
136-
During application request handling, application router emits two events `start_up` and `shutdown` event.
137-
We can subscribe to those events in our modules.
138-
```python
139-
140-
from ellar.common import Module, on_shutdown, on_startup
141-
from ellar.core import ModuleBase
142-
143-
@Module()
144-
class ModuleRequestEventsSample(ModuleBase):
145-
@on_startup
146-
def on_startup_func(cls):
147-
pass
148-
149-
@on_startup()
150-
async def on_startup_func_2(cls):
151-
pass
152-
153-
@on_shutdown
154-
def on_shutdown_func(cls):
155-
pass
156-
157-
@on_shutdown()
158-
async def on_shutdown_func_2(cls):
159-
pass
160-
```
161-
These will be registered to the application router during `ModuleRequestEventsSample` computation at runtime.
162-
Also, the events can be `async` as in the case of `on_shutdown_func_2` and `on_startup_func_2`
163-
164168

165169
## `Dependency Injection`
166170
A module class can inject providers as well (e.g., for configuration purposes):

ellar/core/exceptions/callable_exceptions.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,11 @@ async def catch(
6868
if self.is_async:
6969
return await self.callable_exception_handler(*args) # type:ignore
7070
return await run_in_threadpool(self.callable_exception_handler, *args)
71+
72+
def __eq__(self, other: t.Any) -> bool:
73+
if isinstance(other, CallableExceptionHandler):
74+
return (
75+
other.exception_type_or_code == self.exception_type_or_code
76+
and other.callable_exception_handler == other.callable_exception_handler
77+
)
78+
return False

ellar/core/exceptions/interfaces.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77

88

99
class IExceptionHandler(ABC, t.Iterable):
10+
def __eq__(self, other: t.Any) -> bool:
11+
if isinstance(other, IExceptionHandler):
12+
return other.exception_type_or_code == self.exception_type_or_code
13+
return False
14+
1015
def __iter__(self) -> t.Iterator:
1116
as_tuple = (self.exception_type_or_code, self)
1217
return iter(as_tuple)

ellar/core/middleware/di.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,10 @@ async def __call__(self, scope: TScope, receive: TReceive, send: TSend) -> None:
100100

101101
scope[SCOPE_SERVICE_PROVIDER] = request_provider
102102
scope[SCOPE_EXECUTION_CONTEXT_PROVIDER] = execute_context
103-
await super().__call__(scope, receive, send)
103+
if scope["type"] == "http":
104+
await super().__call__(scope, receive, send)
105+
else:
106+
await self.app(scope, receive, send)
104107

105108
async def error_handler(self, request: Request, exc: Exception) -> Response:
106109
execute_context = request.scope[SCOPE_EXECUTION_CONTEXT_PROVIDER]

ellar/core/modules/builder.py

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from ellar.reflect import reflect
1414

1515
from ..exceptions.callable_exceptions import CallableExceptionHandler
16-
from .helper import class_parameter_executor_wrapper
16+
from .helper import module_callable_factory
1717

1818
if t.TYPE_CHECKING: # pragma: no cover
1919
from ellar.core.middleware.schema import MiddlewareSchema
@@ -52,9 +52,7 @@ def exception_config(self, exception_dict: t.Dict) -> None:
5252

5353
@t.no_type_check
5454
def middleware_config(self, middleware: "MiddlewareSchema") -> None:
55-
middleware.dispatch = class_parameter_executor_wrapper(
56-
self._cls, middleware.dispatch
57-
)
55+
middleware.dispatch = module_callable_factory(middleware.dispatch, self._cls)
5856
reflect.define_metadata(
5957
MIDDLEWARE_HANDLERS_KEY,
6058
middleware.create_middleware(),
@@ -63,23 +61,23 @@ def middleware_config(self, middleware: "MiddlewareSchema") -> None:
6361
)
6462

6563
def on_request_shut_down_config(self, on_shutdown_event: EventHandler) -> None:
66-
on_shutdown_event.handler = class_parameter_executor_wrapper(
67-
self._cls, on_shutdown_event.handler
64+
on_shutdown_event.handler = module_callable_factory(
65+
on_shutdown_event.handler, self._cls
6866
)
6967
reflect.define_metadata(
7068
ON_REQUEST_SHUTDOWN_KEY,
71-
on_shutdown_event.handler,
69+
on_shutdown_event,
7270
self._cls,
7371
default_value=[],
7472
)
7573

7674
def on_request_startup_config(self, on_startup_event: EventHandler) -> None:
77-
on_startup_event.handler = class_parameter_executor_wrapper(
78-
self._cls, on_startup_event.handler
75+
on_startup_event.handler = module_callable_factory(
76+
on_startup_event.handler, self._cls
7977
)
8078
reflect.define_metadata(
8179
ON_REQUEST_STARTUP_KEY,
82-
on_startup_event.handler,
80+
on_startup_event,
8381
self._cls,
8482
default_value=[],
8583
)
@@ -88,8 +86,8 @@ def template_filter_config(self, template_filter: "TemplateFunctionData") -> Non
8886
reflect.define_metadata(
8987
TEMPLATE_FILTER_KEY,
9088
{
91-
template_filter.name: class_parameter_executor_wrapper(
92-
self._cls, template_filter.func
89+
template_filter.name: module_callable_factory(
90+
template_filter.func, self._cls
9391
)
9492
},
9593
self._cls,
@@ -100,8 +98,8 @@ def template_global_config(self, template_filter: "TemplateFunctionData") -> Non
10098
reflect.define_metadata(
10199
TEMPLATE_GLOBAL_KEY,
102100
{
103-
template_filter.name: class_parameter_executor_wrapper(
104-
self._cls, template_filter.func
101+
template_filter.name: module_callable_factory(
102+
template_filter.func, self._cls
105103
)
106104
},
107105
self._cls,

ellar/core/modules/helper.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44

55

66
def _executor_wrapper_async(
7-
cls: t.Type, func: t.Callable
7+
cls: t.Type,
8+
func: t.Callable,
89
) -> t.Callable[..., t.Coroutine]:
910
@wraps(func)
1011
async def _decorator(*args: t.Any, **kwargs: t.Any) -> t.Any:
@@ -21,8 +22,8 @@ def _decorator(*args: t.Any, **kwargs: t.Any) -> t.Any:
2122
return _decorator
2223

2324

24-
def class_parameter_executor_wrapper(
25-
cls: t.Type, func: t.Callable
25+
def module_callable_factory(
26+
func: t.Callable, cls: t.Type
2627
) -> t.Union[t.Callable, t.Callable[..., t.Coroutine]]:
2728
if inspect.iscoroutinefunction(func):
2829
return _executor_wrapper_async(cls, func)

ellar/helper/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ def get_name(endpoint: t.Union[t.Callable, t.Type, object]) -> str:
3434

3535

3636
def is_async_callable(obj: t.Any) -> bool:
37-
while isinstance(obj, functools.partial):
37+
while isinstance(obj, functools.partial): # pragma: no cover
3838
obj = obj.func
3939

4040
return asyncio.iscoroutinefunction(obj) or (

tests/test_exceptions/test_custom_exceptions.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import typing as t
2+
from unittest.mock import patch
23

34
import pytest
45
from pydantic.error_wrappers import ValidationError
@@ -39,6 +40,15 @@ async def catch(
3940
return JSONResponse({"detail": str(exc)}, status_code=404)
4041

4142

43+
class OverrideHTTPException(IExceptionHandler):
44+
exception_type_or_code = HTTPException
45+
46+
async def catch(
47+
self, ctx: IExecutionContext, exc: t.Union[t.Any, Exception]
48+
) -> t.Union[Response, t.Any]:
49+
return JSONResponse({"detail": "HttpException Override"}, status_code=400)
50+
51+
4252
class ServerErrorHandler(IExceptionHandler):
4353
exception_type_or_code = 500
4454

@@ -189,3 +199,34 @@ async def app(scope, receive, send):
189199
client = test_client_factory(app)
190200
with pytest.raises(RuntimeError):
191201
client.get("/")
202+
203+
204+
def test_application_add_exception_handler():
205+
@get()
206+
def homepage():
207+
raise HTTPException(detail="Bad Request", status_code=400)
208+
209+
tm = TestClientFactory.create_test_module()
210+
tm.app.router.append(homepage)
211+
tm.app.add_exception_handler(OverrideHTTPException())
212+
213+
client = tm.get_client()
214+
res = client.get("/")
215+
216+
assert res.status_code == 400
217+
assert res.json() == {"detail": "HttpException Override"}
218+
219+
220+
def test_application_adding_same_exception_twice():
221+
tm = TestClientFactory.create_test_module()
222+
with patch.object(
223+
tm.app.__class__, "rebuild_middleware_stack"
224+
) as rebuild_middleware_stack_mock:
225+
tm.app.add_exception_handler(OverrideHTTPException())
226+
rebuild_middleware_stack_mock.assert_called()
227+
228+
with patch.object(
229+
tm.app.__class__, "rebuild_middleware_stack"
230+
) as rebuild_middleware_stack_mock:
231+
tm.app.add_exception_handler(OverrideHTTPException())
232+
assert rebuild_middleware_stack_mock.call_count == 0

tests/test_modules/sample.py

Lines changed: 5 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,6 @@
11
from typing import Optional
22

3-
from ellar.common import (
4-
Body,
5-
Controller,
6-
Module,
7-
ModuleRouter,
8-
Ws,
9-
exception_handler,
10-
middleware,
11-
on_shutdown,
12-
on_startup,
13-
put,
14-
template_filter,
15-
template_global,
16-
ws_route,
17-
)
18-
from ellar.core import App, Config
3+
from ellar.common import Body, Controller, Module, ModuleRouter, Ws, put, ws_route
194
from ellar.core.connection import WebSocket
205
from ellar.core.modules import ModuleBase
216
from ellar.di import ProviderConfig
@@ -84,50 +69,13 @@ def post_mr():
8469
return {"post_mr", "OK"}
8570

8671

87-
class ModuleBaseExample(ModuleBase):
88-
_before_init_called = False
89-
_app_ready_called = False
90-
91-
@exception_handler(404)
92-
async def exception_404(cls, request, exc):
93-
pass
94-
95-
@middleware("http")
96-
async def middleware(cls, request, call_next):
97-
response = await call_next(request)
98-
return response
99-
100-
@template_global()
101-
def some_template_global(cls, n):
102-
pass
103-
104-
@template_filter()
105-
def some_template_filter(cls, n):
106-
pass
107-
108-
@classmethod
109-
def before_init(cls, config: Config) -> None:
110-
cls._before_init_called = True
111-
112-
def application_ready(self, app: "App") -> None:
113-
self.__class__._app_ready_called = True
114-
115-
@on_startup
116-
async def on_startup_handler(cls):
117-
pass
118-
119-
@on_shutdown
120-
def on_shutdown_handler(cls):
121-
pass
122-
123-
124-
ModuleBaseExample2 = type("ModuleBaseExample2", (ModuleBaseExample,), {})
125-
126-
SampleModule = Module(
72+
@Module(
12773
controllers=(SampleController,),
12874
routers=(mr,),
12975
providers=(
13076
UserService,
13177
ProviderConfig(AnotherUserService, use_value=AnotherUserService()),
13278
),
133-
)(ModuleBaseExample2)
79+
)
80+
class ModuleBaseExample(ModuleBase):
81+
pass

0 commit comments

Comments
 (0)