fastapi-extra 0.4.0__tar.gz → 0.5.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.
Files changed (33) hide show
  1. {fastapi_extra-0.4.0 → fastapi_extra-0.5.1}/PKG-INFO +3 -1
  2. {fastapi_extra-0.4.0 → fastapi_extra-0.5.1}/fastapi_extra/__init__.py +1 -1
  3. fastapi_extra-0.5.1/fastapi_extra/_patch.py +172 -0
  4. {fastapi_extra-0.4.0 → fastapi_extra-0.5.1}/fastapi_extra/database/model.py +2 -2
  5. {fastapi_extra-0.4.0 → fastapi_extra-0.5.1}/fastapi_extra/database/service.py +9 -4
  6. {fastapi_extra-0.4.0 → fastapi_extra-0.5.1}/fastapi_extra/response.py +10 -6
  7. {fastapi_extra-0.4.0 → fastapi_extra-0.5.1}/fastapi_extra.egg-info/PKG-INFO +3 -1
  8. {fastapi_extra-0.4.0 → fastapi_extra-0.5.1}/fastapi_extra.egg-info/requires.txt +3 -0
  9. {fastapi_extra-0.4.0 → fastapi_extra-0.5.1}/pyproject.toml +1 -0
  10. fastapi_extra-0.4.0/fastapi_extra/_patch.py +0 -121
  11. {fastapi_extra-0.4.0 → fastapi_extra-0.5.1}/LICENSE +0 -0
  12. {fastapi_extra-0.4.0 → fastapi_extra-0.5.1}/README.rst +0 -0
  13. {fastapi_extra-0.4.0 → fastapi_extra-0.5.1}/fastapi_extra/cache/__init__.py +0 -0
  14. {fastapi_extra-0.4.0 → fastapi_extra-0.5.1}/fastapi_extra/cache/redis.py +0 -0
  15. {fastapi_extra-0.4.0 → fastapi_extra-0.5.1}/fastapi_extra/cursor.pyi +0 -0
  16. {fastapi_extra-0.4.0 → fastapi_extra-0.5.1}/fastapi_extra/database/__init__.py +0 -0
  17. {fastapi_extra-0.4.0 → fastapi_extra-0.5.1}/fastapi_extra/database/session.py +0 -0
  18. {fastapi_extra-0.4.0 → fastapi_extra-0.5.1}/fastapi_extra/database/sqlmap.py +0 -0
  19. {fastapi_extra-0.4.0 → fastapi_extra-0.5.1}/fastapi_extra/dependency.py +0 -0
  20. {fastapi_extra-0.4.0 → fastapi_extra-0.5.1}/fastapi_extra/form.py +0 -0
  21. {fastapi_extra-0.4.0 → fastapi_extra-0.5.1}/fastapi_extra/native/cursor.pyx +0 -0
  22. {fastapi_extra-0.4.0 → fastapi_extra-0.5.1}/fastapi_extra/native/routing.pyx +0 -0
  23. {fastapi_extra-0.4.0 → fastapi_extra-0.5.1}/fastapi_extra/native/urlparse.pyx +0 -0
  24. {fastapi_extra-0.4.0 → fastapi_extra-0.5.1}/fastapi_extra/py.typed +0 -0
  25. {fastapi_extra-0.4.0 → fastapi_extra-0.5.1}/fastapi_extra/routing.pyi +0 -0
  26. {fastapi_extra-0.4.0 → fastapi_extra-0.5.1}/fastapi_extra/settings.py +0 -0
  27. {fastapi_extra-0.4.0 → fastapi_extra-0.5.1}/fastapi_extra/types.py +0 -0
  28. {fastapi_extra-0.4.0 → fastapi_extra-0.5.1}/fastapi_extra/urlparse.pyi +0 -0
  29. {fastapi_extra-0.4.0 → fastapi_extra-0.5.1}/fastapi_extra/utils.py +0 -0
  30. {fastapi_extra-0.4.0 → fastapi_extra-0.5.1}/fastapi_extra.egg-info/SOURCES.txt +0 -0
  31. {fastapi_extra-0.4.0 → fastapi_extra-0.5.1}/fastapi_extra.egg-info/dependency_links.txt +0 -0
  32. {fastapi_extra-0.4.0 → fastapi_extra-0.5.1}/fastapi_extra.egg-info/top_level.txt +0 -0
  33. {fastapi_extra-0.4.0 → fastapi_extra-0.5.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-extra
3
- Version: 0.4.0
3
+ Version: 0.5.1
4
4
  Summary: extra package for fastapi.
5
5
  Author-email: Ziyan Yin <408856732@qq.com>
6
6
  License: BSD-3-Clause
@@ -30,6 +30,8 @@ Provides-Extra: mysql
30
30
  Requires-Dist: asyncmy; extra == "mysql"
31
31
  Provides-Extra: pgsql
32
32
  Requires-Dist: asyncpg; extra == "pgsql"
33
+ Provides-Extra: oracle
34
+ Requires-Dist: python-oracledb; extra == "oracle"
33
35
  Provides-Extra: aiomysql
34
36
  Requires-Dist: aiomysql; extra == "aiomysql"
35
37
  Dynamic: license-file
@@ -1,4 +1,4 @@
1
- __version__ = "0.4.0"
1
+ __version__ = "0.5.1"
2
2
 
3
3
 
4
4
  from fastapi import FastAPI
@@ -0,0 +1,172 @@
1
+ __author__ = "ziyan.yin"
2
+ __date__ = "2026-01-13"
3
+
4
+ from typing import Any, Mapping, Sequence
5
+
6
+ from fastapi import FastAPI, params
7
+ from fastapi._compat import (ModelField, get_cached_model_fields,
8
+ lenient_issubclass, shared)
9
+ from fastapi.dependencies import utils
10
+ from pydantic import BaseModel, Json
11
+ from pydantic.fields import FieldInfo
12
+ from starlette import datastructures
13
+
14
+ from fastapi_extra import routing
15
+ from fastapi_extra.urlparse import parse_qsl
16
+
17
+ _ID_MAP: dict[int, bool] = {}
18
+ _ALIAS_MAP: dict[int, str] = {}
19
+
20
+
21
+ def install_routes(app: FastAPI) -> None:
22
+ routing.install(app)
23
+
24
+
25
+ def is_sequence_field(annotation: type[Any] | None) -> bool:
26
+ _id = id(annotation)
27
+ if _id not in _ID_MAP:
28
+ _ID_MAP[_id] = shared.field_annotation_is_sequence(annotation)
29
+ return _ID_MAP[_id]
30
+
31
+
32
+ def is_json_field(field_info: FieldInfo):
33
+ _id = id(field_info)
34
+ if _id not in _ID_MAP:
35
+ _ID_MAP[_id] = any(type(item) is Json for item in field_info.metadata)
36
+ return _ID_MAP[_id]
37
+
38
+
39
+ def _get_multidict_value(
40
+ field: ModelField,
41
+ values: Mapping[str, Any],
42
+ alias: str,
43
+ is_json: bool,
44
+ is_sequence: bool
45
+ ) -> Any:
46
+ if (not is_json) and is_sequence and hasattr(values, "getlist"):
47
+ value = values.getlist(alias) # pyright: ignore[reportAttributeAccessIssue]
48
+ else:
49
+ value = values.get(alias, None)
50
+
51
+ if (
52
+ (
53
+ value == ""
54
+ and isinstance(field.field_info, params.Form)
55
+ )
56
+ or (
57
+ is_sequence
58
+ and len(value) == 0
59
+ )
60
+ ):
61
+ return None
62
+
63
+ return value
64
+
65
+
66
+ def request_params_to_args(
67
+ fields: Sequence[ModelField],
68
+ received_params: Mapping[str, Any] | datastructures.QueryParams | datastructures.Headers,
69
+ ) -> tuple[dict[str, Any], list[Any]]:
70
+ values: dict[str, Any] = {}
71
+ errors: list[dict[str, Any]] = []
72
+
73
+ if not fields:
74
+ return values, errors
75
+
76
+ first_field = fields[0]
77
+
78
+ is_multidict = hasattr(received_params, "getlist")
79
+ is_headers = isinstance(received_params, datastructures.Headers)
80
+
81
+ # =========================================================================
82
+ # 分支 A:单模型解析 (例如使用 Pydantic 接收整个 Query 或是 Header 结构)
83
+ # =========================================================================
84
+ if len(fields) == 1 and lenient_issubclass(
85
+ first_field.field_info.annotation, BaseModel
86
+ ):
87
+ fields_to_extract = get_cached_model_fields(first_field.field_info.annotation)
88
+ default_convert_underscores = getattr(
89
+ first_field.field_info, "convert_underscores", True
90
+ )
91
+
92
+ params_to_process: dict[str, Any] = {}
93
+ processed_keys = set()
94
+
95
+ for field in fields_to_extract:
96
+ field_alias = utils.get_validation_alias(field)
97
+ alias = field_alias
98
+
99
+ if is_headers:
100
+ convert_underscores = getattr(
101
+ field.field_info, "convert_underscores", default_convert_underscores
102
+ )
103
+ if convert_underscores and alias == field.name:
104
+ alias = alias.replace("_", "-")
105
+
106
+ # 提前准备属性判断
107
+ is_json = is_json_field(field.field_info)
108
+ is_sequence = is_sequence_field(field.field_info.annotation)
109
+
110
+ value = _get_multidict_value(
111
+ field, received_params, alias=alias, is_json=is_json, is_sequence=is_sequence
112
+ )
113
+ if value is not None:
114
+ params_to_process[field_alias] = value
115
+ processed_keys.add(alias)
116
+
117
+ # 补全未在模型中显式定义的其他传入参数
118
+ for key in received_params.keys():
119
+ if key not in processed_keys:
120
+ if is_multidict:
121
+ value = received_params.getlist(key) # type: ignore
122
+ params_to_process[key] = value[0] if isinstance(value, list) and len(value) == 1 else value
123
+ else:
124
+ params_to_process[key] = received_params.get(key)
125
+
126
+ field_info = first_field.field_info
127
+ assert isinstance(field_info, params.Param), "Params must be subclasses of Param"
128
+
129
+ v_, errors_ = first_field.validate(params_to_process, values, loc=(field_info.in_.value,))
130
+ return {first_field.name: v_}, errors_
131
+
132
+ # =========================================================================
133
+ # 分支 B:多参数平铺解析 (Fast Path - 大部分常规路由走这里)
134
+ # =========================================================================
135
+ else:
136
+ for field in fields:
137
+ field_alias = utils.get_validation_alias(field)
138
+ field_info = field.field_info
139
+
140
+ # 属性判断预提取与缓存
141
+ is_json = is_json_field(field_info)
142
+ is_sequence = is_sequence_field(field_info.annotation)
143
+
144
+ value = _get_multidict_value(
145
+ field, received_params, alias=field_alias, is_json=is_json, is_sequence=is_sequence
146
+ )
147
+
148
+ assert isinstance(field_info, params.Param), "Params must be subclasses of Param"
149
+ loc = (field_info.in_.value, field_alias)
150
+
151
+ v_, errors_ = utils._validate_value_with_model_field(
152
+ field=field, value=value, values=values, loc=loc
153
+ )
154
+ if errors_:
155
+ errors.extend(errors_)
156
+ else:
157
+ values[field.name] = v_
158
+
159
+ return values, errors
160
+
161
+
162
+ def query_params_init(obj: datastructures.QueryParams, *args, **kwargs) -> None:
163
+ value = args[0] if args else []
164
+
165
+ if isinstance(value, bytes):
166
+ super(datastructures.QueryParams, obj).__init__(parse_qsl(value, keep_blank_values=True), **kwargs)
167
+ elif isinstance(value, str):
168
+ super(datastructures.QueryParams, obj).__init__(parse_qsl(value.encode("latin-1"), keep_blank_values=True), **kwargs)
169
+ else:
170
+ super(datastructures.QueryParams, obj).__init__(*args, **kwargs) # type: ignore[arg-type]
171
+ obj._list = [(str(k), str(v)) for k, v in obj._list]
172
+ obj._dict = {str(k): str(v) for k, v in obj._dict.items()}
@@ -14,7 +14,7 @@ from fastapi_extra.utils import get_machine_seed
14
14
 
15
15
 
16
16
  class AutoPK(SQLModel):
17
- id: int | None = Field(
17
+ id: int = Field(
18
18
  default=None,
19
19
  title="ID",
20
20
  primary_key=True,
@@ -25,7 +25,7 @@ class AutoPK(SQLModel):
25
25
 
26
26
 
27
27
  class LocalPK(SQLModel):
28
- id: Cursor | None = Field(
28
+ id: Cursor = Field(
29
29
  default_factory=_Cursor(get_machine_seed()).next_val,
30
30
  title="ID",
31
31
  primary_key=True,
@@ -1,9 +1,11 @@
1
1
  __author__ = "ziyan.yin"
2
2
  __date__ = "2025-01-12"
3
3
 
4
+
4
5
  from contextvars import ContextVar
5
6
  from typing import Any, Generic, Sequence, TypeVar
6
7
 
8
+ import anyio.to_thread
7
9
  from sqlalchemy import ColumnExpressionArgument
8
10
  from sqlmodel import select
9
11
 
@@ -52,15 +54,18 @@ class ModelService(AbstractService, Generic[Model], abstract=True):
52
54
  return (await self.session.exec(select(self.__model__).where(*clause))).all()
53
55
 
54
56
  async def create_model(self, **kwargs: Any) -> Model:
55
- model = self.__model__.model_validate(kwargs)
57
+ model = await anyio.to_thread.run_sync(self.__model__.model_validate, kwargs)
56
58
  self.session.add(model)
57
59
  await self.session.flush()
58
60
  return model
59
61
 
60
- async def update_model(self, model: Model, **kwargs: Any) -> Model:
62
+ async def update_model(self, model: Model, _ignore_none: bool = False, **kwargs: Any) -> Model:
61
63
  for key, value in kwargs.items():
62
- if key in model.__fields__:
63
- setattr(model, key, value)
64
+ if key not in model.__fields__:
65
+ continue
66
+ if _ignore_none and value is None:
67
+ continue
68
+ setattr(model, key, value)
64
69
  await self.session.flush()
65
70
  return model
66
71
 
@@ -3,7 +3,8 @@ __date__ = "2024-12-24"
3
3
 
4
4
 
5
5
  from enum import Enum
6
- from typing import TYPE_CHECKING, Generic, Mapping
6
+ from typing import (TYPE_CHECKING, Generic, Mapping, NotRequired, TypedDict,
7
+ Unpack)
7
8
 
8
9
  from fastapi.responses import JSONResponse
9
10
  from pydantic import BaseModel, Field
@@ -191,22 +192,25 @@ class ResultEnum(Enum):
191
192
  C0503 = ("C0503", "邮件提醒服务失败")
192
193
 
193
194
 
195
+ class ResponseKwargs(TypedDict):
196
+ status_code: NotRequired[int]
197
+ headers: NotRequired[Mapping[str, str] | None]
198
+
199
+
194
200
  class APIResult(BaseModel, Generic[T]):
195
201
  data: T | None = Field(default=None, title="返回数据")
196
202
 
197
203
  if TYPE_CHECKING:
198
-
199
204
  @classmethod
200
- def ok(cls, data: T | None = None) -> "APIResult[T]":
205
+ def ok(cls, data: T | None = None, **response_kwargs: Unpack[ResponseKwargs]) -> "APIResult[T]":
201
206
  return APIResult(data=data)
202
207
 
203
208
  else:
204
-
205
209
  @classmethod
206
- def ok(cls, data: T | None = None) -> "APIResponse":
210
+ def ok(cls, data: T | None = None, status_code: int = 200, headers: Mapping[str, str] | None = None) -> "APIResponse":
207
211
  model = cls.__new__(cls)
208
212
  model.__dict__["data"] = data
209
- return APIResponse(model)
213
+ return APIResponse(model, status_code=status_code, headers=headers)
210
214
 
211
215
 
212
216
  class APIResponse(JSONResponse):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-extra
3
- Version: 0.4.0
3
+ Version: 0.5.1
4
4
  Summary: extra package for fastapi.
5
5
  Author-email: Ziyan Yin <408856732@qq.com>
6
6
  License: BSD-3-Clause
@@ -30,6 +30,8 @@ Provides-Extra: mysql
30
30
  Requires-Dist: asyncmy; extra == "mysql"
31
31
  Provides-Extra: pgsql
32
32
  Requires-Dist: asyncpg; extra == "pgsql"
33
+ Provides-Extra: oracle
34
+ Requires-Dist: python-oracledb; extra == "oracle"
33
35
  Provides-Extra: aiomysql
34
36
  Requires-Dist: aiomysql; extra == "aiomysql"
35
37
  Dynamic: license-file
@@ -9,6 +9,9 @@ aiomysql
9
9
  [mysql]
10
10
  asyncmy
11
11
 
12
+ [oracle]
13
+ python-oracledb
14
+
12
15
  [pgsql]
13
16
  asyncpg
14
17
 
@@ -41,6 +41,7 @@ redis = ["redis"]
41
41
  scheduler = ["apscheduler"]
42
42
  mysql = ["asyncmy"]
43
43
  pgsql = ["asyncpg"]
44
+ oracle = ["python-oracledb"]
44
45
  aiomysql = ["aiomysql"]
45
46
 
46
47
  [tool.setuptools]
@@ -1,121 +0,0 @@
1
- __author__ = "ziyan.yin"
2
- __date__ = "2026-01-13"
3
-
4
- import functools
5
- from typing import Any, Mapping, Sequence, Union
6
-
7
- from fastapi import FastAPI, params
8
- from fastapi._compat import (ModelField, get_cached_model_fields,
9
- lenient_issubclass, shared)
10
- from fastapi.dependencies import utils
11
- from pydantic import BaseModel
12
- from starlette import datastructures
13
-
14
- from fastapi_extra import routing
15
- from fastapi_extra.urlparse import parse_qsl
16
-
17
-
18
- def install_routes(app: FastAPI) -> None:
19
- routing.install(app)
20
-
21
-
22
- @functools.lru_cache
23
- def is_sequence_field(annotation: type[Any]) -> bool:
24
- return shared.field_annotation_is_sequence(annotation)
25
-
26
-
27
- def request_params_to_args(
28
- fields: Sequence[ModelField],
29
- received_params: Union[Mapping[str, Any], datastructures.QueryParams, datastructures.Headers],
30
- ) -> tuple[dict[str, Any], list[Any]]:
31
- values: dict[str, Any] = {}
32
- errors: list[dict[str, Any]] = []
33
-
34
- if not fields:
35
- return values, errors
36
-
37
- first_field = fields[0]
38
- fields_to_extract = fields
39
- single_not_embedded_field = False
40
- default_convert_underscores = True
41
- if len(fields) == 1 and lenient_issubclass(
42
- first_field.field_info.annotation, BaseModel
43
- ):
44
- fields_to_extract = get_cached_model_fields(first_field.field_info.annotation)
45
- single_not_embedded_field = True
46
- # If headers are in a Pydantic model, the way to disable convert_underscores
47
- # would be with Header(convert_underscores=False) at the Pydantic model level
48
- default_convert_underscores = getattr(
49
- first_field.field_info, "convert_underscores", True
50
- )
51
-
52
- params_to_process: dict[str, Any] = {}
53
-
54
- processed_keys = set()
55
-
56
- for field in fields_to_extract:
57
- alias = field_alias = utils.get_validation_alias(field)
58
- if isinstance(received_params, datastructures.Headers):
59
- # Handle fields extracted from a Pydantic Model for a header, each field
60
- # doesn't have a FieldInfo of type Header with the default convert_underscores=True
61
- convert_underscores = getattr(
62
- field.field_info, "convert_underscores", default_convert_underscores
63
- )
64
- if convert_underscores and alias == field.name:
65
- alias = alias.replace("_", "-")
66
- value = utils._get_multidict_value(field, received_params, alias=alias)
67
- if value is not None:
68
- params_to_process[field_alias] = value
69
- processed_keys.add(alias)
70
-
71
- for key in received_params.keys():
72
- if key not in processed_keys:
73
- if hasattr(received_params, "getlist"):
74
- value = received_params.getlist(key) # type: ignore
75
- if isinstance(value, list) and (len(value) == 1):
76
- params_to_process[key] = value[0]
77
- else:
78
- params_to_process[key] = value
79
- else:
80
- params_to_process[key] = received_params.get(key)
81
-
82
- if single_not_embedded_field:
83
- field_info = first_field.field_info
84
- assert isinstance(field_info, params.Param), (
85
- "Params must be subclasses of Param"
86
- )
87
- loc: tuple[str, ...] = (field_info.in_.value,)
88
- v_, errors_ = utils._validate_value_with_model_field(
89
- field=first_field, value=params_to_process, values=values, loc=loc
90
- )
91
- return {first_field.name: v_}, errors_
92
-
93
- for field in fields:
94
- field_alias = utils.get_validation_alias(field)
95
- value = params_to_process.get(field_alias, None)
96
- field_info = field.field_info
97
- assert isinstance(field_info, params.Param), (
98
- "Params must be subclasses of Param"
99
- )
100
- loc = (field_info.in_.value, field_alias)
101
- v_, errors_ = utils._validate_value_with_model_field(
102
- field=field, value=value, values=values, loc=loc
103
- )
104
- if errors_:
105
- errors.extend(errors_)
106
- else:
107
- values[field.name] = v_
108
- return values, errors
109
-
110
-
111
- def query_params_init(obj: datastructures.QueryParams, *args, **kwargs) -> None:
112
- value = args[0] if args else []
113
-
114
- if isinstance(value, bytes):
115
- super(datastructures.QueryParams, obj).__init__(parse_qsl(value, keep_blank_values=True), **kwargs)
116
- elif isinstance(value, str):
117
- super(datastructures.QueryParams, obj).__init__(parse_qsl(value.encode("latin-1"), keep_blank_values=True), **kwargs)
118
- else:
119
- super(datastructures.QueryParams, obj).__init__(*args, **kwargs) # type: ignore[arg-type]
120
- obj._list = [(str(k), str(v)) for k, v in obj._list]
121
- obj._dict = {str(k): str(v) for k, v in obj._dict.items()}
File without changes
File without changes
File without changes