panther 4.3.6__tar.gz → 5.0.0b1__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.
- {panther-4.3.6 → panther-5.0.0b1}/PKG-INFO +32 -18
- {panther-4.3.6 → panther-5.0.0b1}/panther/__init__.py +1 -1
- {panther-4.3.6 → panther-5.0.0b1}/panther/_load_configs.py +78 -64
- {panther-4.3.6 → panther-5.0.0b1}/panther/_utils.py +2 -2
- panther-5.0.0b1/panther/app.py +289 -0
- {panther-4.3.6 → panther-5.0.0b1}/panther/authentications.py +26 -9
- {panther-4.3.6 → panther-5.0.0b1}/panther/base_request.py +27 -2
- {panther-4.3.6 → panther-5.0.0b1}/panther/base_websocket.py +26 -27
- {panther-4.3.6 → panther-5.0.0b1}/panther/cli/create_command.py +1 -0
- panther-5.0.0b1/panther/cli/main.py +52 -0
- {panther-4.3.6 → panther-5.0.0b1}/panther/cli/monitor_command.py +8 -4
- {panther-4.3.6 → panther-5.0.0b1}/panther/cli/template.py +11 -6
- {panther-4.3.6 → panther-5.0.0b1}/panther/cli/utils.py +3 -2
- {panther-4.3.6 → panther-5.0.0b1}/panther/configs.py +7 -9
- {panther-4.3.6 → panther-5.0.0b1}/panther/db/cursor.py +23 -7
- {panther-4.3.6 → panther-5.0.0b1}/panther/db/models.py +26 -19
- {panther-4.3.6 → panther-5.0.0b1}/panther/db/queries/base_queries.py +1 -1
- panther-5.0.0b1/panther/db/queries/mongodb_queries.py +307 -0
- {panther-4.3.6 → panther-5.0.0b1}/panther/db/queries/pantherdb_queries.py +5 -5
- {panther-4.3.6 → panther-5.0.0b1}/panther/db/queries/queries.py +1 -1
- {panther-4.3.6 → panther-5.0.0b1}/panther/events.py +10 -4
- {panther-4.3.6 → panther-5.0.0b1}/panther/exceptions.py +24 -2
- {panther-4.3.6 → panther-5.0.0b1}/panther/generics.py +2 -2
- panther-5.0.0b1/panther/main.py +190 -0
- panther-5.0.0b1/panther/middlewares/__init__.py +1 -0
- panther-5.0.0b1/panther/middlewares/base.py +26 -0
- panther-5.0.0b1/panther/middlewares/monitoring.py +42 -0
- panther-5.0.0b1/panther/openapi/__init__.py +1 -0
- panther-5.0.0b1/panther/openapi/templates/openapi.html +27 -0
- panther-5.0.0b1/panther/openapi/urls.py +5 -0
- panther-5.0.0b1/panther/openapi/utils.py +167 -0
- panther-5.0.0b1/panther/openapi/views.py +101 -0
- {panther-4.3.6 → panther-5.0.0b1}/panther/pagination.py +1 -1
- panther-5.0.0b1/panther/panel/middlewares.py +10 -0
- panther-5.0.0b1/panther/panel/templates/base.html +14 -0
- panther-5.0.0b1/panther/panel/templates/create.html +21 -0
- panther-5.0.0b1/panther/panel/templates/create.js +1270 -0
- panther-5.0.0b1/panther/panel/templates/detail.html +55 -0
- panther-5.0.0b1/panther/panel/templates/home.html +9 -0
- panther-5.0.0b1/panther/panel/templates/home.js +30 -0
- panther-5.0.0b1/panther/panel/templates/login.html +47 -0
- panther-5.0.0b1/panther/panel/templates/sidebar.html +13 -0
- panther-5.0.0b1/panther/panel/templates/table.html +73 -0
- panther-5.0.0b1/panther/panel/templates/table.js +339 -0
- panther-5.0.0b1/panther/panel/urls.py +13 -0
- panther-5.0.0b1/panther/panel/utils.py +115 -0
- panther-5.0.0b1/panther/panel/views.py +143 -0
- {panther-4.3.6 → panther-5.0.0b1}/panther/request.py +3 -0
- {panther-4.3.6 → panther-5.0.0b1}/panther/response.py +91 -53
- {panther-4.3.6 → panther-5.0.0b1}/panther/routings.py +7 -2
- {panther-4.3.6 → panther-5.0.0b1}/panther/serializer.py +1 -1
- {panther-4.3.6 → panther-5.0.0b1}/panther/utils.py +34 -26
- {panther-4.3.6 → panther-5.0.0b1}/panther/websocket.py +3 -0
- {panther-4.3.6 → panther-5.0.0b1}/panther.egg-info/PKG-INFO +32 -18
- {panther-4.3.6 → panther-5.0.0b1}/panther.egg-info/SOURCES.txt +21 -0
- panther-5.0.0b1/panther.egg-info/requires.txt +23 -0
- {panther-4.3.6 → panther-5.0.0b1}/setup.py +19 -17
- {panther-4.3.6 → panther-5.0.0b1}/tests/test_authentication.py +8 -8
- {panther-4.3.6 → panther-5.0.0b1}/tests/test_database.py +10 -6
- panther-5.0.0b1/tests/test_database_advance.py +336 -0
- panther-5.0.0b1/tests/test_middlewares.py +275 -0
- {panther-4.3.6 → panther-5.0.0b1}/tests/test_multipart.py +31 -5
- panther-5.0.0b1/tests/test_openapi.py +275 -0
- {panther-4.3.6 → panther-5.0.0b1}/tests/test_panel_apis.py +5 -4
- {panther-4.3.6 → panther-5.0.0b1}/tests/test_request.py +2 -2
- {panther-4.3.6 → panther-5.0.0b1}/tests/test_response.py +1 -1
- {panther-4.3.6 → panther-5.0.0b1}/tests/test_routing.py +118 -11
- {panther-4.3.6 → panther-5.0.0b1}/tests/test_run.py +3 -3
- {panther-4.3.6 → panther-5.0.0b1}/tests/test_utils.py +16 -13
- {panther-4.3.6 → panther-5.0.0b1}/tests/test_websockets.py +2 -2
- panther-4.3.6/panther/app.py +0 -223
- panther-4.3.6/panther/cli/main.py +0 -60
- panther-4.3.6/panther/db/queries/mongodb_queries.py +0 -145
- panther-4.3.6/panther/main.py +0 -227
- panther-4.3.6/panther/middlewares/__init__.py +0 -1
- panther-4.3.6/panther/middlewares/base.py +0 -30
- panther-4.3.6/panther/panel/urls.py +0 -8
- panther-4.3.6/panther/panel/utils.py +0 -17
- panther-4.3.6/panther.egg-info/requires.txt +0 -21
- {panther-4.3.6 → panther-5.0.0b1}/LICENSE +0 -0
- {panther-4.3.6 → panther-5.0.0b1}/README.md +0 -0
- {panther-4.3.6 → panther-5.0.0b1}/panther/background_tasks.py +0 -0
- {panther-4.3.6 → panther-5.0.0b1}/panther/caching.py +0 -0
- {panther-4.3.6 → panther-5.0.0b1}/panther/cli/__init__.py +0 -0
- {panther-4.3.6 → panther-5.0.0b1}/panther/cli/run_command.py +0 -0
- {panther-4.3.6 → panther-5.0.0b1}/panther/db/__init__.py +0 -0
- {panther-4.3.6 → panther-5.0.0b1}/panther/db/connections.py +0 -0
- {panther-4.3.6 → panther-5.0.0b1}/panther/db/queries/__init__.py +0 -0
- {panther-4.3.6 → panther-5.0.0b1}/panther/db/utils.py +0 -0
- {panther-4.3.6 → panther-5.0.0b1}/panther/file_handler.py +0 -0
- {panther-4.3.6 → panther-5.0.0b1}/panther/logging.py +0 -0
- {panther-4.3.6 → panther-5.0.0b1}/panther/monitoring.py +0 -0
- {panther-4.3.6 → panther-5.0.0b1}/panther/panel/__init__.py +0 -0
- {panther-4.3.6 → panther-5.0.0b1}/panther/panel/apis.py +0 -0
- {panther-4.3.6 → panther-5.0.0b1}/panther/permissions.py +0 -0
- {panther-4.3.6 → panther-5.0.0b1}/panther/status.py +0 -0
- {panther-4.3.6 → panther-5.0.0b1}/panther/test.py +0 -0
- {panther-4.3.6 → panther-5.0.0b1}/panther/throttling.py +0 -0
- {panther-4.3.6 → panther-5.0.0b1}/panther.egg-info/dependency_links.txt +0 -0
- {panther-4.3.6 → panther-5.0.0b1}/panther.egg-info/entry_points.txt +0 -0
- {panther-4.3.6 → panther-5.0.0b1}/panther.egg-info/top_level.txt +0 -0
- {panther-4.3.6 → panther-5.0.0b1}/pyproject.toml +0 -0
- {panther-4.3.6 → panther-5.0.0b1}/setup.cfg +0 -0
- {panther-4.3.6 → panther-5.0.0b1}/tests/test_background_tasks.py +0 -0
- {panther-4.3.6 → panther-5.0.0b1}/tests/test_caching.py +0 -0
- {panther-4.3.6 → panther-5.0.0b1}/tests/test_cli.py +0 -0
- {panther-4.3.6 → panther-5.0.0b1}/tests/test_events.py +0 -0
- {panther-4.3.6 → panther-5.0.0b1}/tests/test_generics.py +0 -0
- {panther-4.3.6 → panther-5.0.0b1}/tests/test_serializer.py +0 -0
- {panther-4.3.6 → panther-5.0.0b1}/tests/test_status.py +0 -0
- {panther-4.3.6 → panther-5.0.0b1}/tests/test_throttling.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
|
-
Metadata-Version: 2.
|
1
|
+
Metadata-Version: 2.4
|
2
2
|
Name: panther
|
3
|
-
Version:
|
3
|
+
Version: 5.0.0b1
|
4
4
|
Summary: Fast & Friendly, Web Framework For Building Async APIs
|
5
5
|
Home-page: https://github.com/alirn76/panther
|
6
6
|
Author: Ali RajabNezhad
|
@@ -14,25 +14,39 @@ Classifier: Programming Language :: Python :: 3.13
|
|
14
14
|
Requires-Python: >=3.10
|
15
15
|
Description-Content-Type: text/markdown
|
16
16
|
License-File: LICENSE
|
17
|
-
Requires-Dist: pantherdb~=2.
|
18
|
-
Requires-Dist:
|
19
|
-
Requires-Dist:
|
20
|
-
Requires-Dist:
|
21
|
-
Requires-Dist:
|
17
|
+
Requires-Dist: pantherdb~=2.2.3
|
18
|
+
Requires-Dist: orjson~=3.9.15
|
19
|
+
Requires-Dist: pydantic~=2.10.6
|
20
|
+
Requires-Dist: rich~=13.9.4
|
21
|
+
Requires-Dist: uvicorn~=0.34.0
|
22
|
+
Requires-Dist: pytz~=2025.2
|
22
23
|
Requires-Dist: Jinja2~=3.1
|
23
|
-
Requires-Dist:
|
24
|
+
Requires-Dist: simple-ulid~=1.0.0
|
25
|
+
Requires-Dist: httptools~=0.6.4
|
24
26
|
Provides-Extra: full
|
25
|
-
Requires-Dist: redis==5.
|
26
|
-
Requires-Dist: motor~=3.
|
27
|
-
Requires-Dist:
|
28
|
-
Requires-Dist: python-jose~=3.
|
29
|
-
Requires-Dist: ruff~=0.
|
30
|
-
Requires-Dist: websockets~=
|
31
|
-
Requires-Dist: cryptography~=
|
32
|
-
Requires-Dist: watchfiles~=0.
|
27
|
+
Requires-Dist: redis==5.2.1; extra == "full"
|
28
|
+
Requires-Dist: motor~=3.7.0; extra == "full"
|
29
|
+
Requires-Dist: ipython~=9.0.2; extra == "full"
|
30
|
+
Requires-Dist: python-jose~=3.4.0; extra == "full"
|
31
|
+
Requires-Dist: ruff~=0.11.2; extra == "full"
|
32
|
+
Requires-Dist: websockets~=15.0.1; extra == "full"
|
33
|
+
Requires-Dist: cryptography~=44.0.2; extra == "full"
|
34
|
+
Requires-Dist: watchfiles~=1.0.4; extra == "full"
|
33
35
|
Provides-Extra: dev
|
34
|
-
Requires-Dist: ruff~=0.
|
35
|
-
Requires-Dist: pytest~=8.3.
|
36
|
+
Requires-Dist: ruff~=0.11.2; extra == "dev"
|
37
|
+
Requires-Dist: pytest~=8.3.5; extra == "dev"
|
38
|
+
Dynamic: author
|
39
|
+
Dynamic: author-email
|
40
|
+
Dynamic: classifier
|
41
|
+
Dynamic: description
|
42
|
+
Dynamic: description-content-type
|
43
|
+
Dynamic: home-page
|
44
|
+
Dynamic: license
|
45
|
+
Dynamic: license-file
|
46
|
+
Dynamic: provides-extra
|
47
|
+
Dynamic: requires-dist
|
48
|
+
Dynamic: requires-python
|
49
|
+
Dynamic: summary
|
36
50
|
|
37
51
|
|
38
52
|
[](https://pypi.org/project/panther/) [](https://pypi.org/project/panther/) [](https://codecov.io/github/AliRn76/panther) [](https://pepy.tech/project/panther) [](https://github.com/alirn76/panther/blob/main/LICENSE)
|
@@ -16,7 +16,8 @@ from panther.db.queries.mongodb_queries import BaseMongoDBQuery
|
|
16
16
|
from panther.db.queries.pantherdb_queries import BasePantherDBQuery
|
17
17
|
from panther.exceptions import PantherError
|
18
18
|
from panther.middlewares.base import WebsocketMiddleware, HTTPMiddleware
|
19
|
-
from panther.
|
19
|
+
from panther.middlewares.monitoring import MonitoringMiddleware, WebsocketMonitoringMiddleware
|
20
|
+
from panther.panel.views import HomeView
|
20
21
|
from panther.routings import finalize_urls, flatten_urls
|
21
22
|
|
22
23
|
__all__ = (
|
@@ -27,7 +28,6 @@ __all__ = (
|
|
27
28
|
'load_timezone',
|
28
29
|
'load_database',
|
29
30
|
'load_secret_key',
|
30
|
-
'load_monitoring',
|
31
31
|
'load_throttling',
|
32
32
|
'load_user_model',
|
33
33
|
'load_log_queries',
|
@@ -36,8 +36,8 @@ __all__ = (
|
|
36
36
|
'load_auto_reformat',
|
37
37
|
'load_background_tasks',
|
38
38
|
'load_default_cache_exp',
|
39
|
-
'load_authentication_class',
|
40
39
|
'load_urls',
|
40
|
+
'load_authentication_class',
|
41
41
|
'load_websocket_connections',
|
42
42
|
'check_endpoints_inheritance',
|
43
43
|
)
|
@@ -94,7 +94,15 @@ def load_templates_dir(_configs: dict, /) -> None:
|
|
94
94
|
if config.TEMPLATES_DIR == '.':
|
95
95
|
config.TEMPLATES_DIR = config.BASE_DIR
|
96
96
|
|
97
|
-
config.JINJA_ENVIRONMENT = jinja2.Environment(
|
97
|
+
config.JINJA_ENVIRONMENT = jinja2.Environment(
|
98
|
+
loader=jinja2.ChoiceLoader(
|
99
|
+
loaders=(
|
100
|
+
jinja2.FileSystemLoader(searchpath=config.TEMPLATES_DIR),
|
101
|
+
jinja2.PackageLoader(package_name='panther', package_path='panel/templates/'),
|
102
|
+
jinja2.PackageLoader(package_name='panther', package_path='openapi/templates/'),
|
103
|
+
)
|
104
|
+
)
|
105
|
+
)
|
98
106
|
|
99
107
|
|
100
108
|
def load_database(_configs: dict, /) -> None:
|
@@ -126,11 +134,6 @@ def load_secret_key(_configs: dict, /) -> None:
|
|
126
134
|
config.SECRET_KEY = secret_key.encode()
|
127
135
|
|
128
136
|
|
129
|
-
def load_monitoring(_configs: dict, /) -> None:
|
130
|
-
if _configs.get('MONITORING'):
|
131
|
-
config.MONITORING = True
|
132
|
-
|
133
|
-
|
134
137
|
def load_throttling(_configs: dict, /) -> None:
|
135
138
|
if throttling := _configs.get('THROTTLING'):
|
136
139
|
config.THROTTLING = throttling
|
@@ -147,42 +150,45 @@ def load_log_queries(_configs: dict, /) -> None:
|
|
147
150
|
|
148
151
|
|
149
152
|
def load_middlewares(_configs: dict, /) -> None:
|
150
|
-
from panther.middlewares import BaseMiddleware
|
151
|
-
|
152
153
|
middlewares = {'http': [], 'ws': []}
|
153
154
|
|
154
155
|
# Collect Middlewares
|
155
156
|
for middleware in _configs.get('MIDDLEWARES') or []:
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
157
|
+
# This block is for Backward Compatibility
|
158
|
+
if isinstance(middleware, list | tuple):
|
159
|
+
if len(middleware) == 1:
|
160
|
+
middleware = middleware[0]
|
161
|
+
elif len(middleware) == 2:
|
162
|
+
_deprecated_warning(
|
163
|
+
field='MIDDLEWARES',
|
164
|
+
message='`data` does not supported in middlewares anymore, as your data is static you may want '
|
165
|
+
'to pass them to your middleware with config variables'
|
166
|
+
)
|
167
|
+
middleware = middleware[0]
|
168
|
+
else:
|
169
|
+
raise _exception_handler(
|
170
|
+
field='MIDDLEWARES', error=f'{middleware} should be dotted path or type of a middleware class')
|
171
|
+
|
172
|
+
# `middleware` can be type or path of a class
|
173
|
+
if not callable(middleware):
|
173
174
|
try:
|
174
|
-
|
175
|
+
middleware = import_class(middleware)
|
175
176
|
except (AttributeError, ModuleNotFoundError):
|
176
|
-
raise _exception_handler(
|
177
|
+
raise _exception_handler(
|
178
|
+
field='MIDDLEWARES', error=f'{middleware} is not a valid middleware path or type')
|
177
179
|
|
178
|
-
if issubclass(
|
179
|
-
|
180
|
+
if issubclass(middleware, (MonitoringMiddleware, WebsocketMonitoringMiddleware)):
|
181
|
+
config.MONITORING = True
|
180
182
|
|
181
|
-
if
|
182
|
-
middlewares['http'].append(
|
183
|
-
|
184
|
-
|
185
|
-
|
183
|
+
if issubclass(middleware, HTTPMiddleware):
|
184
|
+
middlewares['http'].append(middleware)
|
185
|
+
elif issubclass(middleware, WebsocketMiddleware):
|
186
|
+
middlewares['ws'].append(middleware)
|
187
|
+
else:
|
188
|
+
raise _exception_handler(
|
189
|
+
field='MIDDLEWARES',
|
190
|
+
error='is not a sub class of `HTTPMiddleware` or `WebsocketMiddleware`'
|
191
|
+
)
|
186
192
|
|
187
193
|
config.HTTP_MIDDLEWARES = middlewares['http']
|
188
194
|
config.WS_MIDDLEWARES = middlewares['ws']
|
@@ -204,32 +210,6 @@ def load_default_cache_exp(_configs: dict, /) -> None:
|
|
204
210
|
config.DEFAULT_CACHE_EXP = default_cache_exp
|
205
211
|
|
206
212
|
|
207
|
-
def load_authentication_class(_configs: dict, /) -> None:
|
208
|
-
"""Should be after `load_secret_key()`"""
|
209
|
-
if authentication := _configs.get('AUTHENTICATION'):
|
210
|
-
config.AUTHENTICATION = import_class(authentication)
|
211
|
-
|
212
|
-
if ws_authentication := _configs.get('WS_AUTHENTICATION'):
|
213
|
-
config.WS_AUTHENTICATION = import_class(ws_authentication)
|
214
|
-
|
215
|
-
load_jwt_config(_configs)
|
216
|
-
|
217
|
-
|
218
|
-
def load_jwt_config(_configs: dict, /) -> None:
|
219
|
-
"""Only Collect JWT Config If Authentication Is JWTAuthentication"""
|
220
|
-
auth_is_jwt = (
|
221
|
-
getattr(config.AUTHENTICATION, '__name__', None) == 'JWTAuthentication' or
|
222
|
-
getattr(config.WS_AUTHENTICATION, '__name__', None) == 'QueryParamJWTAuthentication'
|
223
|
-
)
|
224
|
-
jwt = _configs.get('JWTConfig', {})
|
225
|
-
if auth_is_jwt or jwt:
|
226
|
-
if 'key' not in jwt:
|
227
|
-
if config.SECRET_KEY is None:
|
228
|
-
raise _exception_handler(field='JWTConfig', error='`JWTConfig.key` or `SECRET_KEY` is required.')
|
229
|
-
jwt['key'] = config.SECRET_KEY.decode()
|
230
|
-
config.JWT_CONFIG = JWTConfig(**jwt)
|
231
|
-
|
232
|
-
|
233
213
|
def load_urls(_configs: dict, /, urls: dict | None) -> None:
|
234
214
|
"""
|
235
215
|
Return tuple of all urls (as a flat dict) and (as a nested dict)
|
@@ -261,7 +241,34 @@ def load_urls(_configs: dict, /, urls: dict | None) -> None:
|
|
261
241
|
|
262
242
|
config.FLAT_URLS = flatten_urls(urls)
|
263
243
|
config.URLS = finalize_urls(config.FLAT_URLS)
|
264
|
-
|
244
|
+
|
245
|
+
|
246
|
+
def load_authentication_class(_configs: dict, /) -> None:
|
247
|
+
"""Should be after `load_secret_key()` and `load_urls()`"""
|
248
|
+
if authentication := _configs.get('AUTHENTICATION'):
|
249
|
+
config.AUTHENTICATION = import_class(authentication)
|
250
|
+
|
251
|
+
if ws_authentication := _configs.get('WS_AUTHENTICATION'):
|
252
|
+
config.WS_AUTHENTICATION = import_class(ws_authentication)
|
253
|
+
|
254
|
+
load_jwt_config(_configs)
|
255
|
+
|
256
|
+
|
257
|
+
def load_jwt_config(_configs: dict, /) -> None:
|
258
|
+
"""Only Collect JWT Config If Authentication Is JWTAuthentication"""
|
259
|
+
auth_is_jwt = (
|
260
|
+
getattr(config.AUTHENTICATION, '__name__', None) == 'JWTAuthentication' or
|
261
|
+
getattr(config.WS_AUTHENTICATION, '__name__', None) == 'QueryParamJWTAuthentication'
|
262
|
+
)
|
263
|
+
jwt = _configs.get('JWTConfig', {})
|
264
|
+
|
265
|
+
using_panel_views = HomeView in config.FLAT_URLS.values()
|
266
|
+
if auth_is_jwt or using_panel_views:
|
267
|
+
if 'key' not in jwt:
|
268
|
+
if config.SECRET_KEY is None:
|
269
|
+
raise _exception_handler(field='JWTConfig', error='`JWTConfig.key` or `SECRET_KEY` is required.')
|
270
|
+
jwt['key'] = config.SECRET_KEY.decode()
|
271
|
+
config.JWT_CONFIG = JWTConfig(**jwt)
|
265
272
|
|
266
273
|
|
267
274
|
def load_websocket_connections():
|
@@ -281,6 +288,9 @@ def load_websocket_connections():
|
|
281
288
|
def check_endpoints_inheritance():
|
282
289
|
"""Should be after `load_urls()`"""
|
283
290
|
for _, endpoint in config.FLAT_URLS.items():
|
291
|
+
if endpoint == {}:
|
292
|
+
continue
|
293
|
+
|
284
294
|
if isinstance(endpoint, types.FunctionType):
|
285
295
|
check_function_type_endpoint(endpoint=endpoint)
|
286
296
|
else:
|
@@ -289,3 +299,7 @@ def check_endpoints_inheritance():
|
|
289
299
|
|
290
300
|
def _exception_handler(field: str, error: str | Exception) -> PantherError:
|
291
301
|
return PantherError(f"Invalid '{field}': {error}")
|
302
|
+
|
303
|
+
|
304
|
+
def _deprecated_warning(field: str, message: str):
|
305
|
+
return logger.warning(f"DEPRECATED '{field}': {message}")
|
@@ -10,7 +10,6 @@ from typing import Any, Generator, Iterator, AsyncGenerator
|
|
10
10
|
|
11
11
|
from panther.exceptions import PantherError
|
12
12
|
from panther.file_handler import File
|
13
|
-
from panther.websocket import GenericWebsocket
|
14
13
|
|
15
14
|
logger = logging.getLogger('panther')
|
16
15
|
|
@@ -49,7 +48,7 @@ def read_multipart_form_data(boundary: str, body: bytes) -> dict:
|
|
49
48
|
if row in (b'', b'--'):
|
50
49
|
continue
|
51
50
|
|
52
|
-
if match := re.match(pattern=field_pattern, string=row):
|
51
|
+
if match := re.match(pattern=field_pattern, string=row, flags=re.DOTALL):
|
53
52
|
_, field_name, _, value = match.groups()
|
54
53
|
data[field_name.decode('utf-8')] = value.decode('utf-8')
|
55
54
|
|
@@ -100,6 +99,7 @@ def check_function_type_endpoint(endpoint: types.FunctionType) -> Callable:
|
|
100
99
|
|
101
100
|
def check_class_type_endpoint(endpoint: Callable) -> Callable:
|
102
101
|
from panther.app import GenericAPI
|
102
|
+
from panther.websocket import GenericWebsocket
|
103
103
|
|
104
104
|
if not issubclass(endpoint, (GenericAPI, GenericWebsocket)):
|
105
105
|
raise PantherError(
|
@@ -0,0 +1,289 @@
|
|
1
|
+
import functools
|
2
|
+
import logging
|
3
|
+
import traceback
|
4
|
+
import typing
|
5
|
+
from datetime import timedelta
|
6
|
+
from typing import Literal, Callable
|
7
|
+
|
8
|
+
from orjson import JSONDecodeError
|
9
|
+
from pydantic import ValidationError, BaseModel
|
10
|
+
|
11
|
+
from panther._utils import is_function_async
|
12
|
+
from panther.caching import (
|
13
|
+
get_response_from_cache,
|
14
|
+
set_response_in_cache,
|
15
|
+
get_throttling_from_cache,
|
16
|
+
increment_throttling_in_cache
|
17
|
+
)
|
18
|
+
from panther.configs import config
|
19
|
+
from panther.exceptions import (
|
20
|
+
APIError,
|
21
|
+
AuthorizationAPIError,
|
22
|
+
JSONDecodeAPIError,
|
23
|
+
MethodNotAllowedAPIError,
|
24
|
+
ThrottlingAPIError,
|
25
|
+
BadRequestAPIError
|
26
|
+
)
|
27
|
+
from panther.exceptions import PantherError
|
28
|
+
from panther.middlewares import HTTPMiddleware
|
29
|
+
from panther.openapi import OutputSchema
|
30
|
+
from panther.permissions import BasePermission
|
31
|
+
from panther.request import Request
|
32
|
+
from panther.response import Response
|
33
|
+
from panther.serializer import ModelSerializer
|
34
|
+
from panther.throttling import Throttling
|
35
|
+
|
36
|
+
__all__ = ('API', 'GenericAPI')
|
37
|
+
|
38
|
+
logger = logging.getLogger('panther')
|
39
|
+
|
40
|
+
|
41
|
+
class API:
|
42
|
+
"""
|
43
|
+
input_model: The `request.data` will be validated with this attribute, It will raise an
|
44
|
+
`panther.exceptions.BadRequestAPIError` or put the validated data in the `request.validated_data`.
|
45
|
+
output_schema: This attribute only used in creation of OpenAPI scheme which is available in `panther.openapi.urls`
|
46
|
+
You may want to add its `url` to your urls.
|
47
|
+
auth: It will authenticate the user with header of its request or raise an
|
48
|
+
`panther.exceptions.AuthenticationAPIError`.
|
49
|
+
permissions: List of permissions that will be called sequentially after authentication to authorize the user.
|
50
|
+
throttling: It will limit the users' request on a specific (time-bucket, path)
|
51
|
+
cache: Response of the request will be cached.
|
52
|
+
cache_exp_time: Specify the expiry time of the cache. (default is `config.DEFAULT_CACHE_EXP`)
|
53
|
+
methods: Specify the allowed methods.
|
54
|
+
middlewares: These middlewares have inner priority than global middlewares.
|
55
|
+
"""
|
56
|
+
func: Callable
|
57
|
+
|
58
|
+
def __init__(
|
59
|
+
self,
|
60
|
+
*,
|
61
|
+
methods: list[Literal['GET', 'POST', 'PUT', 'PATCH', 'DELETE']] | None = None,
|
62
|
+
input_model: type[ModelSerializer] | type[BaseModel] | None = None,
|
63
|
+
output_model: type[BaseModel] | None = None,
|
64
|
+
output_schema: OutputSchema | None = None,
|
65
|
+
auth: bool = False,
|
66
|
+
permissions: list[BasePermission] | None = None,
|
67
|
+
throttling: Throttling | None = None,
|
68
|
+
cache: bool = False,
|
69
|
+
cache_exp_time: timedelta | int | None = None,
|
70
|
+
middlewares: list[HTTPMiddleware] | None = None,
|
71
|
+
):
|
72
|
+
self.methods = {m.upper() for m in methods} if methods else None
|
73
|
+
self.input_model = input_model
|
74
|
+
self.output_schema = output_schema
|
75
|
+
self.auth = auth
|
76
|
+
self.permissions = permissions or []
|
77
|
+
self.throttling = throttling
|
78
|
+
self.cache = cache
|
79
|
+
self.cache_exp_time = cache_exp_time
|
80
|
+
self.middlewares: list[HTTPMiddleware] | None = middlewares
|
81
|
+
self.request: Request | None = None
|
82
|
+
if output_model:
|
83
|
+
deprecation_message = (
|
84
|
+
traceback.format_stack(limit=2)[0] +
|
85
|
+
'\nThe `output_model` argument has been removed in Panther v5 and is no longer available.'
|
86
|
+
'\nPlease update your code to use the new approach. More info: '
|
87
|
+
'https://pantherpy.github.io/open_api/'
|
88
|
+
)
|
89
|
+
raise PantherError(deprecation_message)
|
90
|
+
|
91
|
+
def __call__(self, func):
|
92
|
+
self.func = func
|
93
|
+
|
94
|
+
@functools.wraps(func)
|
95
|
+
async def wrapper(request: Request) -> Response:
|
96
|
+
chained_func = self.handle_endpoint
|
97
|
+
if self.middlewares:
|
98
|
+
for middleware in reversed(self.middlewares):
|
99
|
+
chained_func = middleware(chained_func)
|
100
|
+
return await chained_func(request=request)
|
101
|
+
|
102
|
+
# Store attributes on the function, so have the same behaviour as class-based (useful in `openapi.view.OpenAPI`)
|
103
|
+
wrapper.auth = self.auth
|
104
|
+
wrapper.methods = self.methods
|
105
|
+
wrapper.permissions = self.permissions
|
106
|
+
wrapper.input_model = self.input_model
|
107
|
+
wrapper.output_schema = self.output_schema
|
108
|
+
return wrapper
|
109
|
+
|
110
|
+
async def handle_endpoint(self, request: Request) -> Response:
|
111
|
+
self.request = request
|
112
|
+
|
113
|
+
# 0. Preflight
|
114
|
+
if self.request.method == 'OPTIONS':
|
115
|
+
return self.options()
|
116
|
+
|
117
|
+
# 1. Check Method
|
118
|
+
if self.methods and self.request.method not in self.methods:
|
119
|
+
raise MethodNotAllowedAPIError
|
120
|
+
|
121
|
+
# 2. Authentication
|
122
|
+
await self.handle_authentication()
|
123
|
+
|
124
|
+
# 3. Permissions
|
125
|
+
await self.handle_permission()
|
126
|
+
|
127
|
+
# 4. Throttling
|
128
|
+
await self.handle_throttling()
|
129
|
+
|
130
|
+
# 5. Validate Input
|
131
|
+
if self.request.method in {'POST', 'PUT', 'PATCH'}:
|
132
|
+
self.handle_input_validation()
|
133
|
+
|
134
|
+
# 6. Get Cached Response
|
135
|
+
if self.cache and self.request.method == 'GET':
|
136
|
+
if cached := await get_response_from_cache(request=self.request, cache_exp_time=self.cache_exp_time):
|
137
|
+
return Response(data=cached.data, headers=cached.headers, status_code=cached.status_code)
|
138
|
+
|
139
|
+
# 7. Put PathVariables and Request(If User Wants It) In kwargs
|
140
|
+
kwargs = self.request.clean_parameters(self.func)
|
141
|
+
|
142
|
+
# 8. Call Endpoint
|
143
|
+
if is_function_async(self.func):
|
144
|
+
response = await self.func(**kwargs)
|
145
|
+
else:
|
146
|
+
response = self.func(**kwargs)
|
147
|
+
|
148
|
+
# 9. Clean Response
|
149
|
+
if not isinstance(response, Response):
|
150
|
+
response = Response(data=response)
|
151
|
+
if response.pagination:
|
152
|
+
response.data = await response.pagination.template(response.data)
|
153
|
+
|
154
|
+
# 10. Set New Response To Cache
|
155
|
+
if self.cache and self.request.method == 'GET':
|
156
|
+
await set_response_in_cache(request=self.request, response=response, cache_exp_time=self.cache_exp_time)
|
157
|
+
|
158
|
+
# 11. Warning CacheExpTime
|
159
|
+
if self.cache_exp_time and self.cache is False:
|
160
|
+
logger.warning('"cache_exp_time" won\'t work while "cache" is False')
|
161
|
+
|
162
|
+
return response
|
163
|
+
|
164
|
+
async def handle_authentication(self) -> None:
|
165
|
+
if self.auth:
|
166
|
+
if not config.AUTHENTICATION:
|
167
|
+
logger.critical('"AUTHENTICATION" has not been set in configs')
|
168
|
+
raise APIError
|
169
|
+
self.request.user = await config.AUTHENTICATION.authentication(self.request)
|
170
|
+
|
171
|
+
async def handle_throttling(self) -> None:
|
172
|
+
if throttling := self.throttling or config.THROTTLING:
|
173
|
+
if await get_throttling_from_cache(self.request, duration=throttling.duration) + 1 > throttling.rate:
|
174
|
+
raise ThrottlingAPIError
|
175
|
+
|
176
|
+
await increment_throttling_in_cache(self.request, duration=throttling.duration)
|
177
|
+
|
178
|
+
async def handle_permission(self) -> None:
|
179
|
+
for perm in self.permissions:
|
180
|
+
if type(perm.authorization).__name__ != 'method':
|
181
|
+
logger.error(f'{perm.__name__}.authorization should be "classmethod"')
|
182
|
+
raise AuthorizationAPIError
|
183
|
+
if await perm.authorization(self.request) is False:
|
184
|
+
raise AuthorizationAPIError
|
185
|
+
|
186
|
+
def handle_input_validation(self):
|
187
|
+
if self.input_model:
|
188
|
+
self.request.validated_data = self.validate_input(model=self.input_model, request=self.request)
|
189
|
+
|
190
|
+
@classmethod
|
191
|
+
def options(cls):
|
192
|
+
headers = {
|
193
|
+
'Access-Control-Allow-Methods': 'DELETE, GET, PATCH, POST, PUT, OPTIONS, HEAD',
|
194
|
+
'Access-Control-Allow-Headers': 'Accept, Authorization, User-Agent, Content-Type',
|
195
|
+
}
|
196
|
+
return Response(headers=headers)
|
197
|
+
|
198
|
+
@classmethod
|
199
|
+
def validate_input(cls, model, request: Request):
|
200
|
+
if isinstance(request.data, bytes):
|
201
|
+
raise BadRequestAPIError(detail='Content-Type is not valid')
|
202
|
+
if request.data is None:
|
203
|
+
raise BadRequestAPIError(detail='Request body is required')
|
204
|
+
try:
|
205
|
+
# `request` will be ignored in regular `BaseModel`
|
206
|
+
return model(**request.data, request=request)
|
207
|
+
except ValidationError as validation_error:
|
208
|
+
error = {'.'.join(str(loc) for loc in e['loc']): e['msg'] for e in validation_error.errors()}
|
209
|
+
raise BadRequestAPIError(detail=error)
|
210
|
+
except JSONDecodeError:
|
211
|
+
raise JSONDecodeAPIError
|
212
|
+
|
213
|
+
|
214
|
+
class MetaGenericAPI(type):
|
215
|
+
def __new__(
|
216
|
+
cls,
|
217
|
+
cls_name: str,
|
218
|
+
bases: tuple[type[typing.Any], ...],
|
219
|
+
namespace: dict[str, typing.Any],
|
220
|
+
**kwargs
|
221
|
+
):
|
222
|
+
if cls_name == 'GenericAPI':
|
223
|
+
return super().__new__(cls, cls_name, bases, namespace)
|
224
|
+
if 'output_model' in namespace:
|
225
|
+
deprecation_message = (
|
226
|
+
traceback.format_stack(limit=2)[0] +
|
227
|
+
'\nThe `output_model` argument has been removed in Panther v5 and is no longer available.'
|
228
|
+
'\nPlease update your code to use the new approach. More info: '
|
229
|
+
'https://pantherpy.github.io/open_api/'
|
230
|
+
)
|
231
|
+
raise PantherError(deprecation_message)
|
232
|
+
return super().__new__(cls, cls_name, bases, namespace)
|
233
|
+
|
234
|
+
|
235
|
+
class GenericAPI(metaclass=MetaGenericAPI):
|
236
|
+
"""
|
237
|
+
Check out the documentation of `panther.app.API()`.
|
238
|
+
"""
|
239
|
+
input_model: type[ModelSerializer] | type[BaseModel] | None = None
|
240
|
+
output_schema: OutputSchema | None = None
|
241
|
+
auth: bool = False
|
242
|
+
permissions: list | None = None
|
243
|
+
throttling: Throttling | None = None
|
244
|
+
cache: bool = False
|
245
|
+
cache_exp_time: timedelta | int | None = None
|
246
|
+
middlewares: list[HTTPMiddleware] | None = None
|
247
|
+
|
248
|
+
async def get(self, *args, **kwargs):
|
249
|
+
raise MethodNotAllowedAPIError
|
250
|
+
|
251
|
+
async def post(self, *args, **kwargs):
|
252
|
+
raise MethodNotAllowedAPIError
|
253
|
+
|
254
|
+
async def put(self, *args, **kwargs):
|
255
|
+
raise MethodNotAllowedAPIError
|
256
|
+
|
257
|
+
async def patch(self, *args, **kwargs):
|
258
|
+
raise MethodNotAllowedAPIError
|
259
|
+
|
260
|
+
async def delete(self, *args, **kwargs):
|
261
|
+
raise MethodNotAllowedAPIError
|
262
|
+
|
263
|
+
async def call_method(self, request: Request):
|
264
|
+
match request.method:
|
265
|
+
case 'GET':
|
266
|
+
func = self.get
|
267
|
+
case 'POST':
|
268
|
+
func = self.post
|
269
|
+
case 'PUT':
|
270
|
+
func = self.put
|
271
|
+
case 'PATCH':
|
272
|
+
func = self.patch
|
273
|
+
case 'DELETE':
|
274
|
+
func = self.delete
|
275
|
+
case 'OPTIONS':
|
276
|
+
func = API.options
|
277
|
+
case _:
|
278
|
+
raise MethodNotAllowedAPIError
|
279
|
+
|
280
|
+
return await API(
|
281
|
+
input_model=self.input_model,
|
282
|
+
output_schema=self.output_schema,
|
283
|
+
auth=self.auth,
|
284
|
+
permissions=self.permissions,
|
285
|
+
throttling=self.throttling,
|
286
|
+
cache=self.cache,
|
287
|
+
cache_exp_time=self.cache_exp_time,
|
288
|
+
middlewares=self.middlewares,
|
289
|
+
)(func)(request=request)
|
@@ -29,9 +29,9 @@ class BaseAuthentication:
|
|
29
29
|
msg = f'{cls.__name__}.authentication() is not implemented.'
|
30
30
|
raise cls.exception(msg) from None
|
31
31
|
|
32
|
-
@
|
33
|
-
def exception(message: str, /) -> type[AuthenticationAPIError]:
|
34
|
-
logger.error(f'
|
32
|
+
@classmethod
|
33
|
+
def exception(cls, message: str | Exception, /) -> type[AuthenticationAPIError]:
|
34
|
+
logger.error(f'{cls.__name__} Error: "{message}"')
|
35
35
|
return AuthenticationAPIError
|
36
36
|
|
37
37
|
|
@@ -151,16 +151,33 @@ class JWTAuthentication(BaseAuthentication):
|
|
151
151
|
key = generate_hash_value_from_string(token)
|
152
152
|
return bool(await redis.exists(key))
|
153
153
|
|
154
|
-
@staticmethod
|
155
|
-
def exception(message: str | JWTError | UnicodeEncodeError, /) -> type[AuthenticationAPIError]:
|
156
|
-
logger.error(f'JWT Authentication Error: "{message}"')
|
157
|
-
return AuthenticationAPIError
|
158
|
-
|
159
154
|
|
160
155
|
class QueryParamJWTAuthentication(JWTAuthentication):
|
161
156
|
@classmethod
|
162
157
|
def get_authorization_header(cls, request: Request | Websocket) -> str:
|
163
158
|
if auth := request.query_params.get('authorization'):
|
164
159
|
return auth
|
165
|
-
msg = '
|
160
|
+
msg = '`authorization` query param not found.'
|
161
|
+
raise cls.exception(msg) from None
|
162
|
+
|
163
|
+
|
164
|
+
class CookieJWTAuthentication(JWTAuthentication):
|
165
|
+
@classmethod
|
166
|
+
def get_authorization_header(cls, request: Request | Websocket) -> str:
|
167
|
+
if token := request.headers.get_cookies().get('access_token'):
|
168
|
+
return token
|
169
|
+
msg = '`access_token` Cookie not found.'
|
166
170
|
raise cls.exception(msg) from None
|
171
|
+
|
172
|
+
@classmethod
|
173
|
+
async def authentication(cls, request: Request | Websocket) -> Model:
|
174
|
+
token = cls.get_authorization_header(request)
|
175
|
+
|
176
|
+
if redis.is_connected and await cls._check_in_cache(token=token):
|
177
|
+
msg = 'User logged out'
|
178
|
+
raise cls.exception(msg) from None
|
179
|
+
|
180
|
+
payload = cls.decode_jwt(token)
|
181
|
+
user = await cls.get_user(payload)
|
182
|
+
user._auth_token = token
|
183
|
+
return user
|