Skip to content

Commit 643dff3

Browse files
committed
File Storage Test: Added tests for file storage and refactored static file mounting
1 parent f924240 commit 643dff3

29 files changed

+833
-547
lines changed

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 & 22 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
@@ -73,6 +74,7 @@ def __init__(
7374
self.config.DEFAULT_LIFESPAN_HANDLER = (
7475
lifespan or self.config.DEFAULT_LIFESPAN_HANDLER
7576
)
77+
7678
self.router = ApplicationRouter(
7779
routes=self._get_module_routes(),
7880
redirect_slashes=self.config.REDIRECT_SLASHES,
@@ -81,10 +83,20 @@ def __init__(
8183
self.config.DEFAULT_LIFESPAN_HANDLER # type: ignore[arg-type]
8284
).lifespan,
8385
)
86+
if (
87+
self.config.STATIC_MOUNT_PATH
88+
and self.config.STATIC_MOUNT_PATH not in self.router.routes
89+
):
90+
self.router.append(AppStaticFileMount(self))
91+
8492
self._finalize_app_initialization()
8593
self.middleware_stack = self.build_middleware_stack()
8694
self._config_logging()
8795

96+
self.reload_event_manager += lambda app: self._update_jinja_env_filters( # type:ignore[misc]
97+
self.jinja_environment
98+
)
99+
88100
def _config_logging(self) -> None:
89101
log_level = (
90102
self.config.LOG_LEVEL.value
@@ -117,15 +129,6 @@ async def _statics_func_wrapper(
117129

118130
def _get_module_routes(self) -> t.List[BaseRoute]:
119131
_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-
)
129132

130133
for _, module_ref in self._injector.get_modules().items():
131134
_routes.extend(module_ref.routes)
@@ -157,9 +160,8 @@ def install_module(
157160
module_ref.run_module_register_services()
158161
if isinstance(module_ref, ModuleTemplateRef):
159162
self.router.extend(module_ref.routes)
160-
self.reload_static_app()
161163

162-
self.rebuild_middleware_stack()
164+
self.rebuild_stack()
163165

164166
return t.cast(T, module_ref.get_module_instance())
165167

@@ -189,12 +191,6 @@ def injector(self) -> EllarInjector:
189191
def versioning_scheme(self) -> BaseAPIVersioning:
190192
return t.cast(BaseAPIVersioning, self._config.VERSIONING_SCHEME)
191193

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-
198194
@property
199195
def config(self) -> "Config":
200196
return self._config
@@ -298,7 +294,9 @@ def _finalize_app_initialization(self) -> None:
298294
self.injector.container.register_instance(self)
299295
self.injector.container.register_instance(self.config, Config)
300296
self.injector.container.register_instance(self.jinja_environment, Environment)
301-
self.injector.container.register_instance(self.jinja_environment, Environment)
297+
self.injector.container.register_instance(
298+
self.jinja_environment, JinjaEnvironment
299+
)
302300

303301
def add_exception_handler(
304302
self,
@@ -310,10 +308,11 @@ def add_exception_handler(
310308
self._exception_handlers.append(exception_handler)
311309
_added_any = True
312310
if _added_any:
313-
self.rebuild_middleware_stack()
311+
self.rebuild_stack()
314312

315-
def rebuild_middleware_stack(self) -> None:
313+
def rebuild_stack(self) -> None:
316314
self.middleware_stack = self.build_middleware_stack()
315+
self.reload_event_manager.run(self)
317316

318317
@property
319318
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/routing/route_collections.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ def __init__(self, routes: t.Optional[t.Sequence[BaseRoute]] = None) -> None:
1515
self._served_routes: t.List[BaseRoute] = []
1616
self.extend([] if routes is None else list(routes))
1717

18+
def __contains__(self, item: t.Any) -> bool:
19+
return item in self._routes
20+
1821
@t.no_type_check
1922
def __getitem__(self, i: int) -> BaseRoute:
2023
return self._served_routes.__getitem__(i)

ellar/core/conf/app_settings_models.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ class ConfigValidationSchema(Serializer, ConfigDefaultTypesMixin):
7575

7676
STATIC_DIRECTORIES: t.Optional[t.List[t.Union[str, t.Any]]] = []
7777

78-
STATIC_MOUNT_PATH: str = "/static"
78+
STATIC_MOUNT_PATH: t.Optional[str] = "/static"
7979

8080
CORS_ALLOW_ORIGINS: t.List[str] = []
8181
CORS_ALLOW_METHODS: t.List[str] = ["GET"]
@@ -139,7 +139,8 @@ def serializer_custom_encoder(cls, value: t.Any) -> t.Any:
139139

140140
@field_validator("STATIC_MOUNT_PATH", mode="before")
141141
def pre_static_mount_path(cls, value: t.Any) -> t.Any:
142-
assert value.startswith("/"), "Routed paths must start with '/'"
142+
if value:
143+
assert value.startswith("/"), "Routed paths must start with '/'"
143144
return value
144145

145146
@field_validator("CACHES", mode="before")

ellar/core/conf/mixins.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ class ConfigDefaultTypesMixin:
5050
EXCEPTION_HANDLERS: t.List[IExceptionHandler]
5151

5252
# static route
53-
STATIC_MOUNT_PATH: str
53+
STATIC_MOUNT_PATH: t.Optional[str]
5454

5555
# defines other custom json encoders
5656
SERIALIZER_CUSTOM_ENCODER: t.Dict[t.Any, t.Callable[[t.Any], t.Any]]

ellar/core/files/storages/aws_s3.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
1+
import typing as t
2+
import urllib.parse
3+
from io import BytesIO
4+
15
try:
26
import boto3
37
except ImportError as im_ex: # pragma: no cover
48
raise RuntimeError(
59
"boto3 must be installed to use the 'S3AWSFileStorage' class."
610
) from im_ex
7-
import typing as t
8-
import urllib.parse
9-
from io import BytesIO
1011

1112
from .base import BaseStorage
1213

1314

14-
class S3AWSFileStorage(BaseStorage):
15+
class S3AWSFileStorage(BaseStorage): # pragma: no cover
1516
def service_name(self) -> str:
1617
return "s3_bucket"
1718

@@ -66,8 +67,9 @@ def get_aws_bucket(
6667

6768
def get_s3_path(self, filename: str) -> str:
6869
if self._prefix:
69-
return "{0}/{1}".format(self._prefix, filename)
70-
return filename
70+
filename = "{0}/{1}".format(self._prefix, filename)
71+
self.validate_file_name(filename)
72+
return self.generate_filename(filename)
7173

7274
def _upload_file(
7375
self, filename: str, data: str, content_type: t.Optional[str], rrs: bool = False

ellar/core/files/storages/base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import pathlib
33
from abc import ABC
44

5-
from .exceptions import SuspiciousFileOperation
5+
from .exceptions import UnsafeFileOperation
66
from .interface import Storage
77
from .utils import get_valid_filename, validate_file_name
88

@@ -26,7 +26,7 @@ def generate_filename(self, filename: str) -> str:
2626
# `filename` may include a path as returned by FileField.upload_to.
2727
dirname, filename = os.path.split(filename)
2828
if ".." in pathlib.PurePath(dirname).parts:
29-
raise SuspiciousFileOperation(
29+
raise UnsafeFileOperation(
3030
"Detected path traversal attempt in '%s'" % dirname
3131
)
3232
return os.path.normpath(os.path.join(dirname, get_valid_filename(filename)))
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
class SuspiciousOperation(Exception):
1+
class UnsafeOperation(Exception):
22
"""The user did something suspicious"""
33

44

5-
class SuspiciousFileOperation(SuspiciousOperation):
5+
class UnsafeFileOperation(UnsafeOperation):
66
"""A Suspicious filesystem operation was attempted"""
77

88
pass

0 commit comments

Comments
 (0)