Skip to content

Commit 4756aec

Browse files
authored
Merge pull request #146 from python-ellar/lazy_import
Feat: Lazy Object
2 parents 7dc9f25 + 32159e9 commit 4756aec

31 files changed

+1028
-53
lines changed

ellar/app/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
from .context import current_app, current_config, current_injector
12
from .factory import AppFactory
23
from .main import App
34

45
__all__ = [
56
"App",
67
"AppFactory",
8+
"current_config",
9+
"current_injector",
10+
"current_app",
711
]

ellar/app/context.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import os
2+
import typing as t
3+
from contextvars import ContextVar
4+
from types import TracebackType
5+
6+
from ellar.common.constants import ELLAR_CONFIG_MODULE
7+
from ellar.common.utils.functional import SimpleLazyObject, empty
8+
from ellar.core import Config
9+
from ellar.di import EllarInjector
10+
11+
if t.TYPE_CHECKING:
12+
from ellar.app.main import App
13+
14+
_application_context: ContextVar[
15+
t.Optional[t.Union["ApplicationContext", t.Any]]
16+
] = ContextVar("ellar.app.context")
17+
18+
19+
class ApplicationContext:
20+
"""
21+
Provides Necessary Application Properties when running Ellar CLI commands and when serving request.
22+
23+
"""
24+
25+
__slots__ = ("_injector", "_config", "_app")
26+
27+
def __init__(self, config: Config, injector: EllarInjector, app: "App") -> None:
28+
assert isinstance(config, Config), "config must instance of Config"
29+
assert isinstance(
30+
injector, EllarInjector
31+
), "injector must instance of EllarInjector"
32+
33+
self._injector = injector
34+
self._config = config
35+
self._app = app
36+
37+
@property
38+
def app(self) -> "App":
39+
return self._app
40+
41+
@property
42+
def injector(self) -> EllarInjector:
43+
return self._injector
44+
45+
@property
46+
def config(self) -> Config:
47+
return self._config
48+
49+
def __enter__(self) -> "ApplicationContext":
50+
app_context = _application_context.get(empty)
51+
if app_context is empty:
52+
# If app_context exist
53+
_application_context.set(self)
54+
if current_config._wrapped is not empty: # pragma: no cover
55+
# ensure current_config is in sync with running application context.
56+
current_config._wrapped = self.config
57+
app_context = self
58+
return app_context # type:ignore[return-value]
59+
60+
def __exit__(
61+
self,
62+
exc_type: t.Optional[t.Any],
63+
exc_value: t.Optional[BaseException],
64+
tb: t.Optional[TracebackType],
65+
) -> None:
66+
_application_context.set(empty)
67+
68+
current_app._wrapped = empty # type:ignore[attr-defined]
69+
current_injector._wrapped = empty # type:ignore[attr-defined]
70+
current_config._wrapped = empty
71+
72+
@classmethod
73+
def create(cls, app: "App") -> "ApplicationContext":
74+
return cls(app.config, app.injector, app)
75+
76+
77+
def _get_current_app() -> "App":
78+
app_context = _application_context.get(empty)
79+
if app_context is empty:
80+
raise RuntimeError("ApplicationContext is not available at this scope.")
81+
82+
return app_context.app # type:ignore[union-attr]
83+
84+
85+
def _get_injector() -> EllarInjector:
86+
app_context = _application_context.get(empty)
87+
if app_context is empty:
88+
raise RuntimeError("ApplicationContext is not available at this scope.")
89+
90+
return app_context.injector # type:ignore[union-attr]
91+
92+
93+
def _get_application_config() -> Config:
94+
app_context = _application_context.get(empty)
95+
if app_context is empty:
96+
config_module = os.environ.get(ELLAR_CONFIG_MODULE)
97+
if not config_module:
98+
raise RuntimeError(
99+
"You are trying to access app config outside app context "
100+
"and %s is not specified. This may cause differences in config "
101+
"values when the app." % (ELLAR_CONFIG_MODULE,)
102+
)
103+
return Config(config_module=config_module)
104+
105+
return app_context.config # type:ignore[union-attr]
106+
107+
108+
current_app: "App" = t.cast("App", SimpleLazyObject(func=_get_current_app))
109+
current_injector: EllarInjector = t.cast(
110+
EllarInjector, SimpleLazyObject(func=_get_injector)
111+
)
112+
current_config: Config = t.cast(Config, SimpleLazyObject(func=_get_application_config))

