Skip to content

Commit 4d89c90

Browse files
authored
Merge pull request #147 from python-ellar/file_system
File System
2 parents 3a67aae + 8fe6153 commit 4d89c90

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

51 files changed

+1435
-675
lines changed

.coveragerc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,7 @@ exclude_lines =
1212
if __name__ == .__main__.:
1313
class .*\bProtocol\):
1414
@(abc\.)?abstractmethod
15+
[run]
16+
omit =
17+
# omit this single file
18+
ellar/core/files/storages/aws_s3.py

docs/techniques/configurations.md

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -138,13 +138,17 @@ It defines a list of `IExceptionHandler` objects used in handling custom excepti
138138
### **STATIC_MOUNT_PATH**
139139
Default: `/static`
140140

141-
It configures the root path to get to static files. eg `http://localhost:8000/static/stylesheet.css`.
142-
And if for instance `STATIC_MOUNT_PATH`=`'/my-static'`, then the route becomes `http://localhost:8000/my-static/stylesheet.css`
141+
It configures the root path to serve static files.
142+
For example, if there is a `stylesheet.css` in a **static** folder, and `STATIC_MOUNT_PATH=/static`,
143+
`stylesheet.css` can be reached through the link
144+
`http://localhost:8000/static/stylesheet.css` assuming you are on local development server.
145+
146+
Also, if `STATIC_MOUNT_PATH=None`, static route handler would not be registered to application routes.
143147

144148
### **SERIALIZER_CUSTOM_ENCODER**
145-
Default: `ENCODERS_BY_TYPE` (`pydantic.json.ENCODERS_BY_TYPE`)
149+
Default: `ENCODERS_BY_TYPE` (`ellar.pydantic.ENCODERS_BY_TYPE`)
146150

147-
**SERIALIZER_CUSTOM_ENCODER** is a key-value pair of type and function. Default is a pydantic JSON encode type.
151+
**SERIALIZER_CUSTOM_ENCODER** is a key-value pair of a type and function. Default is a pydantic JSON encode type.
148152
It is used when serializing objects to JSON format.
149153

150154
### **DEFAULT_NOT_FOUND_HANDLER**
@@ -183,7 +187,8 @@ together instead of having a separate handler for `startup` and `shutdown` event
183187

184188
```python
185189
import contextlib
186-
from ellar.core import App, ConfigDefaultTypesMixin
190+
from ellar.core import ConfigDefaultTypesMixin
191+
from ellar.app import App
187192

188193

189194
@contextlib.asynccontextmanager
@@ -271,7 +276,7 @@ To apply these configurations without having to load everything, you have to pro
271276
belongs to ellar. For example,
272277

273278
```python
274-
from ellar.core.factory import AppFactory
279+
from ellar.app import AppFactory
275280
from .root_module import ApplicationModule
276281

