mvcx 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mvcx-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.3
2
+ Name: mvcx
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Author: IronRomulus
6
+ Author-email: IronRomulus <marco.possamai@romutech.net>
7
+ Requires-Dist: granian>=2.7.5
8
+ Requires-Dist: jinja2>=3.1.6
9
+ Requires-Dist: orjson>=3.11.9
10
+ Requires-Dist: httptools>=0.8.0 ; extra == 'asgi'
11
+ Requires-Dist: priority>=2.0.0 ; extra == 'asgi'
12
+ Requires-Dist: uvloop>=0.22.1 ; extra == 'asgi'
13
+ Requires-Dist: websockets>=16.0 ; extra == 'asgi'
14
+ Requires-Dist: mvcx[asgi] ; extra == 'dev'
15
+ Requires-Dist: watchfiles>=1.2.0 ; extra == 'dev'
16
+ Requires-Python: >=3.14
17
+ Provides-Extra: asgi
18
+ Provides-Extra: dev
19
+ Description-Content-Type: text/markdown
20
+
mvcx-0.1.0/README.md ADDED
File without changes
@@ -0,0 +1,42 @@
1
+ [project]
2
+ name = "mvcx"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "IronRomulus", email = "marco.possamai@romutech.net" }
8
+ ]
9
+ requires-python = ">=3.14"
10
+ dependencies = [
11
+ "granian>=2.7.5",
12
+ "jinja2>=3.1.6",
13
+ "orjson>=3.11.9",
14
+ ]
15
+
16
+ [project.scripts]
17
+ mvcx = "mvcx:main"
18
+
19
+ [project.optional-dependencies]
20
+ asgi = [
21
+ "httptools>=0.8.0",
22
+ "priority>=2.0.0",
23
+ "uvloop>=0.22.1",
24
+ "websockets>=16.0",
25
+ ]
26
+
27
+ dev = [
28
+ "mvcx[asgi]",
29
+ "watchfiles>=1.2.0",
30
+ ]
31
+
32
+ [build-system]
33
+ requires = ["uv_build>=0.11.13,<0.12.0"]
34
+ build-backend = "uv_build"
35
+
36
+ [tool.pyright]
37
+ extraPaths = ["src"]
38
+ typeCheckingMode = "strict"
39
+ ignore = ["**/__pycache__"]
40
+
41
+ [tool.uv.sources]
42
+ mvcx = { workspace = true }
File without changes
@@ -0,0 +1,337 @@
1
+ import pathlib
2
+ from asyncio import AbstractEventLoop
3
+ from typing import Literal, Self, cast
4
+
5
+ from granian import Granian
6
+ from granian.constants import Interfaces
7
+ from granian.log import LogLevels
8
+
9
+ from mvcx.asgi import (
10
+ AsgiBodyFactory,
11
+ AsgiHttpReceive,
12
+ AsgiHttpSend,
13
+ AsgiLifespanReceive,
14
+ AsgiLifespanSend,
15
+ AsgiReceive,
16
+ AsgiScope,
17
+ AsgiSend,
18
+ AsgiWsAdapter,
19
+ AsgiWsReceive,
20
+ AsgiWsSend,
21
+ close_ws_asgi,
22
+ create_http_context_asgi,
23
+ send_http_exc_asgi,
24
+ send_response_asgi,
25
+ )
26
+ from mvcx.controller import ControllerFactory
27
+ from mvcx.di import Container, DefaultContainer, Invokable, Lifetime, Resolvable
28
+ from mvcx.http import (
29
+ HttpContext,
30
+ HttpHandler,
31
+ HttpHandlerFactory,
32
+ HttpResult,
33
+ Request,
34
+ )
35
+ from mvcx.middleware import (
36
+ HttpMiddlewareFactory,
37
+ MiddlewareFactory,
38
+ WsMiddlewareFactory,
39
+ )
40
+ from mvcx.routing import DefaultRouter, RouteMethod, Router
41
+ from mvcx.rsgi import (
42
+ RsgiHttpProto,
43
+ RsgiProto,
44
+ RsgiScope,
45
+ RsgiWsAdapter,
46
+ RsgiWsProto,
47
+ close_ws_rsgi,
48
+ send_http_exc_rsgi,
49
+ send_response_rsgi,
50
+ )
51
+ from mvcx.url import Path
52
+ from mvcx.view import View
53
+ from mvcx.websocket import WebSocket, WsHandler, WsHandlerFactory, WsResult
54
+
55
+
56
+ async def _default_http_handler(request: Request) -> HttpResult:
57
+ return await request.handler(request)
58
+
59
+
60
+ async def _default_ws_handler(ws: WebSocket) -> WsResult:
61
+ return await ws.handler(ws)
62
+
63
+
64
+ class Application:
65
+ def __init__(
66
+ self,
67
+ *,
68
+ container: Container | None = None,
69
+ router: Router | None = None,
70
+ ) -> None:
71
+ self._container: Container = container or DefaultContainer()
72
+ self._router: Router = router or DefaultRouter()
73
+
74
+ self._http_handler: HttpHandler = _default_http_handler
75
+ self._ws_handler: WsHandler = _default_ws_handler
76
+
77
+ if pathlib.Path("./views").is_dir():
78
+ self.add_dependency(View)
79
+
80
+ def add_route(
81
+ self, path: str, method: RouteMethod, handler: HttpHandlerFactory
82
+ ) -> Self:
83
+ self._router.add_route(Path(path), method, handler)
84
+ return self
85
+
86
+ def add_controller(self, path: str, controller: ControllerFactory) -> Self:
87
+ async def http_handler(request: Request) -> HttpResult:
88
+ return await self._container.invoke(
89
+ controller, request.scoped_cache
90
+ ).handle_http(request)
91
+
92
+ async def ws_handler(ws: WebSocket) -> WsResult:
93
+ return await self._container.invoke(controller, ws.scoped_cache).handle_ws(
94
+ ws
95
+ )
96
+
97
+ self._router.add_route(Path(path), "*", lambda: http_handler)
98
+ self._router.add_ws_route(Path(path), lambda: ws_handler)
99
+ return self
100
+
101
+ def add_fallback_route(self, status: int, handler: HttpHandlerFactory) -> Self:
102
+ self._router.add_fallback_route(status, handler)
103
+ return self
104
+
105
+ def add_ws_route(self, path: str, handler: WsHandlerFactory) -> Self:
106
+ self._router.add_ws_route(Path(path), handler)
107
+ return self
108
+
109
+ def add_dependency[T](
110
+ self,
111
+ resolvable: Resolvable[T],
112
+ invokable: Invokable[T] | None = None,
113
+ lifetime: Lifetime | None = None,
114
+ ) -> Self:
115
+ self._container.register(
116
+ resolvable, invokable or resolvable, lifetime or "singleton"
117
+ )
118
+ return self
119
+
120
+ def overwrite_dependency[T](
121
+ self, resolvable: Resolvable[T], invokable: Invokable[T]
122
+ ) -> Self:
123
+ self._container.overwrite(resolvable, invokable)
124
+ return self
125
+
126
+ def add_middleware(self, middleware_factory: MiddlewareFactory) -> Self:
127
+ async def http_handler(
128
+ request: Request, call_next: HttpHandler = self._http_handler
129
+ ) -> HttpResult:
130
+ return await self._container.invoke(
131
+ middleware_factory, request.scoped_cache
132
+ ).handle_http(request, call_next)
133
+
134
+ async def ws_handler(
135
+ ws: WebSocket, call_next: WsHandler = self._ws_handler
136
+ ) -> WsResult:
137
+ return await self._container.invoke(
138
+ middleware_factory, ws.scoped_cache
139
+ ).handle_ws(ws, call_next)
140
+
141
+ self._http_handler = http_handler
142
+ self._ws_handler = ws_handler
143
+ return self
144
+
145
+ def add_http_middleware(self, middleware_factory: HttpMiddlewareFactory) -> Self:
146
+ async def handler(
147
+ request: Request, call_next: HttpHandler = self._http_handler
148
+ ) -> HttpResult:
149
+ return await self._container.invoke(
150
+ middleware_factory, request.scoped_cache
151
+ )(request, call_next)
152
+
153
+ self._http_handler = handler
154
+ return self
155
+
156
+ def add_ws_middleware(self, middleware_factory: WsMiddlewareFactory) -> Self:
157
+ async def handler(
158
+ ws: WebSocket, call_next: WsHandler = self._ws_handler
159
+ ) -> WsResult:
160
+ return await self._container.invoke(middleware_factory, ws.scoped_cache)(
161
+ ws, call_next
162
+ )
163
+
164
+ self._ws_handler = handler
165
+ return self
166
+
167
+ def run(
168
+ self,
169
+ app: str,
170
+ port: int,
171
+ *,
172
+ host: str = "0.0.0.0",
173
+ reload: bool = False,
174
+ static_url: str = "/static",
175
+ static_root: str = "static",
176
+ access_log: bool = True,
177
+ interface: Literal["asgi", "rsgi"] = "rsgi",
178
+ ) -> None:
179
+ static_root_path = pathlib.Path(static_root)
180
+
181
+ Granian(
182
+ app,
183
+ port=port,
184
+ address=host,
185
+ reload=reload,
186
+ static_path_route=[static_url] if static_root_path.is_dir() else None,
187
+ static_path_mount=[static_root_path] if static_root_path.is_dir() else None,
188
+ log_access=access_log,
189
+ interface=Interfaces.ASGI if interface == "asgi" else Interfaces.RSGI,
190
+ log_level=LogLevels.info if reload else LogLevels.warning,
191
+ ).serve()
192
+
193
+ async def __call__(
194
+ self, scope: AsgiScope, receive: AsgiReceive, send: AsgiSend
195
+ ) -> None:
196
+ if scope["type"] == "lifespan":
197
+ receive = cast(AsgiLifespanReceive, receive)
198
+ send = cast(AsgiLifespanSend, send)
199
+ event = await receive()
200
+ match event["type"]:
201
+ case "lifespan.startup":
202
+ return await send({"type": "lifespan.startup.complete"})
203
+ case "lifespan.shutdown":
204
+ try:
205
+ await self._container.clear()
206
+ await send({"type": "lifespan.shutdown.complete"})
207
+ except Exception as e:
208
+ await send(
209
+ {"type": "lifespan.shutdown.failed", "message": str(e)}
210
+ )
211
+ raise e
212
+ return
213
+
214
+ context = create_http_context_asgi(scope, self._container)
215
+ try:
216
+ match scope["type"]:
217
+ case "http":
218
+ receive = cast(AsgiHttpReceive, receive)
219
+ send = cast(AsgiHttpSend, send)
220
+ body_factory = AsgiBodyFactory(receive)
221
+ request = Request(context, body_factory)
222
+ route = self._router.get_route(request.path, request.method)
223
+ if route.error is not None:
224
+ fallback = self._router.get_fallback_route(route.error)
225
+ if fallback.error is not None:
226
+ return await send_http_exc_asgi(send, fallback.error)
227
+ response = await self._container.invoke(
228
+ fallback.value, request.scoped_cache
229
+ )(request)
230
+ if response.error is not None:
231
+ return await send_http_exc_asgi(send, response.error)
232
+ return await send_response_asgi(send, response.value)
233
+ request.params = route.value.params
234
+ request.handler = self._container.invoke(
235
+ route.value.handler, request.scoped_cache
236
+ )
237
+ response = await self._http_handler(request)
238
+ if response.error is not None:
239
+ fallback = self._router.get_fallback_route(response.error)
240
+ if fallback.error is not None:
241
+ return await send_http_exc_asgi(send, fallback.error)
242
+ handler = self._container.invoke(
243
+ fallback.value, request.scoped_cache
244
+ )
245
+ response = await handler(request)
246
+ if response.error is not None:
247
+ return await send_http_exc_asgi(send, response.error)
248
+ return await send_response_asgi(send, response.value)
249
+ await send_response_asgi(send, response.value)
250
+
251
+ case "websocket":
252
+ receive = cast(AsgiWsReceive, receive)
253
+ send = cast(AsgiWsSend, send)
254
+ adapter = AsgiWsAdapter(receive, send)
255
+ ws = WebSocket(context, adapter)
256
+ route = self._router.get_ws_route(ws.path)
257
+ if route.error is not None:
258
+ return await close_ws_asgi(send, route.error)
259
+ handler = self._container.invoke(
260
+ route.value.handler, ws.scoped_cache
261
+ )
262
+ ws.handler = handler
263
+ ws.params = route.value.params
264
+ response = await self._ws_handler(ws)
265
+ if response.error is not None:
266
+ await close_ws_asgi(send, response.error)
267
+
268
+ finally:
269
+ await context.scoped_cache.clear()
270
+
271
+ def __rsgi_del__(self, loop: AbstractEventLoop) -> None:
272
+ loop.run_until_complete(self._container.clear())
273
+
274
+ async def __rsgi__(self, scope: RsgiScope, proto: RsgiProto) -> None:
275
+ context = HttpContext(
276
+ Path(scope.path),
277
+ scope.method,
278
+ lambda: scope.headers,
279
+ scope.query_string,
280
+ {},
281
+ self._container,
282
+ )
283
+
284
+ try:
285
+ match scope.proto:
286
+ case "http":
287
+ proto = cast(RsgiHttpProto, proto)
288
+ request = Request(context, proto)
289
+ route = self._router.get_route(request.path, request.method)
290
+ if route.error is not None:
291
+ fallback = self._router.get_fallback_route(route.error)
292
+ if fallback.error is not None:
293
+ return send_http_exc_rsgi(proto, fallback.error)
294
+ handler = self._container.invoke(
295
+ fallback.value, request.scoped_cache
296
+ )
297
+ response = await handler(request)
298
+ if response.error is not None:
299
+ return send_http_exc_rsgi(proto, response.error)
300
+ return await send_response_rsgi(proto, response.value)
301
+ handler = self._container.invoke(
302
+ route.value.handler, request.scoped_cache
303
+ )
304
+ request.handler = handler
305
+ request.params = route.value.params
306
+ response = await self._http_handler(request)
307
+ if response.error is not None:
308
+ fallback = self._router.get_fallback_route(response.error)
309
+ if fallback.error is not None:
310
+ return send_http_exc_rsgi(proto, fallback.error)
311
+ handler = self._container.invoke(
312
+ fallback.value, request.scoped_cache
313
+ )
314
+ response = await handler(request)
315
+ if response.error is not None:
316
+ return send_http_exc_rsgi(proto, response.error)
317
+ return await send_response_rsgi(proto, response.value)
318
+ return await send_response_rsgi(proto, response.value)
319
+
320
+ case "ws":
321
+ proto = cast(RsgiWsProto, proto)
322
+ adapter = RsgiWsAdapter(proto)
323
+ ws = WebSocket(context, adapter)
324
+ route = self._router.get_ws_route(ws.path)
325
+ if route.error is not None:
326
+ return close_ws_rsgi(proto, route.error)
327
+ handler = self._container.invoke(
328
+ route.value.handler, ws.scoped_cache
329
+ )
330
+ ws.handler = handler
331
+ ws.params = route.value.params
332
+ response = await self._ws_handler(ws)
333
+ if response.error is not None:
334
+ close_ws_rsgi(proto, response.error)
335
+
336
+ finally:
337
+ await context.scoped_cache.clear()