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.
Files changed (56) hide show
  1. panther/__init__.py +1 -1
  2. panther/_load_configs.py +46 -37
  3. panther/_utils.py +49 -34
  4. panther/app.py +96 -97
  5. panther/authentications.py +97 -50
  6. panther/background_tasks.py +98 -124
  7. panther/base_request.py +16 -10
  8. panther/base_websocket.py +8 -8
  9. panther/caching.py +16 -80
  10. panther/cli/create_command.py +17 -16
  11. panther/cli/main.py +1 -1
  12. panther/cli/monitor_command.py +11 -6
  13. panther/cli/run_command.py +5 -71
  14. panther/cli/template.py +7 -7
  15. panther/cli/utils.py +58 -69
  16. panther/configs.py +70 -72
  17. panther/db/connections.py +18 -24
  18. panther/db/cursor.py +0 -1
  19. panther/db/models.py +24 -8
  20. panther/db/queries/base_queries.py +2 -5
  21. panther/db/queries/mongodb_queries.py +17 -20
  22. panther/db/queries/pantherdb_queries.py +1 -1
  23. panther/db/queries/queries.py +26 -8
  24. panther/db/utils.py +1 -1
  25. panther/events.py +25 -14
  26. panther/exceptions.py +2 -7
  27. panther/file_handler.py +1 -1
  28. panther/generics.py +11 -8
  29. panther/logging.py +2 -1
  30. panther/main.py +12 -13
  31. panther/middlewares/cors.py +67 -0
  32. panther/middlewares/monitoring.py +5 -3
  33. panther/openapi/urls.py +2 -2
  34. panther/openapi/utils.py +3 -3
  35. panther/openapi/views.py +20 -37
  36. panther/pagination.py +4 -2
  37. panther/panel/apis.py +2 -7
  38. panther/panel/urls.py +2 -6
  39. panther/panel/utils.py +9 -5
  40. panther/panel/views.py +17 -23
  41. panther/permissions.py +2 -1
  42. panther/request.py +2 -1
  43. panther/response.py +53 -47
  44. panther/routings.py +12 -12
  45. panther/serializer.py +19 -20
  46. panther/test.py +73 -58
  47. panther/throttling.py +68 -3
  48. panther/utils.py +5 -11
  49. {panther-5.0.0b2.dist-info → panther-5.0.0b4.dist-info}/METADATA +1 -1
  50. panther-5.0.0b4.dist-info/RECORD +75 -0
  51. panther/monitoring.py +0 -34
  52. panther-5.0.0b2.dist-info/RECORD +0 -75
  53. {panther-5.0.0b2.dist-info → panther-5.0.0b4.dist-info}/WHEEL +0 -0
  54. {panther-5.0.0b2.dist-info → panther-5.0.0b4.dist-info}/entry_points.txt +0 -0
  55. {panther-5.0.0b2.dist-info → panther-5.0.0b4.dist-info}/licenses/LICENSE +0 -0
  56. {panther-5.0.0b2.dist-info → panther-5.0.0b4.dist-info}/top_level.txt +0 -0
panther/__init__.py CHANGED
@@ -1,6 +1,6 @@
1
1
  from panther.main import Panther # noqa: F401
2
2
 
3
- __version__ = '5.0.0beta2'
3
+ __version__ = '5.0.0beta4'
4
4
 
5
5
 
6
6
  def version():
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 import_class, check_function_type_endpoint, check_class_type_endpoint
10
- from panther.background_tasks import background_tasks
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 WebsocketMiddleware, HTTPMiddleware
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
- 'load_auto_reformat',
37
- 'load_background_tasks',
38
- 'load_default_cache_exp',
38
+ 'load_throttling',
39
+ 'load_timezone',
39
40
  'load_urls',
40
- 'load_authentication_class',
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=f'`engine["class"]` not found.')
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.encode()
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.MODELS.append(config.USER_MODEL)
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
- 'to pass them to your middleware with config variables'
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', error=f'{middleware} should be dotted path or type of a middleware class')
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', error=f'{middleware} is not a valid middleware path or type')
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
- background_tasks.initialize()
212
+ _background_tasks.initialize()
206
213
 
207
214
 
208
- def load_default_cache_exp(_configs: dict, /) -> None:
209
- if default_cache_exp := _configs.get('DEFAULT_CACHE_EXP'):
210
- config.DEFAULT_CACHE_EXP = default_cache_exp
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(). " 'Example: Panther(..., urls=...)'
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
- 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', {})
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.decode()
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 _, endpoint in config.FLAT_URLS.items():
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, Generator, Iterator, AsyncGenerator
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
- boundary = b'--' + boundary.encode()
31
- new_line = b'\r\n' if body[-2:] == b'\r\n' else b'\n'
32
-
33
- field_pattern = (
34
- rb'(Content-Disposition: form-data; name=")(.*)("'
35
- + 2 * new_line
36
- + b')(.*)'
37
- )
38
- file_pattern = (
39
- rb'(Content-Disposition: form-data; name=")(.*)("; filename=")(.*)("'
40
- + new_line
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 _row in body.split(boundary):
46
- row = _row.removeprefix(new_line).removesuffix(new_line)
56
+ for part in body.split(boundary_bytes):
57
+ part = part.removeprefix(newline).removesuffix(newline)
47
58
 
48
- if row in (b'', b'--'):
59
+ if part in (b'', b'--'):
49
60
  continue
50
61
 
51
- if match := re.match(pattern=field_pattern, string=row, flags=re.DOTALL):
52
- _, field_name, _, value = match.groups()
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
- file_meta_data, value = row.split(2 * new_line, 1)
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, Callable
7
+ from typing import Literal
7
8
 
8
9
  from orjson import JSONDecodeError
9
- from pydantic import ValidationError, BaseModel
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
- ThrottlingAPIError,
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 Throttling
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-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.
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: Throttling | None = None,
68
- cache: bool = False,
69
- cache_exp_time: timedelta | int | None = None,
70
- middlewares: list[HTTPMiddleware] | None = None,
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 None
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.cache_exp_time = cache_exp_time
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
- 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/'
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.methods and self.request.method not in self.methods:
145
+ if self.request.method not in self.methods:
119
146
  raise MethodNotAllowedAPIError
120
147
 
121
148
  # 2. Authentication
122
- await self.handle_authentication()
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
- await self.handle_permission()
156
+ for perm in self.permissions:
157
+ if await perm.authorization(self.request) is False:
158
+ raise AuthorizationAPIError
126
159
 
127
- # 4. Throttling
128
- await self.handle_throttling()
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.handle_input_validation()
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, cache_exp_time=self.cache_exp_time):
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.func)
174
+ kwargs = self.request.clean_parameters(self.function_annotations)
141
175
 
142
176
  # 8. Call Endpoint
143
- if is_function_async(self.func):
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, 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')
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
- 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/'
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: Throttling | None = None
244
- cache: bool = False
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)