ellar/app/factory.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,10 @@ def _get_config_kwargs() -> t.Dict:
130130
assert reflect.get_metadata(MODULE_WATERMARK, module), "Only Module is allowed"
131131

132132
config = Config(app_configured=True, **_get_config_kwargs())
133+
133134
injector = EllarInjector(auto_bind=config.INJECTOR_AUTO_BIND)
134135
injector.container.register_instance(config, concrete_type=Config)
136+
135137
service = EllarAppService(injector, config)
136138
service.register_core_services()
137139

ellar/app/lifespan.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import typing as t
22

33
from anyio import create_task_group
4-
from ellar.common import IApplicationShutdown, IApplicationStartup, logger
4+
from ellar.common import IApplicationShutdown, IApplicationStartup
5+
from ellar.common.logger import logger
56
from ellar.reflect import asynccontextmanager
67

7-
if t.TYPE_CHECKING: # pragma: no cover
8+
if t.TYPE_CHECKING:
89
from ellar.app import App
910

1011
_T = t.TypeVar("_T")
@@ -51,15 +52,17 @@ async def run_all_shutdown_actions(self, app: "App") -> None:
5152

5253
@asynccontextmanager
5354
async def lifespan(self, app: "App") -> t.AsyncIterator[t.Any]:
54-
logger.logger.debug("Executing Modules Startup Handlers")
55+
logger.debug("Executing Modules Startup Handlers")
5556

5657
async with create_task_group() as tg:
5758
tg.start_soon(self.run_all_startup_actions, app)
5859

5960
try:
6061
async with self._lifespan_context(app) as ctx: # type:ignore[attr-defined]
62+
logger.info("Application is ready.")
6163
yield ctx
6264
finally:
63-
logger.logger.debug("Executing Modules Shutdown Handlers")
65+
logger.debug("Executing Modules Shutdown Handlers")
6466
async with create_task_group() as tg:
6567
tg.start_soon(self.run_all_shutdown_actions, app)
68+
logger.info("Application shutdown successfully.")

ellar/app/main.py

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,17 @@
22
import logging.config
33
import typing as t
44

5+
from ellar.app.context import ApplicationContext
56
from ellar.auth.handlers import AuthenticationHandlerType
67
from ellar.common import GlobalGuard, IIdentitySchemes
78
from ellar.common.compatible import cached_property
89
from ellar.common.constants import ELLAR_LOG_FMT_STRING, LOG_LEVELS
910
from ellar.common.datastructures import State, URLPath
1011
from ellar.common.interfaces import IExceptionHandler, IExceptionMiddlewareService
11-
from ellar.common.logger import logger
1212
from ellar.common.models import EllarInterceptor, GuardCanActivate
1313
from ellar.common.templating import Environment
1414
from ellar.common.types import ASGIApp, T, TReceive, TScope, TSend
15+
from ellar.core import reflector
1516
from ellar.core.conf import Config
1617
from ellar.core.middleware import (
1718
CORSMiddleware,
@@ -32,15 +33,15 @@
3233
)
3334
from ellar.core.routing import ApplicationRouter
3435
from ellar.core.services import Reflector
35-
from ellar.core.templating import AppTemplating
3636
from ellar.core.versioning import BaseAPIVersioning, VersioningSchemes
37-
from ellar.di.injector import EllarInjector
37+
from ellar.di import EllarInjector
3838
from starlette.routing import BaseRoute, Mount
3939

4040
from .lifespan import EllarApplicationLifespan
41+
from .mixin import AppMixin
4142

4243

