fusion 0.1.0__py3-none-any.whl
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.
- fusion/__init__.py +5 -0
- fusion/annotations.py +15 -0
- fusion/application.py +11 -0
- fusion/context.py +28 -0
- fusion/di.py +102 -0
- fusion/endpoints.py +17 -0
- fusion/exceptions.py +2 -0
- fusion/resolvers.py +140 -0
- fusion/routing.py +42 -0
- fusion-0.1.0.dist-info/METADATA +21 -0
- fusion-0.1.0.dist-info/RECORD +13 -0
- fusion-0.1.0.dist-info/WHEEL +4 -0
- fusion-0.1.0.dist-info/licenses/LICENSE.md +0 -0
fusion/__init__.py
ADDED
fusion/annotations.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from fusion.resolvers import (
|
|
4
|
+
CookieResolver,
|
|
5
|
+
HeaderResolver,
|
|
6
|
+
PathParamResolver,
|
|
7
|
+
QueryParamResolver,
|
|
8
|
+
RequestBodyResolver,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
type PathParam[T] = Annotated[T, {"resolver": PathParamResolver}]
|
|
12
|
+
type QueryParam[T] = Annotated[T, {"resolver": QueryParamResolver}]
|
|
13
|
+
type Header[T] = Annotated[T, {"resolver": HeaderResolver}]
|
|
14
|
+
type Cookie[T] = Annotated[T, {"resolver": CookieResolver}]
|
|
15
|
+
type RequestBody[O] = Annotated[O, {"resolver": RequestBodyResolver}]
|
fusion/application.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from starlette.applications import Starlette
|
|
2
|
+
|
|
3
|
+
from fusion.routing import Router
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Fusion(Starlette):
|
|
7
|
+
"""Fusion application that integrates dependency injection."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, routes, middleware=None, lifespan=None):
|
|
10
|
+
super().__init__(routes=routes, middleware=middleware, lifespan=lifespan)
|
|
11
|
+
self.router = Router(routes=routes, lifespan=lifespan)
|
fusion/context.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from contextlib import AsyncExitStack
|
|
2
|
+
from contextvars import ContextVar, Token
|
|
3
|
+
from typing import Self
|
|
4
|
+
|
|
5
|
+
from starlette.requests import Request
|
|
6
|
+
|
|
7
|
+
context: ContextVar["Context"] = ContextVar("context")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Context(AsyncExitStack):
|
|
11
|
+
_token: Token
|
|
12
|
+
request: Request
|
|
13
|
+
|
|
14
|
+
def __init__(self, request: Request):
|
|
15
|
+
super().__init__()
|
|
16
|
+
self.request = request
|
|
17
|
+
|
|
18
|
+
async def __aenter__(self) -> Self:
|
|
19
|
+
if context.get(None) is not None:
|
|
20
|
+
raise RuntimeError("Nested context is not allowed")
|
|
21
|
+
self._token = context.set(self)
|
|
22
|
+
return await super().__aenter__()
|
|
23
|
+
|
|
24
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: # type:ignore
|
|
25
|
+
try:
|
|
26
|
+
await super().__aexit__(exc_type, exc_val, exc_tb)
|
|
27
|
+
finally:
|
|
28
|
+
context.reset(self._token)
|
fusion/di.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from collections.abc import AsyncIterator
|
|
2
|
+
from contextlib import AbstractAsyncContextManager
|
|
3
|
+
from functools import wraps
|
|
4
|
+
from typing import Any, Callable, ClassVar, Self, TypeVar, get_origin
|
|
5
|
+
|
|
6
|
+
from msgspec import Struct as Object
|
|
7
|
+
|
|
8
|
+
from fusion.resolvers import (
|
|
9
|
+
Constructor,
|
|
10
|
+
FactoryResolver,
|
|
11
|
+
InjectableResolver,
|
|
12
|
+
Resolver,
|
|
13
|
+
__factories__,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Injectable(Object):
|
|
20
|
+
__resolvers__: ClassVar[list[Resolver]]
|
|
21
|
+
|
|
22
|
+
def __init_subclass__(cls, *args, **kwargs):
|
|
23
|
+
cls.__resolvers__ = build_resolvers(cls.__annotations__)
|
|
24
|
+
super().__init_subclass__(*args, **kwargs)
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
async def instance(cls) -> Self:
|
|
28
|
+
"""Create an instance of the class with all dependencies resolved."""
|
|
29
|
+
params = {}
|
|
30
|
+
for resolver in cls.__resolvers__:
|
|
31
|
+
name, value = await resolver.resolve()
|
|
32
|
+
params[name] = value
|
|
33
|
+
|
|
34
|
+
return cls(**params)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def factory(func: Constructor) -> Constructor:
|
|
38
|
+
"""Decorator to register a factory function for a type."""
|
|
39
|
+
if "return" not in func.__annotations__:
|
|
40
|
+
raise ValueError("Factory function must have a return type annotation")
|
|
41
|
+
# Register the factory function
|
|
42
|
+
return_annotation = func.__annotations__["return"]
|
|
43
|
+
origin = get_origin(return_annotation)
|
|
44
|
+
return_type = return_annotation.__args__[0] if origin is AsyncIterator else return_annotation
|
|
45
|
+
__factories__[return_type] = func
|
|
46
|
+
return func
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_resolvers(annotations: dict[str, Any]) -> list[Resolver]:
|
|
50
|
+
resolvers = []
|
|
51
|
+
for name, annotation in annotations.items():
|
|
52
|
+
origin = get_origin(annotation)
|
|
53
|
+
if not origin:
|
|
54
|
+
if issubclass(annotation, Injectable):
|
|
55
|
+
resolvers.append(InjectableResolver(name=name, typ=annotation))
|
|
56
|
+
elif annotation in __factories__:
|
|
57
|
+
resolvers.append(FactoryResolver(name=name, typ=annotation))
|
|
58
|
+
else:
|
|
59
|
+
raise ValueError(f"Invalid annotation for {name}: {annotation}")
|
|
60
|
+
continue
|
|
61
|
+
# skip if annotation is ClassVar
|
|
62
|
+
if origin is ClassVar:
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
if len(annotation.__args__) != 1:
|
|
66
|
+
raise ValueError(f"Invalid annotation for {name}: {annotation}")
|
|
67
|
+
|
|
68
|
+
typ = annotation.__args__[0]
|
|
69
|
+
annotated = origin.__value__
|
|
70
|
+
if not annotated:
|
|
71
|
+
raise ValueError(f"Invalid annotation for {name}: {annotation}")
|
|
72
|
+
|
|
73
|
+
if not hasattr(annotated, "__metadata__"):
|
|
74
|
+
raise ValueError(f"Invalid annotation for {name}: {annotation}")
|
|
75
|
+
|
|
76
|
+
metadata = annotated.__metadata__[0]
|
|
77
|
+
DependencyResolver = metadata.get("resolver", None)
|
|
78
|
+
if not DependencyResolver:
|
|
79
|
+
raise ValueError(f"No resolver found for {name}: {annotation}")
|
|
80
|
+
|
|
81
|
+
if not issubclass(DependencyResolver, Resolver):
|
|
82
|
+
raise ValueError(f"Invalid resolver for {name}: {annotation}")
|
|
83
|
+
|
|
84
|
+
resolvers.append(DependencyResolver(name=name, typ=typ))
|
|
85
|
+
|
|
86
|
+
return resolvers
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def inject(func: Callable) -> Callable:
|
|
90
|
+
"""Decorator to mark a function as an injector."""
|
|
91
|
+
resolvers = build_resolvers(func.__annotations__)
|
|
92
|
+
|
|
93
|
+
@wraps(func)
|
|
94
|
+
async def wrapper(self, *args, **kwargs):
|
|
95
|
+
params = {}
|
|
96
|
+
for resolver in resolvers:
|
|
97
|
+
name, value = await resolver.resolve()
|
|
98
|
+
params[name] = value
|
|
99
|
+
# Call the original function with resolved parameters
|
|
100
|
+
return await func(self, **params)
|
|
101
|
+
|
|
102
|
+
return wrapper
|
fusion/endpoints.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from typing import ClassVar
|
|
2
|
+
|
|
3
|
+
from fusion.di import Injectable, inject
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HttpEndpoint(Injectable):
|
|
7
|
+
methods: ClassVar[list[str]]
|
|
8
|
+
|
|
9
|
+
def __init_subclass__(cls, *args, **kwargs):
|
|
10
|
+
cls.methods = []
|
|
11
|
+
for http_method in ("GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"):
|
|
12
|
+
method = getattr(cls, http_method.lower(), None)
|
|
13
|
+
if callable(method):
|
|
14
|
+
cls.methods.append(http_method)
|
|
15
|
+
setattr(cls, http_method.lower(), inject(method))
|
|
16
|
+
|
|
17
|
+
super().__init_subclass__(*args, **kwargs)
|
fusion/exceptions.py
ADDED
fusion/resolvers.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from abc import abstractmethod
|
|
2
|
+
from collections.abc import AsyncIterator, Awaitable
|
|
3
|
+
from contextlib import AbstractAsyncContextManager
|
|
4
|
+
from typing import (
|
|
5
|
+
Callable,
|
|
6
|
+
Generic,
|
|
7
|
+
Protocol,
|
|
8
|
+
Self,
|
|
9
|
+
Type,
|
|
10
|
+
TypeVar,
|
|
11
|
+
get_origin,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
import msgspec
|
|
15
|
+
from msgspec import Struct as Object
|
|
16
|
+
|
|
17
|
+
from fusion.context import context
|
|
18
|
+
|
|
19
|
+
T = TypeVar("T")
|
|
20
|
+
type Constructor[T] = Callable[[], Awaitable[T] | AbstractAsyncContextManager[T]]
|
|
21
|
+
__factories__: dict[Type, Constructor] = {}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InjectableObject(Protocol):
|
|
25
|
+
@classmethod
|
|
26
|
+
async def instance(cls) -> Self:
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Resolver(Object, Generic[T]):
|
|
31
|
+
"""Base class for resolvers."""
|
|
32
|
+
|
|
33
|
+
name: str
|
|
34
|
+
typ: Type[T]
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
async def resolve(self) -> tuple[str, T | None]:
|
|
38
|
+
"""Resolve the dependency."""
|
|
39
|
+
raise NotImplementedError("Subclasses must implement this method")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class InjectableResolver(Resolver[InjectableObject]):
|
|
43
|
+
"""Resolver for injected dependencies."""
|
|
44
|
+
|
|
45
|
+
async def resolve(self) -> tuple[str, InjectableObject]:
|
|
46
|
+
"""Resolve the injected dependency."""
|
|
47
|
+
ctx = context.get()
|
|
48
|
+
if not ctx:
|
|
49
|
+
raise RuntimeError("Request context is not available")
|
|
50
|
+
|
|
51
|
+
return self.name, await self.typ.instance()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def isasynccontextmanager(func: Callable) -> bool:
|
|
55
|
+
assert hasattr(func, "__annotations__")
|
|
56
|
+
ret = func.__annotations__.get("return", None)
|
|
57
|
+
return get_origin(ret) is AsyncIterator if ret else False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class FactoryResolver(Resolver[T]):
|
|
61
|
+
"""Resolver for factory functions."""
|
|
62
|
+
|
|
63
|
+
async def resolve(self) -> tuple[str, T]:
|
|
64
|
+
"""Resolve the factory function."""
|
|
65
|
+
factory: Constructor | None = __factories__.get(self.typ)
|
|
66
|
+
if factory is None:
|
|
67
|
+
raise ValueError(f"No factory found for {self.typ}")
|
|
68
|
+
|
|
69
|
+
if isasynccontextmanager(factory):
|
|
70
|
+
ctx = context.get()
|
|
71
|
+
return self.name, await ctx.enter_async_context(factory()) # type: ignore
|
|
72
|
+
else:
|
|
73
|
+
return self.name, await factory() # type: ignore
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class QueryParamResolver(Resolver[T]):
|
|
77
|
+
"""Resolver for query parameters."""
|
|
78
|
+
|
|
79
|
+
async def resolve(self) -> tuple[str, T | None]:
|
|
80
|
+
"""Resolve the query parameter from the request context."""
|
|
81
|
+
ctx = context.get()
|
|
82
|
+
value = ctx.request.query_params.get(self.name, None)
|
|
83
|
+
if value is not None:
|
|
84
|
+
value = msgspec.convert(value, self.typ, strict=False)
|
|
85
|
+
return self.name, value
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class PathParamResolver(Resolver[T]):
|
|
89
|
+
"""Resolver for path parameters."""
|
|
90
|
+
|
|
91
|
+
async def resolve(self) -> tuple[str, T | None]:
|
|
92
|
+
"""Resolve the path parameter from the request context."""
|
|
93
|
+
ctx = context.get()
|
|
94
|
+
value = ctx.request.path_params.get(self.name, None)
|
|
95
|
+
if value is not None:
|
|
96
|
+
value = msgspec.convert(value, self.typ, strict=False)
|
|
97
|
+
return self.name, value
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class RequestBodyResolver(Resolver[T]):
|
|
101
|
+
"""Resolver for request body parameters."""
|
|
102
|
+
|
|
103
|
+
async def resolve(self) -> tuple[str, T]:
|
|
104
|
+
"""Resolve the request body from the request context."""
|
|
105
|
+
ctx = context.get()
|
|
106
|
+
body = await ctx.request.json()
|
|
107
|
+
value = msgspec.convert(body, self.typ, strict=True)
|
|
108
|
+
return self.name, value
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class HeaderResolver(Resolver[T]):
|
|
112
|
+
"""Resolver for header."""
|
|
113
|
+
|
|
114
|
+
async def resolve(self) -> tuple[str, T | None]:
|
|
115
|
+
"""Resolve the header parameter from the request context."""
|
|
116
|
+
ctx = context.get()
|
|
117
|
+
headers = {
|
|
118
|
+
key.lower().replace("-", "_").replace(" ", "_"): value
|
|
119
|
+
for key, value in ctx.request.headers.items()
|
|
120
|
+
}
|
|
121
|
+
value = headers.get(self.name, None)
|
|
122
|
+
if value is not None:
|
|
123
|
+
value = msgspec.convert(value, self.typ, strict=False)
|
|
124
|
+
return self.name, value
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class CookieResolver(Resolver[T]):
|
|
128
|
+
"""Resolver for cookie."""
|
|
129
|
+
|
|
130
|
+
async def resolve(self) -> tuple[str, T | None]:
|
|
131
|
+
"""Resolve the cookie parameter from the request context."""
|
|
132
|
+
ctx = context.get()
|
|
133
|
+
cookies = {
|
|
134
|
+
key.lower().replace("-", "_").replace(" ", "_"): value
|
|
135
|
+
for key, value in ctx.request.cookies.items()
|
|
136
|
+
}
|
|
137
|
+
value = cookies.get(self.name, None)
|
|
138
|
+
if value is not None:
|
|
139
|
+
value = msgspec.convert(value, self.typ, strict=False)
|
|
140
|
+
return self.name, value
|
fusion/routing.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from typing import Type
|
|
2
|
+
|
|
3
|
+
from starlette.exceptions import HTTPException
|
|
4
|
+
from starlette.requests import Request
|
|
5
|
+
from starlette.responses import PlainTextResponse
|
|
6
|
+
from starlette.routing import Route as StarletteRoute
|
|
7
|
+
from starlette.routing import Router as StarletteRouter
|
|
8
|
+
from starlette.types import Receive, Scope, Send
|
|
9
|
+
|
|
10
|
+
from fusion.context import Context
|
|
11
|
+
from fusion.endpoints import HttpEndpoint
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Route(StarletteRoute):
|
|
15
|
+
"""Custom route that integrates dependency injection."""
|
|
16
|
+
|
|
17
|
+
endpoint: HttpEndpoint
|
|
18
|
+
|
|
19
|
+
def __init__(self, path: str, endpoint: Type[HttpEndpoint]):
|
|
20
|
+
super().__init__(path=path, endpoint=endpoint, methods=endpoint.methods)
|
|
21
|
+
|
|
22
|
+
async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
23
|
+
if scope["method"] not in self.endpoint.methods:
|
|
24
|
+
headers = {"Allow": ", ".join(self.endpoint.methods)}
|
|
25
|
+
if "app" in scope:
|
|
26
|
+
raise HTTPException(status_code=405, headers=headers)
|
|
27
|
+
else:
|
|
28
|
+
response = PlainTextResponse("Method Not Allowed", status_code=405, headers=headers)
|
|
29
|
+
await response(scope, receive, send)
|
|
30
|
+
else:
|
|
31
|
+
request = Request(scope, receive, send)
|
|
32
|
+
async with Context(request):
|
|
33
|
+
endpoint = (
|
|
34
|
+
await self.endpoint.instance()
|
|
35
|
+
) # initialize the endpoint with dependencies resolved
|
|
36
|
+
method = getattr(endpoint, scope["method"].lower())
|
|
37
|
+
response = await method()
|
|
38
|
+
await response(scope, receive, send)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Router(StarletteRouter):
|
|
42
|
+
...
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fusion
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fusion is a modern ASGI web framework for Python with built-in dependency injection, OpenAPI schema generation, and MCP support.
|
|
5
|
+
Project-URL: Homepage, https://github.com/okanakbulut/fusion
|
|
6
|
+
Author: Okan Akbulut
|
|
7
|
+
License-File: LICENSE.md
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Requires-Dist: msgspec==0.19.0
|
|
10
|
+
Requires-Dist: starlette==0.47.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: commitizen==4.8.2; extra == 'dev'
|
|
13
|
+
Requires-Dist: coverage[toml]>=6.5; extra == 'dev'
|
|
14
|
+
Requires-Dist: httpx; extra == 'dev'
|
|
15
|
+
Requires-Dist: ipython; extra == 'dev'
|
|
16
|
+
Requires-Dist: pre-commit==4.2.0; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest-mock; extra == 'dev'
|
|
21
|
+
Requires-Dist: ruff==0.11.12; extra == 'dev'
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
fusion/__init__.py,sha256=L-Z54sAceWBwciSBHzkWtfp5q7pTL6K9J4mXhoMWXOc,207
|
|
2
|
+
fusion/annotations.py,sha256=TRslH-aRxKcnwV8Gb6UW7rNHrisEGdj_7M77LkHh_Xs,500
|
|
3
|
+
fusion/application.py,sha256=2h6_hnfAWjAZqX1c3N9NB2OZnq7trLMWzgkHr05rlk8,384
|
|
4
|
+
fusion/context.py,sha256=pygwcKSZZoRX29vVs6WCL41GCxYay4gvwVIRwRSFg2g,816
|
|
5
|
+
fusion/di.py,sha256=8qS5xD45tbLKiQ0aMiiADEnuIBDzMh7LH8B9Kp7mLiw,3458
|
|
6
|
+
fusion/endpoints.py,sha256=_Q9ob3Ya5rkfAr_QT5jyjawe2GaPY3KfJ4Z-rTDqFa8,561
|
|
7
|
+
fusion/exceptions.py,sha256=ifvhlxVWqvNDv8EGYdTGuvYwVV-s0K_mbbwL7vBZiHk,43
|
|
8
|
+
fusion/resolvers.py,sha256=1mrfymBN3HY0pVUK_3qXaD0Kw9xQwHWUH_D1oUM-2AM,4335
|
|
9
|
+
fusion/routing.py,sha256=eFJKfquk3FJps8NgFESB18aBhDxlzYMyvY52mYHihec,1601
|
|
10
|
+
fusion-0.1.0.dist-info/METADATA,sha256=FCgwJV8OM-YhAeqAMyx9qeDTXQ2P5CM9YLSLRigAEPE,842
|
|
11
|
+
fusion-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
12
|
+
fusion-0.1.0.dist-info/licenses/LICENSE.md,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
fusion-0.1.0.dist-info/RECORD,,
|
|
File without changes
|