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 ADDED
@@ -0,0 +1,5 @@
1
+ from .annotations import Cookie, Header, PathParam, QueryParam, RequestBody
2
+ from .application import Fusion
3
+ from .di import Injectable, factory
4
+ from .endpoints import HttpEndpoint
5
+ from .routing import Route
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
@@ -0,0 +1,2 @@
1
+ class ValidationError(Exception):
2
+ pass
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
File without changes