43-
class App(AppTemplating):
44+
class App(AppMixin):
4445
def __init__(
4546
self,
4647
config: "Config",
@@ -105,9 +106,6 @@ def _config_logging(self) -> None:
105106
logging.getLogger("ellar").setLevel(log_level)
106107
logging.getLogger("ellar.request").setLevel(log_level)
107108

108-
logger.info(f"APP SETTINGS MODULE: {self.config.config_module}")
109-
logger.debug("ELLAR LOGGER CONFIGURED")
110-
111109
def _statics_wrapper(self) -> t.Callable:
112110
async def _statics_func_wrapper(
113111
scope: TScope, receive: TReceive, send: TSend
@@ -198,7 +196,7 @@ def has_static_files(self) -> bool: # type: ignore
198196
)
199197

200198
@property
201-
def config(self) -> "Config": # type: ignore
199+
def config(self) -> "Config":
202200
return self._config
203201

204202
def build_middleware_stack(self) -> ASGIApp:
@@ -258,9 +256,23 @@ def build_middleware_stack(self) -> ASGIApp:
258256
app = item(app=app, injector=self.injector)
259257
return app
260258

259+
def application_context(self) -> ApplicationContext:
260+
"""
261+
Create an ApplicationContext.
262+
Use as a contextmanager block to make `current_app`, `current_injector` and `current_config` point at this application.
263+
264+
It can be used manually outside ellar cli commands or request,
265+
e.g.,
266+
with app.application_context():
267+
assert current_app is app
268+
run_some_actions()
269+
"""
270+
return ApplicationContext.create(app=self)
271+
261272
async def __call__(self, scope: TScope, receive: TReceive, send: TSend) -> None:
262-
scope["app"] = self
263-
await self.middleware_stack(scope, receive, send)
273+
with self.application_context() as ctx:
274+
scope["app"] = ctx.app
275+
await self.middleware_stack(scope, receive, send)
264276

265277
@property
266278
def routes(self) -> t.List[BaseRoute]:
@@ -303,9 +315,9 @@ def add_exception_handler(
303315
def rebuild_middleware_stack(self) -> None:
304316
self.middleware_stack = self.build_middleware_stack()
305317

306-
@cached_property
318+
@property
307319
def reflector(self) -> Reflector:
308-
return self.injector.get(Reflector) # type: ignore[no-any-return]
320+
return reflector
309321

310322
@cached_property
311323
def __identity_scheme(self) -> IIdentitySchemes:

ellar/core/templating/app.py renamed to ellar/app/mixin.py

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,21 @@
1212
ModuleTemplating,
1313
)
1414
from ellar.common.types import ASGIApp
15+
from ellar.core.conf import Config
1516
from ellar.core.connection import Request
1617
from ellar.core.staticfiles import StaticFiles
18+
from ellar.di import EllarInjector
1719
from jinja2 import Environment as BaseEnvironment
1820
from starlette.templating import pass_context
1921

20-
if t.TYPE_CHECKING: # pragma: no cover
21-
from ellar.core.conf import Config
22-
from ellar.core.main import App
23-
from ellar.di import EllarInjector
22+
if t.TYPE_CHECKING:
23+
from .main import App
2424

2525

26-
class AppTemplating(JinjaTemplating):
27-
config: "Config"
26+
class AppMixin(JinjaTemplating):
2827
_static_app: t.Optional[ASGIApp]
29-
_injector: "EllarInjector"
28+
_injector: EllarInjector
29+
_config: Config
3030
has_static_files: bool
3131

3232
@abstractmethod
@@ -43,12 +43,12 @@ def get_module_loaders(self) -> t.Generator[ModuleTemplating, None, None]:
4343

4444
@property
4545
def debug(self) -> bool:
46-
return self.config.DEBUG
46+
return self._config.DEBUG
4747

4848
@debug.setter
4949
def debug(self, value: bool) -> None:
5050
del self.__dict__["jinja_environment"]
51-
self.config.DEBUG = value
51+
self._config.DEBUG = value
5252
# TODO: Add warning
5353
self.rebuild_middleware_stack()
5454

@@ -70,7 +70,7 @@ def select_jinja_auto_escape(filename: str) -> bool:
7070
"autoescape": select_jinja_auto_escape,
7171
}
7272
jinja_options: t.Dict = t.cast(
73-
t.Dict, self.config.JINJA_TEMPLATES_OPTIONS or {}
73+
t.Dict, self._config.JINJA_TEMPLATES_OPTIONS or {}
7474
)
7575