277282
application = AppFactory.create_from_app_module(ApplicationModule, config_module=dict(
@@ -286,13 +291,16 @@ This will be applied to the configuration instance when the application is ready
286291
During application bootstrapping with `AppFactory`, you can define app configurations directly under `config_module` as a dict object as some below.
287292

288293
```python
289-
from ellar.core.factory import AppFactory
294+
from ellar.app import AppFactory
290295
from .root_module import ApplicationModule
291296

292-
application = AppFactory.create_from_app_module(ApplicationModule, config_module=dict(
293-
SECRET_KEY = "your-secret-key-changed",
294-
INJECTOR_AUTO_BIND = True,
295-
MIDDLEWARE=[],
296-
EXCEPTION_HANDLERS=[]
297-
))
297+
application = AppFactory.create_from_app_module(
298+
ApplicationModule,
299+
config_module=dict(
300+
SECRET_KEY = "your-secret-key-changed",
301+
INJECTOR_AUTO_BIND = True,
302+
MIDDLEWARE=[],
303+
EXCEPTION_HANDLERS=[]
304+
)
305+
)
298306
```

ellar/app/factory.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,7 @@ def _get_config_kwargs() -> t.Dict:
167167

168168
if module_changed:
169169
app.router.extend(routes)
170-
app.reload_static_app()
171-
app.rebuild_middleware_stack()
170+
app.rebuild_stack()
172171

173172
return app
174173

ellar/app/main.py

Lines changed: 21 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,12 @@
3131
ModuleSetup,
3232
ModuleTemplateRef,
3333
)
34-
from ellar.core.routing import ApplicationRouter
34+
from ellar.core.routing import ApplicationRouter, AppStaticFileMount
3535
from ellar.core.services import Reflector
3636
from ellar.core.versioning import BaseAPIVersioning, VersioningSchemes
3737
from ellar.di import EllarInjector
38-
from starlette.routing import BaseRoute, Mount
38+
from jinja2 import Environment as JinjaEnvironment
39+
from starlette.routing import BaseRoute
3940

4041
from .lifespan import EllarApplicationLifespan
4142
from .mixin import AppMixin
@@ -67,12 +68,11 @@ def __init__(
6768

6869
self._user_middleware = list(t.cast(list, self.config.MIDDLEWARE))
6970

70-
self._static_app: t.Optional[ASGIApp] = None
71-
7271
self.state = State()
7372
self.config.DEFAULT_LIFESPAN_HANDLER = (
7473
lifespan or self.config.DEFAULT_LIFESPAN_HANDLER
7574
)
75+
7676
self.router = ApplicationRouter(
7777
routes=self._get_module_routes(),
7878
redirect_slashes=self.config.REDIRECT_SLASHES,
@@ -81,10 +81,20 @@ def __init__(
8181
self.config.DEFAULT_LIFESPAN_HANDLER # type: ignore[arg-type]
8282
).lifespan,
8383
)
84+
if (
85+
self.config.STATIC_MOUNT_PATH
86+
and self.config.STATIC_MOUNT_PATH not in self.router.routes
87+
):
88+
self.router.append(AppStaticFileMount(self))
89+
8490
self._finalize_app_initialization()
8591
self.middleware_stack = self.build_middleware_stack()
8692
self._config_logging()
8793

94+
self.reload_event_manager += lambda app: self._update_jinja_env_filters( # type:ignore[misc]
95+
self.jinja_environment
96+
)
97+
8898
def _config_logging(self) -> None:
8999
log_level = (
90100
self.config.LOG_LEVEL.value
@@ -106,26 +116,8 @@ def _config_logging(self) -> None:
106116
logging.getLogger("ellar").setLevel(log_level)
107117
logging.getLogger("ellar.request").setLevel(log_level)
108118

109-
def _statics_wrapper(self) -> t.Callable:
110-
async def _statics_func_wrapper(
111-
scope: TScope, receive: TReceive, send: TSend
112-
) -> t.Any:
113-
assert self._static_app, 'app static ASGIApp can not be "None"'
114-
return await self._static_app(scope, receive, send)
115-
116-
return _statics_func_wrapper
117-
118119
def _get_module_routes(self) -> t.List[BaseRoute]:
119120
_routes: t.List[BaseRoute] = []
120-
if self.has_static_files:
121-
self._static_app = self.create_static_app()
122-
_routes.append(
123-
Mount(
124-
str(self.config.STATIC_MOUNT_PATH),
125-
app=self._statics_wrapper(),
126-
name="static",
127-
)
128-
)
129121

130122
for _, module_ref in self._injector.get_modules().items():
131123
_routes.extend(module_ref.routes)
@@ -157,9 +149,8 @@ def install_module(
157149
module_ref.run_module_register_services()
158150
if isinstance(module_ref, ModuleTemplateRef):
159151
self.router.extend(module_ref.routes)
160-
self.reload_static_app()
161152

162-
self.rebuild_middleware_stack()
153+
self.rebuild_stack()
163154

164155
return t.cast(T, module_ref.get_module_instance())
165156

@@ -189,12 +180,6 @@ def injector(self) -> EllarInjector:
189180
def versioning_scheme(self) -> BaseAPIVersioning:
190181
return t.cast(BaseAPIVersioning, self._config.VERSIONING_SCHEME)
191182

192-
@property
193-
def has_static_files(self) -> bool: # type: ignore
194-
return (
195-
True if self.static_files or self.config.STATIC_FOLDER_PACKAGES else False
196-
)
197-
198183
@property
199184
def config(self) -> "Config":
200185
return self._config
@@ -298,7 +283,9 @@ def _finalize_app_initialization(self) -> None:
298283
self.injector.container.register_instance(self)
299284
self.injector.container.register_instance(self.config, Config)
300285
self.injector.container.register_instance(self.jinja_environment, Environment)
301-
self.injector.container.register_instance(self.jinja_environment, Environment)
286+
self.injector.container.register_instance(
287+
self.jinja_environment, JinjaEnvironment
288+
)
302289

303290
def add_exception_handler(
304291
self,
@@ -310,10 +297,11 @@ def add_exception_handler(
310297
self._exception_handlers.append(exception_handler)
311298
_added_any = True
312299
if _added_any:
313-
self.rebuild_middleware_stack()
300+
self.rebuild_stack()
314301

315-
def rebuild_middleware_stack(self) -> None:
302+
def rebuild_stack(self) -> None:
316303
self.middleware_stack = self.build_middleware_stack()
304+
self.reload_event_manager.run(self)
317305

318306
@property
319307
def reflector(self) -> Reflector:

ellar/app/mixin.py

Lines changed: 4 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import json
22
import typing as t
3-
from abc import abstractmethod
43

54
from ellar.common.compatible import cached_property
65
from ellar.common.constants import TEMPLATE_FILTER_KEY, TEMPLATE_GLOBAL_KEY
@@ -14,8 +13,8 @@
1413
from ellar.common.types import ASGIApp
1514
from ellar.core.conf import Config
1615
from ellar.core.connection import Request
17-
from ellar.core.staticfiles import StaticFiles
1816
from ellar.di import EllarInjector
17+
from ellar.events import EventManager
1918
from jinja2 import Environment as BaseEnvironment
2019
from starlette.templating import pass_context
2120

@@ -27,15 +26,8 @@ class AppMixin(JinjaTemplating):
2726
_static_app: t.Optional[ASGIApp]
2827
_injector: EllarInjector
2928
_config: Config
30-
has_static_files: bool
31-
32-
@abstractmethod
33-
def build_middleware_stack(self) -> t.Callable: # pragma: no cover
34-
pass
35-
36-
@abstractmethod
37-
def rebuild_middleware_stack(self) -> None: # pragma: no cover
38-
pass
29+
rebuild_stack: t.Callable
30+
reload_event_manager = EventManager()
3931

4032
def get_module_loaders(self) -> t.Generator[ModuleTemplating, None, None]:
4133
for loader in self._injector.get_templating_modules().values():
@@ -50,7 +42,7 @@ def debug(self, value: bool) -> None:
5042
del self.__dict__["jinja_environment"]
5143
self._config.DEBUG = value
5244
# TODO: Add warning
53-
self.rebuild_middleware_stack()
45+
self.rebuild_stack()
5446

5547
@cached_property
5648
def jinja_environment(self) -> BaseEnvironment: # type: ignore[override]
@@ -94,26 +86,6 @@ def url_for(context: dict, name: str, **path_params: t.Any) -> URL:
9486
def create_global_jinja_loader(self) -> JinjaLoader:
9587
return JinjaLoader(t.cast("App", self))
9688

97-
def create_static_app(self) -> ASGIApp:
98-
return StaticFiles(
99-
directories=self.static_files, # type: ignore[arg-type]
100-
packages=self._config.STATIC_FOLDER_PACKAGES,
101-
)
102-
103-
def reload_static_app(self) -> None:
104-
del self.__dict__["static_files"]
105-
if self.has_static_files:
106-
self._static_app = self.create_static_app()
107-
self._update_jinja_env_filters(self.jinja_environment)
108-
10989
def _update_jinja_env_filters(self, jinja_environment: BaseEnvironment) -> None:
11090
jinja_environment.globals.update(self._config.get(TEMPLATE_GLOBAL_KEY, {}))
11191
jinja_environment.filters.update(self._config.get(TEMPLATE_FILTER_KEY, {}))
112-
113-
@cached_property
114-
def static_files(self) -> t.List[str]:
115-
static_directories = t.cast(t.List, self._config.STATIC_DIRECTORIES or [])
116-
for module in self.get_module_loaders():
117-
if module.static_directory:
118-
static_directories.append(module.static_directory)
119-
return static_directories

ellar/common/datastructures.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import typing as t
2+
from io import BytesIO, StringIO
23

34
from ellar.pydantic import as_pydantic_validator
45
from starlette.datastructures import (
@@ -36,6 +37,7 @@
3637
"UploadFile",
3738
"URLPath",
3839
"State",
40+
"ContentFile",
3941
]
4042

4143

@@ -89,3 +91,18 @@ def __validate_input__(cls, __input_value: t.Any, _: t.Any) -> "UploadFile":
8991
filename=__input_value.filename,
9092
headers=__input_value.headers,
9193
)
94+
95+
96+
class ContentFile(UploadFile):
97+
"""
98+
A File-like object that takes just raw content, rather than an actual file.
99+
"""
100+
101+
def __init__(
102+
self, content: t.Union[str, bytes], name: t.Optional[str] = None
103+
) -> None:
104+
stream_class = StringIO if isinstance(content, str) else BytesIO
105+
headers = Headers({"content-type": "text/plain"})
106+
super().__init__(
107+
stream_class(content), filename=name, size=len(content), headers=headers
108+
)

ellar/common/decorators/controller.py

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -48,28 +48,35 @@ def reflect_all_controller_type_routes(cls: t.Type[ControllerBase]) -> None:
4848
)
4949
reflect.define_metadata(CONTROLLER_CLASS_KEY, cls, item)
5050

51-
parameter = item.__dict__[ROUTE_OPERATION_PARAMETERS]
51+
parameters = item.__dict__[ROUTE_OPERATION_PARAMETERS]
5252
operation: t.Union[
5353
ControllerRouteOperation, ControllerWebsocketRouteOperation
5454
]
55-
if isinstance(parameter, RouteParameters):
56-
operation = ControllerRouteOperation(**parameter.dict())
57-
elif isinstance(parameter, WsRouteParameters):
58-
operation = ControllerWebsocketRouteOperation(**parameter.dict())
59-
else: # pragma: no cover
60-
logger.warning(
61-
f"Parameter type is not recognized. {type(parameter) if not isinstance(parameter, type) else parameter}"
55+
56+
if not isinstance(parameters, list):
57+
parameters = [parameters]
58+
59+
for parameter in parameters:
60+
if isinstance(parameter, RouteParameters):
61+
operation = ControllerRouteOperation(**parameter.dict())
62+
elif isinstance(parameter, WsRouteParameters):
63+
operation = ControllerWebsocketRouteOperation(
64+
**parameter.dict()
65+
)
66+
else: # pragma: no cover
67+
logger.warning(
68+
f"Parameter type is not recognized. {type(parameter) if not isinstance(parameter, type) else parameter}"
69+
)
70+
continue
71+
72+
reflect.define_metadata(
73+
CONTROLLER_OPERATION_HANDLER_KEY,
74+
[operation],
75+
cls,
6276
)
63-
continue
6477

6578
del item.__dict__[ROUTE_OPERATION_PARAMETERS]
6679

67-
reflect.define_metadata(
68-
CONTROLLER_OPERATION_HANDLER_KEY,
69-
[operation],
70-
cls,
71-
)
72-
7380

7481
@t.no_type_check
7582
def Controller(

ellar/common/responses/models/base.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from ellar.common.exceptions import RequestValidationError
77
from ellar.common.interfaces import IExecutionContext, IResponseModel
88
from ellar.common.logging import request_logger
9-
from ellar.common.serializer import BaseSerializer, SerializerFilter
9+
from ellar.common.serializer import BaseSerializer, SerializerFilter, serialize_object
1010
from ellar.pydantic import ModelField, create_model_field
1111
from ellar.reflect import reflect
1212
from starlette.responses import Response
@@ -204,7 +204,18 @@ def serialize(
204204
response_obj: t.Any,
205205
serializer_filter: t.Optional[SerializerFilter] = None,
206206
) -> t.Union[t.List[t.Dict], t.Dict, t.Any]:
207-
return response_obj
207+
return self._serialize_with_serializer_object(response_obj)
208+
209+
def _serialize_with_serializer_object(
210+
self,
211+
response_obj: t.Any,
212+
serializer_filter: t.Optional[SerializerFilter] = None,
213+
) -> t.Union[t.List[t.Dict], t.Dict, t.Any]:
214+
try:
215+
return serialize_object(response_obj, serializer_filter=serializer_filter)
216+
except Exception: # pragma: no cover
217+
"""Could not serialize response obj"""
218+
return response_obj
208219

209220

210221
class ResponseResolver(t.NamedTuple):

0 commit comments

Comments
 (0)