fastapi-extra 0.1.9__tar.gz → 0.2.1__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.
- {fastapi_extra-0.1.9 → fastapi_extra-0.2.1}/PKG-INFO +3 -2
- {fastapi_extra-0.1.9 → fastapi_extra-0.2.1}/fastapi_extra/__init__.py +1 -1
- {fastapi_extra-0.1.9 → fastapi_extra-0.2.1}/fastapi_extra/cache/__init__.py +1 -1
- fastapi_extra-0.2.1/fastapi_extra/cache/redis.py +59 -0
- fastapi_extra-0.2.1/fastapi_extra/cursor.pyi +8 -0
- {fastapi_extra-0.1.9 → fastapi_extra-0.2.1}/fastapi_extra/database/__init__.py +4 -3
- {fastapi_extra-0.1.9 → fastapi_extra-0.2.1}/fastapi_extra/database/service.py +12 -4
- fastapi_extra-0.2.1/fastapi_extra/database/session.py +68 -0
- fastapi_extra-0.2.1/fastapi_extra/dependency.py +73 -0
- {fastapi_extra-0.1.9 → fastapi_extra-0.2.1}/fastapi_extra/native/cursor.pyx +2 -2
- fastapi_extra-0.2.1/fastapi_extra/native/routing.pyx +192 -0
- fastapi_extra-0.2.1/fastapi_extra/routing.pyi +16 -0
- {fastapi_extra-0.1.9 → fastapi_extra-0.2.1}/fastapi_extra.egg-info/PKG-INFO +3 -2
- {fastapi_extra-0.1.9 → fastapi_extra-0.2.1}/fastapi_extra.egg-info/SOURCES.txt +2 -1
- fastapi_extra-0.1.9/fastapi_extra/cache/redis.py +0 -59
- fastapi_extra-0.1.9/fastapi_extra/database/driver.py +0 -83
- fastapi_extra-0.1.9/fastapi_extra/database/session.py +0 -26
- fastapi_extra-0.1.9/fastapi_extra/dependency.py +0 -37
- fastapi_extra-0.1.9/fastapi_extra/native/routing.pyx +0 -135
- {fastapi_extra-0.1.9 → fastapi_extra-0.2.1}/LICENSE +0 -0
- {fastapi_extra-0.1.9 → fastapi_extra-0.2.1}/README.rst +0 -0
- {fastapi_extra-0.1.9 → fastapi_extra-0.2.1}/fastapi_extra/database/model.py +0 -0
- {fastapi_extra-0.1.9 → fastapi_extra-0.2.1}/fastapi_extra/form.py +0 -0
- {fastapi_extra-0.1.9 → fastapi_extra-0.2.1}/fastapi_extra/py.typed +0 -0
- {fastapi_extra-0.1.9 → fastapi_extra-0.2.1}/fastapi_extra/response.py +0 -0
- {fastapi_extra-0.1.9 → fastapi_extra-0.2.1}/fastapi_extra/settings.py +0 -0
- {fastapi_extra-0.1.9 → fastapi_extra-0.2.1}/fastapi_extra/types.py +0 -0
- {fastapi_extra-0.1.9 → fastapi_extra-0.2.1}/fastapi_extra/utils.py +0 -0
- {fastapi_extra-0.1.9 → fastapi_extra-0.2.1}/fastapi_extra.egg-info/dependency_links.txt +0 -0
- {fastapi_extra-0.1.9 → fastapi_extra-0.2.1}/fastapi_extra.egg-info/requires.txt +0 -0
- {fastapi_extra-0.1.9 → fastapi_extra-0.2.1}/fastapi_extra.egg-info/top_level.txt +0 -0
- {fastapi_extra-0.1.9 → fastapi_extra-0.2.1}/pyproject.toml +0 -0
- {fastapi_extra-0.1.9 → fastapi_extra-0.2.1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-extra
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: extra package for fastapi.
|
|
5
5
|
Author-email: Ziyan Yin <408856732@qq.com>
|
|
6
6
|
License: BSD-3-Clause
|
|
@@ -32,3 +32,4 @@ Provides-Extra: pgsql
|
|
|
32
32
|
Requires-Dist: asyncpg; extra == "pgsql"
|
|
33
33
|
Provides-Extra: aiomysql
|
|
34
34
|
Requires-Dist: aiomysql; extra == "aiomysql"
|
|
35
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
__author__ = "ziyan.yin"
|
|
2
|
+
__date__ = "2025-01-17"
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, AsyncGenerator, Self
|
|
6
|
+
|
|
7
|
+
from fastapi.params import Depends
|
|
8
|
+
from pydantic import BaseModel, Field, RedisDsn
|
|
9
|
+
from redis.asyncio import ConnectionPool, Redis
|
|
10
|
+
|
|
11
|
+
from fastapi_extra.dependency import AbstractComponent
|
|
12
|
+
from fastapi_extra.settings import Settings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RedisConfig(BaseModel):
|
|
16
|
+
url: RedisDsn = RedisDsn("redis://localhost:6379/0")
|
|
17
|
+
max_connections: int | None = None
|
|
18
|
+
connection_kwargs: dict = Field(default_factory=dict)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DefaultRedisSettings(Settings):
|
|
22
|
+
redis: RedisConfig
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_settings = DefaultRedisSettings() # type: ignore
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RedisPool(AbstractComponent):
|
|
29
|
+
default_config = _settings.redis
|
|
30
|
+
|
|
31
|
+
def __init__(self):
|
|
32
|
+
self._pool: ConnectionPool | None = None
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def setup(cls, **options) -> Self:
|
|
36
|
+
redis = cls()
|
|
37
|
+
redis._pool = ConnectionPool.from_url(
|
|
38
|
+
cls.default_config.url,
|
|
39
|
+
**cls.default_config.model_dump(exclude_defaults=True, exclude={"url", "connection_kwargs"}),
|
|
40
|
+
**cls.default_config.connection_kwargs
|
|
41
|
+
**options
|
|
42
|
+
)
|
|
43
|
+
return redis
|
|
44
|
+
|
|
45
|
+
def get_client(self) -> Redis:
|
|
46
|
+
return Redis(connection_pool=self._pool)
|
|
47
|
+
|
|
48
|
+
async def dispose(self) -> None:
|
|
49
|
+
if self._pool:
|
|
50
|
+
await self._pool.aclose()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def get_redis(pool: RedisPool) -> AsyncGenerator[Redis, None]:
|
|
55
|
+
async with RedisPool.get_client as client:
|
|
56
|
+
yield client
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
RedisCli = Annotated[Redis, Depends(get_redis)]
|
|
@@ -5,10 +5,11 @@ __date__ = "2025-01-05"
|
|
|
5
5
|
from fastapi_extra.database.model import SQLBase
|
|
6
6
|
from fastapi_extra.database.service import ModelService
|
|
7
7
|
from fastapi_extra.database.session import DefaultSession as Session
|
|
8
|
+
from fastapi_extra.database.session import SessionFactory
|
|
8
9
|
|
|
9
10
|
__all__ = [
|
|
10
|
-
"
|
|
11
|
-
"ModelService",
|
|
11
|
+
"SessionFactory",
|
|
12
12
|
"Session",
|
|
13
|
-
"SQLBase"
|
|
13
|
+
"SQLBase",
|
|
14
|
+
"ModelService"
|
|
14
15
|
]
|
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
__author__ = "ziyan.yin"
|
|
2
2
|
__date__ = "2025-01-12"
|
|
3
3
|
|
|
4
|
+
from contextvars import ContextVar
|
|
4
5
|
from typing import Any, Generic, Self, TypeVar
|
|
5
6
|
|
|
6
7
|
from fastapi_extra.database.model import SQLModel
|
|
7
8
|
from fastapi_extra.database.session import DefaultSession
|
|
8
|
-
from fastapi_extra.dependency import
|
|
9
|
+
from fastapi_extra.dependency import AbstractService
|
|
9
10
|
|
|
10
11
|
Model = TypeVar("Model", bound=SQLModel)
|
|
11
12
|
|
|
12
13
|
|
|
13
|
-
class ModelService(
|
|
14
|
-
__slot__ = (
|
|
14
|
+
class ModelService(AbstractService, Generic[Model], abstract=True):
|
|
15
|
+
__slot__ = ()
|
|
15
16
|
__model__: Model
|
|
17
|
+
__session_container__ = ContextVar("__session_container__", default=None)
|
|
16
18
|
|
|
17
19
|
@classmethod
|
|
18
20
|
def __class_getitem__(cls, item: type[SQLModel]) -> Self:
|
|
@@ -27,7 +29,13 @@ class ModelService(AbstractDependency, Generic[Model], annotated=False):
|
|
|
27
29
|
return SubService
|
|
28
30
|
|
|
29
31
|
def __init__(self, session: DefaultSession):
|
|
30
|
-
self.session
|
|
32
|
+
self.__session_container__.set(session)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def session(self):
|
|
36
|
+
_session = self.__session_container__.get()
|
|
37
|
+
assert _session is not None, "Session is not initialized"
|
|
38
|
+
return _session
|
|
31
39
|
|
|
32
40
|
async def get(self, ident: int | str, **kwargs: Any) -> Model | None:
|
|
33
41
|
return await self.session.get(self.__model__, ident, **kwargs)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
__author__ = "ziyan.yin"
|
|
2
|
+
__date__ = "2024-12-26"
|
|
3
|
+
|
|
4
|
+
from typing import Annotated, AsyncGenerator, Literal, Self
|
|
5
|
+
|
|
6
|
+
from fastapi.params import Depends
|
|
7
|
+
from pydantic import AnyUrl, BaseModel, Field
|
|
8
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
9
|
+
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
10
|
+
|
|
11
|
+
from fastapi_extra.dependency import AbstractComponent
|
|
12
|
+
from fastapi_extra.settings import Settings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DatabaseConfig(BaseModel):
|
|
16
|
+
url: AnyUrl
|
|
17
|
+
echo: bool = False
|
|
18
|
+
echo_pool: bool = False
|
|
19
|
+
isolation_level: Literal[
|
|
20
|
+
"SERIALIZABLE",
|
|
21
|
+
"REPEATABLE READ",
|
|
22
|
+
"READ COMMITTED",
|
|
23
|
+
"READ UNCOMMITTED",
|
|
24
|
+
"AUTOCOMMIT",
|
|
25
|
+
] | None = None
|
|
26
|
+
options: dict = Field(default_factory=dict)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DefaultDatabaseSettings(Settings):
|
|
30
|
+
datasource: DatabaseConfig
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_settings = DefaultDatabaseSettings() # type: ignore
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SessionFactory(AbstractComponent):
|
|
37
|
+
__slot__ = ("_engine",)
|
|
38
|
+
default_config = _settings.datasource
|
|
39
|
+
|
|
40
|
+
def __init__(self):
|
|
41
|
+
self._engine = None
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def setup(cls, **options) -> Self:
|
|
45
|
+
db = cls()
|
|
46
|
+
db._engine = create_async_engine(
|
|
47
|
+
url=str(cls.default_config.url),
|
|
48
|
+
**cls.default_config.model_dump(exclude_defaults=True, exclude={"url", "options"}),
|
|
49
|
+
**cls.default_config.options,
|
|
50
|
+
**options
|
|
51
|
+
)
|
|
52
|
+
return db
|
|
53
|
+
|
|
54
|
+
def create_session(self) -> AsyncSession:
|
|
55
|
+
return AsyncSession(self._engine)
|
|
56
|
+
|
|
57
|
+
async def dispose(self) -> None:
|
|
58
|
+
if self._engine:
|
|
59
|
+
await self._engine.dispose()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def get_session(factory: SessionFactory) -> AsyncGenerator[AsyncSession, None]:
|
|
63
|
+
async with factory.create_session() as session:
|
|
64
|
+
yield session
|
|
65
|
+
await session.commit()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
DefaultSession = Annotated[AsyncSession, Depends(get_session)]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
__author__ = "ziyan.yin"
|
|
2
|
+
__date__ = "2025-01-05"
|
|
3
|
+
|
|
4
|
+
from abc import ABCMeta
|
|
5
|
+
from functools import update_wrapper
|
|
6
|
+
from typing import Annotated, Any, Callable, ClassVar, Self, final
|
|
7
|
+
|
|
8
|
+
from fastapi import Depends, FastAPI, Request
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def async_wrapper(func: Callable):
|
|
12
|
+
|
|
13
|
+
async def func_wrapper(*args, **kwds):
|
|
14
|
+
return func(*args, **kwds)
|
|
15
|
+
|
|
16
|
+
return update_wrapper(func_wrapper, func)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DependencyMetaClass(ABCMeta):
|
|
20
|
+
__load__ = None
|
|
21
|
+
__token__ = None
|
|
22
|
+
|
|
23
|
+
def __new__(
|
|
24
|
+
mcs,
|
|
25
|
+
name: str,
|
|
26
|
+
bases: tuple[type, ...],
|
|
27
|
+
attrs: dict,
|
|
28
|
+
abstract: bool = False
|
|
29
|
+
):
|
|
30
|
+
new_cls = super().__new__(mcs, name, bases, attrs)
|
|
31
|
+
new_cls.__token__ = f"{new_cls.__module__}.{new_cls.__name__}"
|
|
32
|
+
if not abstract:
|
|
33
|
+
if not new_cls.__load__:
|
|
34
|
+
return Annotated[new_cls, Depends(async_wrapper(new_cls))]
|
|
35
|
+
return Annotated[new_cls, Depends(new_cls.__load__)]
|
|
36
|
+
return new_cls
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AbstractComponent(metaclass=DependencyMetaClass, abstract=True):
|
|
40
|
+
__slot__ = ()
|
|
41
|
+
__token__: ClassVar[str]
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def setup(cls, *args: Any, **kwargs: Any) -> Self:
|
|
45
|
+
raise NotImplementedError
|
|
46
|
+
|
|
47
|
+
@final
|
|
48
|
+
@classmethod
|
|
49
|
+
def install(cls, app: FastAPI, *args: Any, **kwargs: Any) -> Self:
|
|
50
|
+
component = cls.setup(*args, **kwargs)
|
|
51
|
+
setattr(app.state, cls.__token__, component)
|
|
52
|
+
return component
|
|
53
|
+
|
|
54
|
+
@final
|
|
55
|
+
@classmethod
|
|
56
|
+
async def __load__(cls, request: Request) -> Self:
|
|
57
|
+
assert hasattr(request.app.state, cls.__token__), f"{cls.__name__} must be installed in lifespan"
|
|
58
|
+
return getattr(request.app.state, cls.__token__)
|
|
59
|
+
|
|
60
|
+
async def dispose(self) -> None:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AbstractService(metaclass=DependencyMetaClass, abstract=True):
|
|
65
|
+
__slot__ = ()
|
|
66
|
+
__load__ = None
|
|
67
|
+
__instance__ = None
|
|
68
|
+
__token__: ClassVar[str]
|
|
69
|
+
|
|
70
|
+
def __new__(cls, *args, **kwargs) -> Self:
|
|
71
|
+
if cls.__instance__ is None:
|
|
72
|
+
cls.__instance__ = super().__new__(cls)
|
|
73
|
+
return cls.__instance__
|
|
@@ -37,8 +37,8 @@ cdef class Cursor:
|
|
|
37
37
|
self.cursor = count
|
|
38
38
|
return (point << (_sequence_length + 4)) + (self.seed << _sequence_length) + count
|
|
39
39
|
|
|
40
|
-
def next_val(self) ->
|
|
40
|
+
def next_val(self) -> int:
|
|
41
41
|
index = self.fetch()
|
|
42
42
|
while index == 0:
|
|
43
43
|
index = self.fetch()
|
|
44
|
-
return
|
|
44
|
+
return index
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
__author__ = "ziyan.yin"
|
|
2
|
+
__describe__ = ""
|
|
3
|
+
|
|
4
|
+
cimport cython
|
|
5
|
+
|
|
6
|
+
from starlette import _utils as starlette_utils
|
|
7
|
+
from starlette.datastructures import URL
|
|
8
|
+
from starlette.responses import RedirectResponse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
cdef int find_params(unicode path):
|
|
12
|
+
for i, ch in enumerate(path):
|
|
13
|
+
if ch == "{":
|
|
14
|
+
return i
|
|
15
|
+
return -1
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
cdef int get_longest_common_prefix(unicode path, unicode node_path):
|
|
19
|
+
cdef int i
|
|
20
|
+
cdef int max_len = min(len(path), len(node_path))
|
|
21
|
+
for i in range(max_len):
|
|
22
|
+
if path[i] != node_path[i]:
|
|
23
|
+
return i
|
|
24
|
+
return max_len
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@cython.no_gc
|
|
28
|
+
cdef class RouteNode:
|
|
29
|
+
|
|
30
|
+
cdef readonly:
|
|
31
|
+
unicode prefix
|
|
32
|
+
list params_routes
|
|
33
|
+
list static_routes
|
|
34
|
+
dict children
|
|
35
|
+
|
|
36
|
+
cdef public object parent
|
|
37
|
+
|
|
38
|
+
def __cinit__(self, prefix: str):
|
|
39
|
+
self.prefix = prefix
|
|
40
|
+
self.params_routes = []
|
|
41
|
+
self.static_routes = []
|
|
42
|
+
self.children = {}
|
|
43
|
+
self.parent = None
|
|
44
|
+
|
|
45
|
+
def add_route(self, fullpath: str, handler: object):
|
|
46
|
+
wild_child = False
|
|
47
|
+
if (index := find_params(fullpath)) >= 0:
|
|
48
|
+
wild_child = True
|
|
49
|
+
path = fullpath[:index]
|
|
50
|
+
else:
|
|
51
|
+
path = fullpath
|
|
52
|
+
insert_route(self, path, wild_child, handler)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
cdef void insert_route(RouteNode node, unicode path, bint wild_child, object handler):
|
|
56
|
+
if node.prefix == path:
|
|
57
|
+
add_node(node, wild_child, handler)
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
cdef Py_UCS4 key = path.removeprefix(node.prefix)[0]
|
|
61
|
+
if key not in node.children:
|
|
62
|
+
add_child_node(node, key, path, wild_child, handler)
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
child_node = node.children[key]
|
|
66
|
+
i = get_longest_common_prefix(child_node.prefix, path)
|
|
67
|
+
longest_prefix = child_node.prefix[0: i]
|
|
68
|
+
if i == len(child_node.prefix):
|
|
69
|
+
insert_route(node.children[key], path, wild_child, handler)
|
|
70
|
+
return
|
|
71
|
+
next_node = RouteNode.__new__(RouteNode, longest_prefix)
|
|
72
|
+
next_node.parent = node
|
|
73
|
+
node.children[key] = next_node
|
|
74
|
+
next_node.children[child_node.prefix[i]] = child_node
|
|
75
|
+
child_node.parent = next_node
|
|
76
|
+
insert_route(next_node, path, wild_child, handler)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
cdef inline void add_child_node(RouteNode node, Py_UCS4 key, unicode path, bint wild_child, object handler):
|
|
80
|
+
child = RouteNode.__new__(RouteNode, path)
|
|
81
|
+
child.parent = node
|
|
82
|
+
add_node(child, wild_child, handler)
|
|
83
|
+
node.children[key] = child
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
cdef inline void add_node(RouteNode node, bint wild_child, object handler):
|
|
87
|
+
if wild_child:
|
|
88
|
+
node.params_routes.append(handler)
|
|
89
|
+
else:
|
|
90
|
+
node.static_routes.append(handler)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
root_node = RouteNode.__new__(RouteNode, "")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
cdef RouteNode search_node(unicode url):
|
|
97
|
+
cdef RouteNode current_node = root_node
|
|
98
|
+
cdef int n = len(url)
|
|
99
|
+
cdef int i = get_longest_common_prefix(url, current_node.prefix)
|
|
100
|
+
|
|
101
|
+
while i < n:
|
|
102
|
+
key = url[i]
|
|
103
|
+
if key not in current_node.children:
|
|
104
|
+
break
|
|
105
|
+
current_node = current_node.children[key]
|
|
106
|
+
i = get_longest_common_prefix(url, current_node.prefix)
|
|
107
|
+
|
|
108
|
+
return current_node
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def handle(scope, receive, send):
|
|
112
|
+
router = scope["app"].router
|
|
113
|
+
assert scope["type"] in ("http", "websocket", "lifespan")
|
|
114
|
+
|
|
115
|
+
if "router" not in scope:
|
|
116
|
+
scope["router"] = router
|
|
117
|
+
|
|
118
|
+
if scope["type"] == "lifespan":
|
|
119
|
+
await router.lifespan(scope, receive, send)
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
partial = None
|
|
123
|
+
|
|
124
|
+
scope["path"] = route_path = starlette_utils.get_route_path(scope)
|
|
125
|
+
scope["root_path"] = ""
|
|
126
|
+
leaf_node = search_node(route_path)
|
|
127
|
+
|
|
128
|
+
if leaf_node.prefix == route_path:
|
|
129
|
+
for route in leaf_node.static_routes:
|
|
130
|
+
match, child_scope = route.matches(scope)
|
|
131
|
+
if match.value == 2:
|
|
132
|
+
scope.update(child_scope)
|
|
133
|
+
await route.handle(scope, receive, send)
|
|
134
|
+
return
|
|
135
|
+
elif match.value == 1 and partial is None:
|
|
136
|
+
partial = route
|
|
137
|
+
partial_scope = child_scope
|
|
138
|
+
else:
|
|
139
|
+
current_node = leaf_node
|
|
140
|
+
routes = current_node.params_routes
|
|
141
|
+
while current_node.parent:
|
|
142
|
+
for route in routes:
|
|
143
|
+
match, child_scope = route.matches(scope)
|
|
144
|
+
if match.value == 2:
|
|
145
|
+
scope.update(child_scope)
|
|
146
|
+
await route.handle(scope, receive, send)
|
|
147
|
+
return
|
|
148
|
+
elif match.value == 1 and partial is None:
|
|
149
|
+
partial = route
|
|
150
|
+
partial_scope = child_scope
|
|
151
|
+
current_node = current_node.parent
|
|
152
|
+
|
|
153
|
+
if partial is not None:
|
|
154
|
+
scope.update(partial_scope)
|
|
155
|
+
await partial.handle(scope, receive, send)
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
if scope["type"] == "http" and router.redirect_slashes and route_path != "/":
|
|
159
|
+
redirect_scope = dict(scope)
|
|
160
|
+
if route_path.endswith("/"):
|
|
161
|
+
redirect_scope["path"] = redirect_scope["path"].rstrip("/")
|
|
162
|
+
else:
|
|
163
|
+
redirect_scope["path"] = redirect_scope["path"] + "/"
|
|
164
|
+
|
|
165
|
+
if leaf_node.prefix == redirect_scope["path"]:
|
|
166
|
+
for route in leaf_node.static_routes:
|
|
167
|
+
match, child_scope = route.matches(redirect_scope)
|
|
168
|
+
if match.value != 0:
|
|
169
|
+
redirect_url = URL(scope=redirect_scope)
|
|
170
|
+
response = RedirectResponse(url=str(redirect_url))
|
|
171
|
+
await response(scope, receive, send)
|
|
172
|
+
return
|
|
173
|
+
else:
|
|
174
|
+
current_node = leaf_node
|
|
175
|
+
routes = current_node.params_routes
|
|
176
|
+
while current_node.parent:
|
|
177
|
+
for route in routes:
|
|
178
|
+
if match.value != 0:
|
|
179
|
+
redirect_url = URL(scope=redirect_scope)
|
|
180
|
+
response = RedirectResponse(url=str(redirect_url))
|
|
181
|
+
await response(scope, receive, send)
|
|
182
|
+
return
|
|
183
|
+
current_node = current_node.parent
|
|
184
|
+
|
|
185
|
+
await router.default(scope, receive, send)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def install(app):
|
|
189
|
+
for route in app.routes:
|
|
190
|
+
root_node.add_route(route.path, route)
|
|
191
|
+
|
|
192
|
+
app.router.middleware_stack = handle
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-extra
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: extra package for fastapi.
|
|
5
5
|
Author-email: Ziyan Yin <408856732@qq.com>
|
|
6
6
|
License: BSD-3-Clause
|
|
@@ -32,3 +32,4 @@ Provides-Extra: pgsql
|
|
|
32
32
|
Requires-Dist: asyncpg; extra == "pgsql"
|
|
33
33
|
Provides-Extra: aiomysql
|
|
34
34
|
Requires-Dist: aiomysql; extra == "aiomysql"
|
|
35
|
+
Dynamic: license-file
|
|
@@ -2,10 +2,12 @@ LICENSE
|
|
|
2
2
|
README.rst
|
|
3
3
|
pyproject.toml
|
|
4
4
|
fastapi_extra/__init__.py
|
|
5
|
+
fastapi_extra/cursor.pyi
|
|
5
6
|
fastapi_extra/dependency.py
|
|
6
7
|
fastapi_extra/form.py
|
|
7
8
|
fastapi_extra/py.typed
|
|
8
9
|
fastapi_extra/response.py
|
|
10
|
+
fastapi_extra/routing.pyi
|
|
9
11
|
fastapi_extra/settings.py
|
|
10
12
|
fastapi_extra/types.py
|
|
11
13
|
fastapi_extra/utils.py
|
|
@@ -19,7 +21,6 @@ fastapi_extra/native/routing.pyx
|
|
|
19
21
|
fastapi_extra/cache/__init__.py
|
|
20
22
|
fastapi_extra/cache/redis.py
|
|
21
23
|
fastapi_extra/database/__init__.py
|
|
22
|
-
fastapi_extra/database/driver.py
|
|
23
24
|
fastapi_extra/database/model.py
|
|
24
25
|
fastapi_extra/database/service.py
|
|
25
26
|
fastapi_extra/database/session.py
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
__author__ = "ziyan.yin"
|
|
2
|
-
__date__ = "2025-01-17"
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
from typing import Annotated, AsyncGenerator
|
|
6
|
-
|
|
7
|
-
from fastapi.params import Depends
|
|
8
|
-
from pydantic import BaseModel, Field, RedisDsn
|
|
9
|
-
from redis.asyncio import ConnectionPool, Redis
|
|
10
|
-
|
|
11
|
-
from fastapi_extra.dependency import AbstractComponent
|
|
12
|
-
from fastapi_extra.settings import Settings
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
class RedisConfig(BaseModel):
|
|
16
|
-
url: RedisDsn = RedisDsn("redis://localhost:6379/0")
|
|
17
|
-
max_connections: int | None = None
|
|
18
|
-
connection_kwargs: dict = Field(default_factory=dict)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
class DefaultRedisSettings(Settings):
|
|
22
|
-
redis: RedisConfig
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
_settings = DefaultRedisSettings() # type: ignore
|
|
26
|
-
_loaded_pools: list[ConnectionPool] = []
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
class RedisCli(AbstractComponent):
|
|
30
|
-
default_config = _settings.redis
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def __init__(self):
|
|
34
|
-
self._pool = None
|
|
35
|
-
|
|
36
|
-
@property
|
|
37
|
-
def pool(self) -> ConnectionPool:
|
|
38
|
-
if not self._pool:
|
|
39
|
-
self._pool = ConnectionPool.from_url(
|
|
40
|
-
self.default_config.url,
|
|
41
|
-
**self.default_config.model_dump(exclude_defaults=True, exclude={"url", "connection_kwargs"}),
|
|
42
|
-
**self.default_config.connection_kwargs
|
|
43
|
-
)
|
|
44
|
-
_loaded_pools.append(self)
|
|
45
|
-
return self._pool
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
async def dispose() -> None:
|
|
49
|
-
for redis_pool in _loaded_pools:
|
|
50
|
-
redis_pool.aclose()
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
async def get_redis(redis_cli: RedisCli) -> AsyncGenerator[Redis, None]:
|
|
55
|
-
async with Redis(connection_pool=redis_cli.pool) as redis:
|
|
56
|
-
yield redis
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
DefaultRedis = Annotated[Redis, Depends(get_redis)]
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
__author__ = "ziyan.yin"
|
|
2
|
-
__date__ = "2024-12-26"
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
from typing import Literal
|
|
6
|
-
|
|
7
|
-
from pydantic import AnyUrl, BaseModel, Field
|
|
8
|
-
from sqlalchemy import Engine, NullPool
|
|
9
|
-
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
10
|
-
from sqlalchemy.util import _concurrency_py3k
|
|
11
|
-
from sqlmodel import Session, create_engine
|
|
12
|
-
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
13
|
-
|
|
14
|
-
from fastapi_extra.dependency import AbstractComponent
|
|
15
|
-
from fastapi_extra.settings import Settings
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class DatabaseConfig(BaseModel):
|
|
19
|
-
url: AnyUrl
|
|
20
|
-
echo: bool = False
|
|
21
|
-
echo_pool: bool = False
|
|
22
|
-
isolation_level: Literal[
|
|
23
|
-
"SERIALIZABLE",
|
|
24
|
-
"REPEATABLE READ",
|
|
25
|
-
"READ COMMITTED",
|
|
26
|
-
"READ UNCOMMITTED",
|
|
27
|
-
"AUTOCOMMIT",
|
|
28
|
-
] | None = None
|
|
29
|
-
options: dict = Field(default_factory=dict)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
class DefaultDatabaseSettings(Settings):
|
|
33
|
-
datasource: DatabaseConfig
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
_settings = DefaultDatabaseSettings() # type: ignore
|
|
37
|
-
_loaded_engines: list[Engine] = []
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
class DB(AbstractComponent):
|
|
41
|
-
default_config = _settings.datasource
|
|
42
|
-
default_options = {}
|
|
43
|
-
|
|
44
|
-
def __init__(self):
|
|
45
|
-
self._engine = None
|
|
46
|
-
|
|
47
|
-
@property
|
|
48
|
-
def engine(self) -> Engine:
|
|
49
|
-
if not self._engine:
|
|
50
|
-
self._engine = create_engine(
|
|
51
|
-
url=str(self.default_config.url),
|
|
52
|
-
**self.default_config.model_dump(exclude_defaults=True, exclude={"url", "options"}),
|
|
53
|
-
**self.default_config.options,
|
|
54
|
-
**self.default_options
|
|
55
|
-
)
|
|
56
|
-
_loaded_engines.append(self._engine)
|
|
57
|
-
return self._engine
|
|
58
|
-
|
|
59
|
-
@property
|
|
60
|
-
def session(self) -> Session:
|
|
61
|
-
return Session(self.engine)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
class AsyncDB(DB):
|
|
65
|
-
|
|
66
|
-
@property
|
|
67
|
-
def engine(self) -> AsyncEngine:
|
|
68
|
-
if not self._engine:
|
|
69
|
-
self._engine = AsyncEngine(super().engine)
|
|
70
|
-
return self._engine
|
|
71
|
-
|
|
72
|
-
@property
|
|
73
|
-
def session(self) -> AsyncSession:
|
|
74
|
-
return AsyncSession(self.engine)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if _settings.mode == "test":
|
|
78
|
-
DB.default_options = {"poolclass": NullPool}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
async def dispose() -> None:
|
|
82
|
-
for engine in _loaded_engines:
|
|
83
|
-
await _concurrency_py3k.greenlet_spawn(engine.dispose)
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
__author__ = "ziyan.yin"
|
|
2
|
-
__date__ = "2025-01-05"
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
from typing import Annotated, AsyncGenerator, Generator
|
|
6
|
-
|
|
7
|
-
from fastapi.params import Depends
|
|
8
|
-
from sqlmodel import Session
|
|
9
|
-
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
10
|
-
|
|
11
|
-
from fastapi_extra.database.driver import DB, AsyncDB
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
async def get_async_session(db: AsyncDB) -> AsyncGenerator[AsyncSession, None]:
|
|
15
|
-
async with db.session as session:
|
|
16
|
-
yield session
|
|
17
|
-
await session.commit()
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def get_session(db: DB) -> Generator[Session, None, None]:
|
|
21
|
-
with db.session as session:
|
|
22
|
-
yield session
|
|
23
|
-
session.commit()
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
DefaultSession = Annotated[AsyncSession, Depends(get_async_session)]
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
__author__ = "ziyan.yin"
|
|
2
|
-
__date__ = "2025-01-05"
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
from abc import ABCMeta
|
|
6
|
-
from typing import Annotated, Any, Self
|
|
7
|
-
|
|
8
|
-
from fastapi.params import Depends
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class DependencyMetaClass(ABCMeta):
|
|
12
|
-
|
|
13
|
-
def __new__(
|
|
14
|
-
mcs,
|
|
15
|
-
name: str,
|
|
16
|
-
bases: tuple[type, ...],
|
|
17
|
-
attrs: dict,
|
|
18
|
-
annotated: bool = True
|
|
19
|
-
):
|
|
20
|
-
new_cls = super().__new__(mcs, name, bases, attrs)
|
|
21
|
-
if annotated:
|
|
22
|
-
return Annotated[new_cls, Depends(new_cls)]
|
|
23
|
-
return new_cls
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
class AbstractDependency(metaclass=DependencyMetaClass, annotated=False):
|
|
27
|
-
__slot__ = ()
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
class AbstractComponent(AbstractDependency, annotated=False):
|
|
31
|
-
__slot__ = ()
|
|
32
|
-
__instance__: Any = None
|
|
33
|
-
|
|
34
|
-
def __new__(cls, *args, **kwargs) -> Self:
|
|
35
|
-
if cls.__instance__ is None:
|
|
36
|
-
cls.__instance__ = super().__new__(cls)
|
|
37
|
-
return cls.__instance__
|
|
@@ -1,135 +0,0 @@
|
|
|
1
|
-
__author__ = "ziyan.yin"
|
|
2
|
-
__describe__ = ""
|
|
3
|
-
|
|
4
|
-
cimport cython
|
|
5
|
-
|
|
6
|
-
from typing import MutableMapping
|
|
7
|
-
|
|
8
|
-
from starlette import _utils as starlette_utils
|
|
9
|
-
from starlette.datastructures import URL
|
|
10
|
-
from starlette.responses import RedirectResponse
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
@cython.no_gc
|
|
14
|
-
cdef class RouteNode:
|
|
15
|
-
cdef readonly:
|
|
16
|
-
list routes
|
|
17
|
-
dict leaves
|
|
18
|
-
unicode prefix
|
|
19
|
-
|
|
20
|
-
def __cinit__(self, prefix):
|
|
21
|
-
self.prefix = prefix
|
|
22
|
-
self.routes = []
|
|
23
|
-
self.leaves = {}
|
|
24
|
-
|
|
25
|
-
def add_route(self, route):
|
|
26
|
-
self.routes.append(route)
|
|
27
|
-
|
|
28
|
-
def add_leaf(self, node):
|
|
29
|
-
if node.prefix in self.leaves:
|
|
30
|
-
raise KeyError(node.prefix)
|
|
31
|
-
else:
|
|
32
|
-
self.leaves[node.prefix] = node
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
cdef list change_path_to_ranks(unicode path):
|
|
36
|
-
ranks = path.lstrip('/').split('/')
|
|
37
|
-
return ranks
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
cdef void add_route(unicode path, RouteNode root, object route):
|
|
41
|
-
current_node = root
|
|
42
|
-
ranks = change_path_to_ranks(path)
|
|
43
|
-
for r in ranks:
|
|
44
|
-
if r.find('{') >= 0 and r.find('}') > 0:
|
|
45
|
-
break
|
|
46
|
-
if not r:
|
|
47
|
-
continue
|
|
48
|
-
if r in current_node.leaves:
|
|
49
|
-
current_node = current_node.leaves[r]
|
|
50
|
-
else:
|
|
51
|
-
next_node = RouteNode.__new__(RouteNode, r)
|
|
52
|
-
current_node.add_leaf(next_node)
|
|
53
|
-
current_node = next_node
|
|
54
|
-
current_node.add_route(route)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
cdef list find_routes(unicode path, RouteNode root):
|
|
58
|
-
current_node = root
|
|
59
|
-
ranks = change_path_to_ranks(path)
|
|
60
|
-
|
|
61
|
-
routes = []
|
|
62
|
-
if current_node.routes:
|
|
63
|
-
routes += current_node.routes
|
|
64
|
-
for r in ranks:
|
|
65
|
-
if not r:
|
|
66
|
-
continue
|
|
67
|
-
if r in current_node.leaves:
|
|
68
|
-
current_node = current_node.leaves[r]
|
|
69
|
-
if current_node.routes:
|
|
70
|
-
routes += current_node.routes
|
|
71
|
-
continue
|
|
72
|
-
break
|
|
73
|
-
return routes
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
root_node = RouteNode.__new__(RouteNode, "")
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
async def handle(router, scope, receive, send):
|
|
80
|
-
assert scope["type"] in ("http", "websocket", "lifespan")
|
|
81
|
-
|
|
82
|
-
if "router" not in scope:
|
|
83
|
-
scope["router"] = router
|
|
84
|
-
|
|
85
|
-
if scope["type"] == "lifespan":
|
|
86
|
-
await router.lifespan(scope, receive, send)
|
|
87
|
-
return
|
|
88
|
-
|
|
89
|
-
partial = None
|
|
90
|
-
|
|
91
|
-
scope["path"] = route_path = starlette_utils.get_route_path(scope)
|
|
92
|
-
scope["root_path"] = ""
|
|
93
|
-
matched_routes = find_routes(route_path, root_node)
|
|
94
|
-
n = len(matched_routes)
|
|
95
|
-
|
|
96
|
-
for i in range(n):
|
|
97
|
-
route = matched_routes[n - i - 1]
|
|
98
|
-
match, child_scope = route.matches(scope)
|
|
99
|
-
if match.value == 2:
|
|
100
|
-
scope.update(child_scope)
|
|
101
|
-
await route.handle(scope, receive, send)
|
|
102
|
-
return
|
|
103
|
-
elif match.value == 1 and partial is None:
|
|
104
|
-
partial = route
|
|
105
|
-
partial_scope = child_scope
|
|
106
|
-
|
|
107
|
-
if partial is not None:
|
|
108
|
-
scope.update(partial_scope)
|
|
109
|
-
await partial.handle(scope, receive, send)
|
|
110
|
-
return
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if scope["type"] == "http" and router.redirect_slashes and route_path != "/":
|
|
114
|
-
redirect_scope = dict(scope)
|
|
115
|
-
if route_path.endswith("/"):
|
|
116
|
-
redirect_scope["path"] = redirect_scope["path"].rstrip("/")
|
|
117
|
-
else:
|
|
118
|
-
redirect_scope["path"] = redirect_scope["path"] + "/"
|
|
119
|
-
|
|
120
|
-
for i in range(n):
|
|
121
|
-
route = matched_routes[n - i - 1]
|
|
122
|
-
match, child_scope = route.matches(redirect_scope)
|
|
123
|
-
if match.value != 0:
|
|
124
|
-
redirect_url = URL(scope=redirect_scope)
|
|
125
|
-
response = RedirectResponse(url=str(redirect_url))
|
|
126
|
-
await response(scope, receive, send)
|
|
127
|
-
return
|
|
128
|
-
|
|
129
|
-
await router.default(scope, receive, send)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
def install(app):
|
|
133
|
-
for route in app.routes:
|
|
134
|
-
add_route(route.path, root_node, route)
|
|
135
|
-
app.router.app = handle
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|