7676
for k, v in options_defaults.items():
@@ -81,12 +81,12 @@ def url_for(context: dict, name: str, **path_params: t.Any) -> URL:
8181
request = t.cast(Request, context["request"])
8282
return request.url_for(name, **path_params)
8383

84-
app: App = t.cast("App", self)
84+
app: "App" = t.cast("App", self)
8585

8686
jinja_env = Environment(app, **jinja_options)
8787
jinja_env.globals.update(
8888
url_for=url_for,
89-
config=self.config,
89+
config=self._config,
9090
)
9191
jinja_env.policies["json.dumps_function"] = json.dumps
9292
return jinja_env
@@ -97,7 +97,7 @@ def create_global_jinja_loader(self) -> JinjaLoader:
9797
def create_static_app(self) -> ASGIApp:
9898
return StaticFiles(
9999
directories=self.static_files, # type: ignore[arg-type]
100-
packages=self.config.STATIC_FOLDER_PACKAGES,
100+
packages=self._config.STATIC_FOLDER_PACKAGES,
101101
)
102102

103103
def reload_static_app(self) -> None:
@@ -107,12 +107,12 @@ def reload_static_app(self) -> None:
107107
self._update_jinja_env_filters(self.jinja_environment)
108108

109109
def _update_jinja_env_filters(self, jinja_environment: BaseEnvironment) -> None:
110-
jinja_environment.globals.update(self.config.get(TEMPLATE_GLOBAL_KEY, {}))
111-
jinja_environment.filters.update(self.config.get(TEMPLATE_FILTER_KEY, {}))
110+
jinja_environment.globals.update(self._config.get(TEMPLATE_GLOBAL_KEY, {}))
111+
jinja_environment.filters.update(self._config.get(TEMPLATE_FILTER_KEY, {}))
112112

113113
@cached_property
114114
def static_files(self) -> t.List[str]:
115-
static_directories = t.cast(t.List, self.config.STATIC_DIRECTORIES or [])
115+
static_directories = t.cast(t.List, self._config.STATIC_DIRECTORIES or [])
116116
for module in self.get_module_loaders():
117117
if module.static_directory:
118118
static_directories.append(module.static_directory)

ellar/app/services.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@
1212
IInterceptorsConsumer,
1313
IWebSocketContextFactory,
1414
)
15-
from ellar.core.context import ExecutionContextFactory, HostContextFactory
16-
from ellar.core.context.factory import (
15+
from ellar.core.exceptions.service import ExceptionMiddlewareService
16+
from ellar.core.execution_context import ExecutionContextFactory, HostContextFactory
17+
from ellar.core.execution_context.factory import (
1718
HTTPConnectionContextFactory,
1819
WebSocketContextFactory,
1920
)
20-
from ellar.core.exceptions.service import ExceptionMiddlewareService
2121
from ellar.core.guards import GuardConsumer
2222
from ellar.core.interceptors import EllarInterceptorConsumer
2323
from ellar.core.services import Reflector, reflector

ellar/common/datastructures.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,4 +89,4 @@ def __get_validators__(
8989
def validate(cls: t.Type["UploadFile"], v: t.Any) -> t.Any:
9090
if not isinstance(v, StarletteUploadFile):
9191
raise ValueError(f"Expected UploadFile, received: {type(v)}")
92-
return v
92+
return cls(v.file, size=v.size, filename=v.filename, headers=v.headers)

ellar/common/routing/schema.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,10 @@
33
from ellar.common.constants import ROUTE_METHODS
44
from ellar.common.interfaces import IResponseModel
55
from ellar.common.responses.models import EmptyAPIResponseModel, create_response_model
6+
from ellar.common.routing.websocket import WebSocketExtraHandler
67
from ellar.common.serializer import BaseSerializer
78
from pydantic import BaseModel, Field, PrivateAttr, root_validator, validator
89

9-
if t.TYPE_CHECKING: # pragma: no cover
10-
from ellar.common.routing.websocket import WebSocketExtraHandler
11-
1210

1311
class TResponseModel:
1412
@classmethod

0 commit comments

Comments
 (0)