panther 5.0.0b2__py3-none-any.whl → 5.0.0b4__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.
- panther/__init__.py +1 -1
- panther/_load_configs.py +46 -37
- panther/_utils.py +49 -34
- panther/app.py +96 -97
- panther/authentications.py +97 -50
- panther/background_tasks.py +98 -124
- panther/base_request.py +16 -10
- panther/base_websocket.py +8 -8
- panther/caching.py +16 -80
- panther/cli/create_command.py +17 -16
- panther/cli/main.py +1 -1
- panther/cli/monitor_command.py +11 -6
- panther/cli/run_command.py +5 -71
- panther/cli/template.py +7 -7
- panther/cli/utils.py +58 -69
- panther/configs.py +70 -72
- panther/db/connections.py +18 -24
- panther/db/cursor.py +0 -1
- panther/db/models.py +24 -8
- panther/db/queries/base_queries.py +2 -5
- panther/db/queries/mongodb_queries.py +17 -20
- panther/db/queries/pantherdb_queries.py +1 -1
- panther/db/queries/queries.py +26 -8
- panther/db/utils.py +1 -1
- panther/events.py +25 -14
- panther/exceptions.py +2 -7
- panther/file_handler.py +1 -1
- panther/generics.py +11 -8
- panther/logging.py +2 -1
- panther/main.py +12 -13
- panther/middlewares/cors.py +67 -0
- panther/middlewares/monitoring.py +5 -3
- panther/openapi/urls.py +2 -2
- panther/openapi/utils.py +3 -3
- panther/openapi/views.py +20 -37
- panther/pagination.py +4 -2
- panther/panel/apis.py +2 -7
- panther/panel/urls.py +2 -6
- panther/panel/utils.py +9 -5
- panther/panel/views.py +17 -23
- panther/permissions.py +2 -1
- panther/request.py +2 -1
- panther/response.py +53 -47
- panther/routings.py +12 -12
- panther/serializer.py +19 -20
- panther/test.py +73 -58
- panther/throttling.py +68 -3
- panther/utils.py +5 -11
- {panther-5.0.0b2.dist-info → panther-5.0.0b4.dist-info}/METADATA +1 -1
- panther-5.0.0b4.dist-info/RECORD +75 -0
- panther/monitoring.py +0 -34
- panther-5.0.0b2.dist-info/RECORD +0 -75
- {panther-5.0.0b2.dist-info → panther-5.0.0b4.dist-info}/WHEEL +0 -0
- {panther-5.0.0b2.dist-info → panther-5.0.0b4.dist-info}/entry_points.txt +0 -0
- {panther-5.0.0b2.dist-info → panther-5.0.0b4.dist-info}/licenses/LICENSE +0 -0
- {panther-5.0.0b2.dist-info → panther-5.0.0b4.dist-info}/top_level.txt +0 -0
panther/__init__.py
CHANGED
panther/_load_configs.py
CHANGED
@@ -6,8 +6,8 @@ from multiprocessing import Manager
|
|
6
6
|
|
7
7
|
import jinja2
|
8
8
|
|
9
|
-
from panther._utils import
|
10
|
-
from panther.background_tasks import
|
9
|
+
from panther._utils import check_class_type_endpoint, check_function_type_endpoint, import_class
|
10
|
+
from panther.background_tasks import _background_tasks
|
11
11
|
from panther.base_websocket import WebsocketConnections
|
12
12
|
from panther.cli.utils import import_error
|
13
13
|
from panther.configs import JWTConfig, config
|
@@ -15,34 +15,35 @@ from panther.db.connections import redis
|
|
15
15
|
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
|
-
from panther.middlewares.base import
|
18
|
+
from panther.middlewares.base import HTTPMiddleware, WebsocketMiddleware
|
19
19
|
from panther.middlewares.monitoring import MonitoringMiddleware, WebsocketMonitoringMiddleware
|
20
20
|
from panther.panel.views import HomeView
|
21
21
|
from panther.routings import finalize_urls, flatten_urls
|
22
22
|
|
23
23
|
__all__ = (
|
24
|
+
'check_endpoints_inheritance',
|
25
|
+
'load_authentication_class',
|
26
|
+
'load_auto_reformat',
|
27
|
+
'load_background_tasks',
|
24
28
|
'load_configs_module',
|
25
|
-
'load_redis',
|
26
|
-
'load_startup',
|
27
|
-
'load_shutdown',
|
28
|
-
'load_timezone',
|
29
29
|
'load_database',
|
30
|
-
'load_secret_key',
|
31
|
-
'load_throttling',
|
32
|
-
'load_user_model',
|
33
30
|
'load_log_queries',
|
34
31
|
'load_middlewares',
|
32
|
+
'load_other_configs',
|
33
|
+
'load_redis',
|
34
|
+
'load_secret_key',
|
35
|
+
'load_shutdown',
|
36
|
+
'load_startup',
|
35
37
|
'load_templates_dir',
|
36
|
-
'
|
37
|
-
'
|
38
|
-
'load_default_cache_exp',
|
38
|
+
'load_throttling',
|
39
|
+
'load_timezone',
|
39
40
|
'load_urls',
|
40
|
-
'
|
41
|
+
'load_user_model',
|
41
42
|
'load_websocket_connections',
|
42
|
-
'check_endpoints_inheritance',
|
43
43
|
)
|
44
44
|
|
45
45
|
logger = logging.getLogger('panther')
|
46
|
+
monitoring_logger = logging.getLogger('monitoring')
|
46
47
|
|
47
48
|
|
48
49
|
def load_configs_module(module_name: str, /) -> dict:
|
@@ -88,7 +89,7 @@ def load_timezone(_configs: dict, /) -> None:
|
|
88
89
|
|
89
90
|
|
90
91
|
def load_templates_dir(_configs: dict, /) -> None:
|
91
|
-
if templates_dir := _configs.get('TEMPLATES_DIR'):
|
92
|
+
if templates_dir := _configs.get('TEMPLATES_DIR', '.'):
|
92
93
|
config.TEMPLATES_DIR = templates_dir
|
93
94
|
|
94
95
|
if config.TEMPLATES_DIR == '.':
|
@@ -100,8 +101,8 @@ def load_templates_dir(_configs: dict, /) -> None:
|
|
100
101
|
jinja2.FileSystemLoader(searchpath=config.TEMPLATES_DIR),
|
101
102
|
jinja2.PackageLoader(package_name='panther', package_path='panel/templates/'),
|
102
103
|
jinja2.PackageLoader(package_name='panther', package_path='openapi/templates/'),
|
103
|
-
)
|
104
|
-
)
|
104
|
+
),
|
105
|
+
),
|
105
106
|
)
|
106
107
|
|
107
108
|
|
@@ -109,7 +110,7 @@ def load_database(_configs: dict, /) -> None:
|
|
109
110
|
database_config = _configs.get('DATABASE', {})
|
110
111
|
if 'engine' in database_config:
|
111
112
|
if 'class' not in database_config['engine']:
|
112
|
-
raise _exception_handler(field='DATABASE', error=
|
113
|
+
raise _exception_handler(field='DATABASE', error='`engine["class"]` not found.')
|
113
114
|
|
114
115
|
engine_class_path = database_config['engine']['class']
|
115
116
|
engine_class = import_class(engine_class_path)
|
@@ -131,7 +132,7 @@ def load_database(_configs: dict, /) -> None:
|
|
131
132
|
|
132
133
|
def load_secret_key(_configs: dict, /) -> None:
|
133
134
|
if secret_key := _configs.get('SECRET_KEY'):
|
134
|
-
config.SECRET_KEY = secret_key
|
135
|
+
config.SECRET_KEY = secret_key
|
135
136
|
|
136
137
|
|
137
138
|
def load_throttling(_configs: dict, /) -> None:
|
@@ -141,7 +142,8 @@ def load_throttling(_configs: dict, /) -> None:
|
|
141
142
|
|
142
143
|
def load_user_model(_configs: dict, /) -> None:
|
143
144
|
config.USER_MODEL = import_class(_configs.get('USER_MODEL', 'panther.db.models.BaseUser'))
|
144
|
-
config.
|
145
|
+
if config.USER_MODEL not in config.MODELS:
|
146
|
+
config.MODELS.append(config.USER_MODEL)
|
145
147
|
|
146
148
|
|
147
149
|
def load_log_queries(_configs: dict, /) -> None:
|
@@ -162,12 +164,14 @@ def load_middlewares(_configs: dict, /) -> None:
|
|
162
164
|
_deprecated_warning(
|
163
165
|
field='MIDDLEWARES',
|
164
166
|
message='`data` does not supported in middlewares anymore, as your data is static you may want '
|
165
|
-
|
167
|
+
'to pass them to your middleware with config variables',
|
166
168
|
)
|
167
169
|
middleware = middleware[0]
|
168
170
|
else:
|
169
171
|
raise _exception_handler(
|
170
|
-
field='MIDDLEWARES',
|
172
|
+
field='MIDDLEWARES',
|
173
|
+
error=f'{middleware} should be dotted path or type of a middleware class',
|
174
|
+
)
|
171
175
|
|
172
176
|
# `middleware` can be type or path of a class
|
173
177
|
if not callable(middleware):
|
@@ -175,9 +179,12 @@ def load_middlewares(_configs: dict, /) -> None:
|
|
175
179
|
middleware = import_class(middleware)
|
176
180
|
except (AttributeError, ModuleNotFoundError):
|
177
181
|
raise _exception_handler(
|
178
|
-
field='MIDDLEWARES',
|
182
|
+
field='MIDDLEWARES',
|
183
|
+
error=f'{middleware} is not a valid middleware path or type',
|
184
|
+
)
|
179
185
|
|
180
186
|
if issubclass(middleware, (MonitoringMiddleware, WebsocketMonitoringMiddleware)):
|
187
|
+
monitoring_logger.debug('') # Initiated
|
181
188
|
config.MONITORING = True
|
182
189
|
|
183
190
|
if issubclass(middleware, HTTPMiddleware):
|
@@ -187,7 +194,7 @@ def load_middlewares(_configs: dict, /) -> None:
|
|
187
194
|
else:
|
188
195
|
raise _exception_handler(
|
189
196
|
field='MIDDLEWARES',
|
190
|
-
error='is not a sub class of `HTTPMiddleware` or `WebsocketMiddleware`'
|
197
|
+
error='is not a sub class of `HTTPMiddleware` or `WebsocketMiddleware`',
|
191
198
|
)
|
192
199
|
|
193
200
|
config.HTTP_MIDDLEWARES = middlewares['http']
|
@@ -202,12 +209,14 @@ def load_auto_reformat(_configs: dict, /) -> None:
|
|
202
209
|
def load_background_tasks(_configs: dict, /) -> None:
|
203
210
|
if _configs.get('BACKGROUND_TASKS'):
|
204
211
|
config.BACKGROUND_TASKS = True
|
205
|
-
|
212
|
+
_background_tasks.initialize()
|
206
213
|
|
207
214
|
|
208
|
-
def
|
209
|
-
|
210
|
-
|
215
|
+
def load_other_configs(_configs: dict, /) -> None:
|
216
|
+
known_configs = set(config.__dataclass_fields__)
|
217
|
+
for key, value in _configs.items():
|
218
|
+
if key.isupper() and key not in known_configs:
|
219
|
+
config[key] = value
|
211
220
|
|
212
221
|
|
213
222
|
def load_urls(_configs: dict, /, urls: dict | None) -> None:
|
@@ -222,7 +231,7 @@ def load_urls(_configs: dict, /, urls: dict | None) -> None:
|
|
222
231
|
|
223
232
|
elif isinstance(url_routing, dict):
|
224
233
|
error = (
|
225
|
-
"can't be 'dict', you may want to pass it's value directly to Panther().
|
234
|
+
"can't be 'dict', you may want to pass it's value directly to Panther(). Example: Panther(..., urls=...)"
|
226
235
|
)
|
227
236
|
raise _exception_handler(field='URLs', error=error)
|
228
237
|
|
@@ -256,18 +265,18 @@ def load_authentication_class(_configs: dict, /) -> None:
|
|
256
265
|
|
257
266
|
def load_jwt_config(_configs: dict, /) -> None:
|
258
267
|
"""Only Collect JWT Config If Authentication Is JWTAuthentication"""
|
259
|
-
|
260
|
-
getattr(config.AUTHENTICATION, '__name__', None) == 'JWTAuthentication' or
|
261
|
-
getattr(config.WS_AUTHENTICATION, '__name__', None) == 'QueryParamJWTAuthentication'
|
262
|
-
)
|
263
|
-
jwt = _configs.get('JWTConfig', {})
|
268
|
+
from panther.authentications import JWTAuthentication
|
264
269
|
|
270
|
+
auth_is_jwt = (config.AUTHENTICATION and issubclass(config.AUTHENTICATION, JWTAuthentication)) or (
|
271
|
+
config.WS_AUTHENTICATION and issubclass(config.WS_AUTHENTICATION, JWTAuthentication)
|
272
|
+
)
|
273
|
+
jwt = _configs.get('JWT_CONFIG', {})
|
265
274
|
using_panel_views = HomeView in config.FLAT_URLS.values()
|
266
275
|
if auth_is_jwt or using_panel_views:
|
267
276
|
if 'key' not in jwt:
|
268
277
|
if config.SECRET_KEY is None:
|
269
278
|
raise _exception_handler(field='JWTConfig', error='`JWTConfig.key` or `SECRET_KEY` is required.')
|
270
|
-
jwt['key'] = config.SECRET_KEY
|
279
|
+
jwt['key'] = config.SECRET_KEY
|
271
280
|
config.JWT_CONFIG = JWTConfig(**jwt)
|
272
281
|
|
273
282
|
|
@@ -287,7 +296,7 @@ def load_websocket_connections():
|
|
287
296
|
|
288
297
|
def check_endpoints_inheritance():
|
289
298
|
"""Should be after `load_urls()`"""
|
290
|
-
for
|
299
|
+
for endpoint in config.FLAT_URLS.values():
|
291
300
|
if endpoint == {}:
|
292
301
|
continue
|
293
302
|
|
panther/_utils.py
CHANGED
@@ -4,9 +4,9 @@ import logging
|
|
4
4
|
import re
|
5
5
|
import subprocess
|
6
6
|
import types
|
7
|
-
from collections.abc import Callable
|
7
|
+
from collections.abc import AsyncGenerator, Callable, Generator, Iterator
|
8
8
|
from traceback import TracebackException
|
9
|
-
from typing import Any
|
9
|
+
from typing import Any
|
10
10
|
|
11
11
|
from panther.exceptions import PantherError
|
12
12
|
from panther.file_handler import File
|
@@ -20,51 +20,65 @@ def import_class(dotted_path: str, /) -> type[Any]:
|
|
20
20
|
-------
|
21
21
|
Input: panther.db.models.User
|
22
22
|
Output: User (The Class)
|
23
|
+
|
23
24
|
"""
|
24
25
|
path, name = dotted_path.rsplit('.', 1)
|
25
26
|
module = importlib.import_module(path)
|
26
27
|
return getattr(module, name)
|
27
28
|
|
28
29
|
|
30
|
+
NEWLINE_CRLF = b'\r\n' # Windows-style
|
31
|
+
NEWLINE_LF = b'\n' # Unix/Linux-style
|
32
|
+
|
33
|
+
# Regex patterns for CRLF (Windows)
|
34
|
+
FIELD_PATTERN_CRLF = re.compile(rb'Content-Disposition: form-data; name="(.*)"\r\n\r\n(.*)', flags=re.DOTALL)
|
35
|
+
FILE_PATTERN_CRLF = re.compile(rb'Content-Disposition: form-data; name="(.*)"; filename="(.*)"\r\nContent-Type: (.*)')
|
36
|
+
|
37
|
+
# Regex patterns for LF (Linux)
|
38
|
+
FIELD_PATTERN_LF = re.compile(rb'Content-Disposition: form-data; name="(.*)"\n\n(.*)', flags=re.DOTALL)
|
39
|
+
FILE_PATTERN_LF = re.compile(rb'Content-Disposition: form-data; name="(.*)"; filename="(.*)"\nContent-Type: (.*)')
|
40
|
+
|
41
|
+
|
29
42
|
def read_multipart_form_data(boundary: str, body: bytes) -> dict:
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
+ b'Content-Type: )(.*)'
|
42
|
-
)
|
43
|
+
boundary_bytes = b'--' + boundary.encode()
|
44
|
+
|
45
|
+
# Choose newline type and corresponding patterns
|
46
|
+
if body.endswith(NEWLINE_CRLF):
|
47
|
+
newline = NEWLINE_CRLF
|
48
|
+
field_pattern = FIELD_PATTERN_CRLF
|
49
|
+
file_pattern = FILE_PATTERN_CRLF
|
50
|
+
else:
|
51
|
+
newline = NEWLINE_LF
|
52
|
+
field_pattern = FIELD_PATTERN_LF
|
53
|
+
file_pattern = FILE_PATTERN_LF
|
43
54
|
|
44
55
|
data = {}
|
45
|
-
for
|
46
|
-
|
56
|
+
for part in body.split(boundary_bytes):
|
57
|
+
part = part.removeprefix(newline).removesuffix(newline)
|
47
58
|
|
48
|
-
if
|
59
|
+
if part in (b'', b'--'):
|
49
60
|
continue
|
50
61
|
|
51
|
-
if match :=
|
52
|
-
|
62
|
+
if match := field_pattern.match(string=part):
|
63
|
+
field_name, value = match.groups()
|
53
64
|
data[field_name.decode('utf-8')] = value.decode('utf-8')
|
65
|
+
continue
|
54
66
|
|
67
|
+
try:
|
68
|
+
headers, file_content = part.split(2 * newline, 1)
|
69
|
+
except ValueError:
|
70
|
+
logger.error('Malformed part, skipping.')
|
71
|
+
continue
|
72
|
+
|
73
|
+
if match := file_pattern.match(string=headers):
|
74
|
+
field_name, file_name, content_type = match.groups()
|
75
|
+
data[field_name.decode('utf-8')] = File(
|
76
|
+
file_name=file_name.decode('utf-8'),
|
77
|
+
content_type=content_type.decode('utf-8'),
|
78
|
+
file=file_content,
|
79
|
+
)
|
55
80
|
else:
|
56
|
-
|
57
|
-
|
58
|
-
if match := re.match(pattern=file_pattern, string=file_meta_data):
|
59
|
-
_, field_name, _, file_name, _, content_type = match.groups()
|
60
|
-
file = File(
|
61
|
-
file_name=file_name.decode('utf-8'),
|
62
|
-
content_type=content_type.decode('utf-8'),
|
63
|
-
file=value,
|
64
|
-
)
|
65
|
-
data[field_name.decode('utf-8')] = file
|
66
|
-
else:
|
67
|
-
logger.error('Unrecognized Pattern')
|
81
|
+
logger.error('Unrecognized multipart format')
|
68
82
|
|
69
83
|
return data
|
70
84
|
|
@@ -94,7 +108,8 @@ def check_function_type_endpoint(endpoint: types.FunctionType) -> Callable:
|
|
94
108
|
# Function Doesn't Have @API Decorator
|
95
109
|
if not hasattr(endpoint, '__wrapped__'):
|
96
110
|
raise PantherError(
|
97
|
-
f'You may have forgotten to use `@API()` on the `{endpoint.__module__}.{endpoint.__name__}()`'
|
111
|
+
f'You may have forgotten to use `@API()` on the `{endpoint.__module__}.{endpoint.__name__}()`',
|
112
|
+
)
|
98
113
|
|
99
114
|
|
100
115
|
def check_class_type_endpoint(endpoint: Callable) -> Callable:
|
@@ -104,7 +119,7 @@ def check_class_type_endpoint(endpoint: Callable) -> Callable:
|
|
104
119
|
if not issubclass(endpoint, (GenericAPI, GenericWebsocket)):
|
105
120
|
raise PantherError(
|
106
121
|
f'You may have forgotten to inherit from `panther.app.GenericAPI` or `panther.app.GenericWebsocket` '
|
107
|
-
f'on the `{endpoint.__module__}.{endpoint.__name__}()`'
|
122
|
+
f'on the `{endpoint.__module__}.{endpoint.__name__}()`',
|
108
123
|
)
|
109
124
|
|
110
125
|
|
panther/app.py
CHANGED
@@ -2,36 +2,35 @@ import functools
|
|
2
2
|
import logging
|
3
3
|
import traceback
|
4
4
|
import typing
|
5
|
+
from collections.abc import Callable
|
5
6
|
from datetime import timedelta
|
6
|
-
from typing import Literal
|
7
|
+
from typing import Literal
|
7
8
|
|
8
9
|
from orjson import JSONDecodeError
|
9
|
-
from pydantic import
|
10
|
+
from pydantic import BaseModel, ValidationError
|
10
11
|
|
11
12
|
from panther._utils import is_function_async
|
13
|
+
from panther.base_request import BaseRequest
|
12
14
|
from panther.caching import (
|
13
15
|
get_response_from_cache,
|
14
16
|
set_response_in_cache,
|
15
|
-
get_throttling_from_cache,
|
16
|
-
increment_throttling_in_cache
|
17
17
|
)
|
18
18
|
from panther.configs import config
|
19
19
|
from panther.exceptions import (
|
20
20
|
APIError,
|
21
21
|
AuthorizationAPIError,
|
22
|
+
BadRequestAPIError,
|
22
23
|
JSONDecodeAPIError,
|
23
24
|
MethodNotAllowedAPIError,
|
24
|
-
|
25
|
-
BadRequestAPIError
|
25
|
+
PantherError,
|
26
26
|
)
|
27
|
-
from panther.exceptions import PantherError
|
28
27
|
from panther.middlewares import HTTPMiddleware
|
29
28
|
from panther.openapi import OutputSchema
|
30
29
|
from panther.permissions import BasePermission
|
31
30
|
from panther.request import Request
|
32
31
|
from panther.response import Response
|
33
32
|
from panther.serializer import ModelSerializer
|
34
|
-
from panther.throttling import
|
33
|
+
from panther.throttling import Throttle
|
35
34
|
|
36
35
|
__all__ = ('API', 'GenericAPI')
|
37
36
|
|
@@ -40,6 +39,7 @@ logger = logging.getLogger('panther')
|
|
40
39
|
|
41
40
|
class API:
|
42
41
|
"""
|
42
|
+
methods: Specify the allowed methods.
|
43
43
|
input_model: The `request.data` will be validated with this attribute, It will raise an
|
44
44
|
`panther.exceptions.BadRequestAPIError` or put the validated data in the `request.validated_data`.
|
45
45
|
output_schema: This attribute only used in creation of OpenAPI scheme which is available in `panther.openapi.urls`
|
@@ -47,12 +47,11 @@ class API:
|
|
47
47
|
auth: It will authenticate the user with header of its request or raise an
|
48
48
|
`panther.exceptions.AuthenticationAPIError`.
|
49
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-
|
51
|
-
cache:
|
52
|
-
cache_exp_time: Specify the expiry time of the cache. (default is `config.DEFAULT_CACHE_EXP`)
|
53
|
-
methods: Specify the allowed methods.
|
50
|
+
throttling: It will limit the users' request on a specific (time-window, path)
|
51
|
+
cache: Specify the duration of the cache (Will be used only in GET requests).
|
54
52
|
middlewares: These middlewares have inner priority than global middlewares.
|
55
53
|
"""
|
54
|
+
|
56
55
|
func: Callable
|
57
56
|
|
58
57
|
def __init__(
|
@@ -60,36 +59,68 @@ class API:
|
|
60
59
|
*,
|
61
60
|
methods: list[Literal['GET', 'POST', 'PUT', 'PATCH', 'DELETE']] | None = None,
|
62
61
|
input_model: type[ModelSerializer] | type[BaseModel] | None = None,
|
63
|
-
output_model: type[BaseModel] | None = None,
|
64
62
|
output_schema: OutputSchema | None = None,
|
65
63
|
auth: bool = False,
|
66
|
-
permissions: list[BasePermission] | None = None,
|
67
|
-
throttling:
|
68
|
-
cache:
|
69
|
-
|
70
|
-
|
64
|
+
permissions: list[type[BasePermission]] | None = None,
|
65
|
+
throttling: Throttle | None = None,
|
66
|
+
cache: timedelta | None = None,
|
67
|
+
middlewares: list[type[HTTPMiddleware]] | None = None,
|
68
|
+
**kwargs,
|
71
69
|
):
|
72
|
-
self.methods = {m.upper() for m in methods} if methods else
|
70
|
+
self.methods = {m.upper() for m in methods} if methods else {'GET', 'POST', 'PUT', 'PATCH', 'DELETE'}
|
73
71
|
self.input_model = input_model
|
74
72
|
self.output_schema = output_schema
|
75
73
|
self.auth = auth
|
76
74
|
self.permissions = permissions or []
|
77
75
|
self.throttling = throttling
|
78
76
|
self.cache = cache
|
79
|
-
self.
|
80
|
-
self.middlewares: list[HTTPMiddleware] | None = middlewares
|
77
|
+
self.middlewares: list[[HTTPMiddleware]] | None = middlewares
|
81
78
|
self.request: Request | None = None
|
82
|
-
if output_model:
|
79
|
+
if kwargs.pop('output_model', None):
|
80
|
+
deprecation_message = (
|
81
|
+
traceback.format_stack(limit=2)[0]
|
82
|
+
+ '\nThe `output_model` argument has been removed in Panther v5 and is no longer available.'
|
83
|
+
'\nPlease update your code to use the new approach. More info: '
|
84
|
+
'https://pantherpy.github.io/open_api/'
|
85
|
+
)
|
86
|
+
raise PantherError(deprecation_message)
|
87
|
+
if kwargs.pop('cache_exp_time', None):
|
83
88
|
deprecation_message = (
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
'https://pantherpy.github.io/open_api/'
|
89
|
+
traceback.format_stack(limit=2)[0]
|
90
|
+
+ '\nThe `cache_exp_time` argument has been removed in Panther v5 and is no longer available.'
|
91
|
+
'\nYou may want to use `cache` instead.'
|
88
92
|
)
|
89
93
|
raise PantherError(deprecation_message)
|
94
|
+
# Validate Cache
|
95
|
+
if self.cache and not isinstance(self.cache, timedelta):
|
96
|
+
deprecation_message = (
|
97
|
+
traceback.format_stack(limit=2)[0] + '\nThe `cache` argument has been changed in Panther v5, '
|
98
|
+
'it should be an instance of `datetime.timedelta()`.'
|
99
|
+
)
|
100
|
+
raise PantherError(deprecation_message)
|
101
|
+
assert self.cache is None or isinstance(self.cache, timedelta)
|
102
|
+
# Validate Permissions
|
103
|
+
for perm in self.permissions:
|
104
|
+
if is_function_async(perm.authorization) is False:
|
105
|
+
msg = f'{perm.__name__}.authorization() should be `async`'
|
106
|
+
logger.error(msg)
|
107
|
+
raise PantherError(msg)
|
108
|
+
if type(perm.authorization).__name__ != 'method':
|
109
|
+
msg = f'{perm.__name__}.authorization() should be `@classmethod`'
|
110
|
+
logger.error(msg)
|
111
|
+
raise PantherError(msg)
|
112
|
+
# Check kwargs
|
113
|
+
if kwargs:
|
114
|
+
msg = f'Unknown kwargs: {kwargs.keys()}'
|
115
|
+
logger.error(msg)
|
116
|
+
raise PantherError(msg)
|
90
117
|
|
91
118
|
def __call__(self, func):
|
92
119
|
self.func = func
|
120
|
+
self.is_function_async = is_function_async(self.func)
|
121
|
+
self.function_annotations = {
|
122
|
+
k: v for k, v in func.__annotations__.items() if v in {BaseRequest, Request, bool, int}
|
123
|
+
}
|
93
124
|
|
94
125
|
@functools.wraps(func)
|
95
126
|
async def wrapper(request: Request) -> Response:
|
@@ -110,37 +141,40 @@ class API:
|
|
110
141
|
async def handle_endpoint(self, request: Request) -> Response:
|
111
142
|
self.request = request
|
112
143
|
|
113
|
-
# 0. Preflight
|
114
|
-
if self.request.method == 'OPTIONS':
|
115
|
-
return self.options()
|
116
|
-
|
117
144
|
# 1. Check Method
|
118
|
-
if self.
|
145
|
+
if self.request.method not in self.methods:
|
119
146
|
raise MethodNotAllowedAPIError
|
120
147
|
|
121
148
|
# 2. Authentication
|
122
|
-
|
149
|
+
if self.auth:
|
150
|
+
if not config.AUTHENTICATION:
|
151
|
+
logger.critical('"AUTHENTICATION" has not been set in configs')
|
152
|
+
raise APIError
|
153
|
+
self.request.user = await config.AUTHENTICATION.authentication(self.request)
|
123
154
|
|
124
155
|
# 3. Permissions
|
125
|
-
|
156
|
+
for perm in self.permissions:
|
157
|
+
if await perm.authorization(self.request) is False:
|
158
|
+
raise AuthorizationAPIError
|
126
159
|
|
127
|
-
# 4.
|
128
|
-
|
160
|
+
# 4. Throttle
|
161
|
+
if throttling := self.throttling or config.THROTTLING:
|
162
|
+
await throttling.check_and_increment(request=self.request)
|
129
163
|
|
130
164
|
# 5. Validate Input
|
131
|
-
if self.request.method in {'POST', 'PUT', 'PATCH'}:
|
132
|
-
self.
|
165
|
+
if self.input_model and self.request.method in {'POST', 'PUT', 'PATCH'}:
|
166
|
+
self.request.validated_data = self.validate_input(model=self.input_model, request=self.request)
|
133
167
|
|
134
168
|
# 6. Get Cached Response
|
135
169
|
if self.cache and self.request.method == 'GET':
|
136
|
-
if cached := await get_response_from_cache(request=self.request,
|
170
|
+
if cached := await get_response_from_cache(request=self.request, duration=self.cache):
|
137
171
|
return Response(data=cached.data, headers=cached.headers, status_code=cached.status_code)
|
138
172
|
|
139
173
|
# 7. Put PathVariables and Request(If User Wants It) In kwargs
|
140
|
-
kwargs = self.request.clean_parameters(self.
|
174
|
+
kwargs = self.request.clean_parameters(self.function_annotations)
|
141
175
|
|
142
176
|
# 8. Call Endpoint
|
143
|
-
if
|
177
|
+
if self.is_function_async:
|
144
178
|
response = await self.func(**kwargs)
|
145
179
|
else:
|
146
180
|
response = self.func(**kwargs)
|
@@ -153,48 +187,10 @@ class API:
|
|
153
187
|
|
154
188
|
# 10. Set New Response To Cache
|
155
189
|
if self.cache and self.request.method == 'GET':
|
156
|
-
await set_response_in_cache(request=self.request, response=response,
|
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')
|
190
|
+
await set_response_in_cache(request=self.request, response=response, duration=self.cache)
|
161
191
|
|
162
192
|
return response
|
163
193
|
|
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
194
|
@classmethod
|
199
195
|
def validate_input(cls, model, request: Request):
|
200
196
|
if isinstance(request.data, bytes):
|
@@ -212,21 +208,15 @@ class API:
|
|
212
208
|
|
213
209
|
|
214
210
|
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
|
-
):
|
211
|
+
def __new__(cls, cls_name: str, bases: tuple[type[typing.Any], ...], namespace: dict[str, typing.Any], **kwargs):
|
222
212
|
if cls_name == 'GenericAPI':
|
223
213
|
return super().__new__(cls, cls_name, bases, namespace)
|
224
214
|
if 'output_model' in namespace:
|
225
215
|
deprecation_message = (
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
216
|
+
traceback.format_stack(limit=2)[0]
|
217
|
+
+ '\nThe `output_model` argument has been removed in Panther v5 and is no longer available.'
|
218
|
+
'\nPlease update your code to use the new approach. More info: '
|
219
|
+
'https://pantherpy.github.io/open_api/'
|
230
220
|
)
|
231
221
|
raise PantherError(deprecation_message)
|
232
222
|
return super().__new__(cls, cls_name, bases, namespace)
|
@@ -236,15 +226,27 @@ class GenericAPI(metaclass=MetaGenericAPI):
|
|
236
226
|
"""
|
237
227
|
Check out the documentation of `panther.app.API()`.
|
238
228
|
"""
|
229
|
+
|
239
230
|
input_model: type[ModelSerializer] | type[BaseModel] | None = None
|
240
231
|
output_schema: OutputSchema | None = None
|
241
232
|
auth: bool = False
|
242
|
-
permissions: list | None = None
|
243
|
-
throttling:
|
244
|
-
cache:
|
245
|
-
cache_exp_time: timedelta | int | None = None
|
233
|
+
permissions: list[type[BasePermission]] | None = None
|
234
|
+
throttling: Throttle | None = None
|
235
|
+
cache: timedelta | None = None
|
246
236
|
middlewares: list[HTTPMiddleware] | None = None
|
247
237
|
|
238
|
+
def __init_subclass__(cls, **kwargs):
|
239
|
+
# Creating API instance to validate the attributes.
|
240
|
+
API(
|
241
|
+
input_model=cls.input_model,
|
242
|
+
output_schema=cls.output_schema,
|
243
|
+
auth=cls.auth,
|
244
|
+
permissions=cls.permissions,
|
245
|
+
throttling=cls.throttling,
|
246
|
+
cache=cls.cache,
|
247
|
+
middlewares=cls.middlewares,
|
248
|
+
)
|
249
|
+
|
248
250
|
async def get(self, *args, **kwargs):
|
249
251
|
raise MethodNotAllowedAPIError
|
250
252
|
|
@@ -272,8 +274,6 @@ class GenericAPI(metaclass=MetaGenericAPI):
|
|
272
274
|
func = self.patch
|
273
275
|
case 'DELETE':
|
274
276
|
func = self.delete
|
275
|
-
case 'OPTIONS':
|
276
|
-
func = API.options
|
277
277
|
case _:
|
278
278
|
raise MethodNotAllowedAPIError
|
279
279
|
|
@@ -284,6 +284,5 @@ class GenericAPI(metaclass=MetaGenericAPI):
|
|
284
284
|
permissions=self.permissions,
|
285
285
|
throttling=self.throttling,
|
286
286
|
cache=self.cache,
|
287
|
-
cache_exp_time=self.cache_exp_time,
|
288
287
|
middlewares=self.middlewares,
|
289
288
|
)(func)(request=request)
|