panther 4.3.7__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.
Files changed (111) hide show
  1. {panther-4.3.7 → panther-5.0.0b1}/PKG-INFO +19 -17
  2. {panther-4.3.7 → panther-5.0.0b1}/panther/__init__.py +1 -1
  3. {panther-4.3.7 → panther-5.0.0b1}/panther/_load_configs.py +78 -64
  4. {panther-4.3.7 → panther-5.0.0b1}/panther/_utils.py +1 -1
  5. panther-5.0.0b1/panther/app.py +289 -0
  6. {panther-4.3.7 → panther-5.0.0b1}/panther/authentications.py +26 -9
  7. {panther-4.3.7 → panther-5.0.0b1}/panther/base_request.py +27 -2
  8. {panther-4.3.7 → panther-5.0.0b1}/panther/base_websocket.py +26 -27
  9. {panther-4.3.7 → panther-5.0.0b1}/panther/cli/create_command.py +1 -0
  10. panther-5.0.0b1/panther/cli/main.py +52 -0
  11. {panther-4.3.7 → panther-5.0.0b1}/panther/cli/monitor_command.py +8 -4
  12. {panther-4.3.7 → panther-5.0.0b1}/panther/cli/template.py +11 -6
  13. {panther-4.3.7 → panther-5.0.0b1}/panther/cli/utils.py +3 -2
  14. {panther-4.3.7 → panther-5.0.0b1}/panther/configs.py +7 -9
  15. {panther-4.3.7 → panther-5.0.0b1}/panther/db/cursor.py +23 -7
  16. {panther-4.3.7 → panther-5.0.0b1}/panther/db/models.py +26 -19
  17. {panther-4.3.7 → panther-5.0.0b1}/panther/db/queries/base_queries.py +1 -1
  18. panther-5.0.0b1/panther/db/queries/mongodb_queries.py +307 -0
  19. {panther-4.3.7 → panther-5.0.0b1}/panther/db/queries/pantherdb_queries.py +5 -5
  20. {panther-4.3.7 → panther-5.0.0b1}/panther/db/queries/queries.py +1 -1
  21. {panther-4.3.7 → panther-5.0.0b1}/panther/events.py +10 -4
  22. {panther-4.3.7 → panther-5.0.0b1}/panther/exceptions.py +24 -2
  23. {panther-4.3.7 → panther-5.0.0b1}/panther/generics.py +2 -2
  24. panther-5.0.0b1/panther/main.py +190 -0
  25. panther-5.0.0b1/panther/middlewares/__init__.py +1 -0
  26. panther-5.0.0b1/panther/middlewares/base.py +26 -0
  27. panther-5.0.0b1/panther/middlewares/monitoring.py +42 -0
  28. panther-5.0.0b1/panther/openapi/__init__.py +1 -0
  29. panther-5.0.0b1/panther/openapi/templates/openapi.html +27 -0
  30. panther-5.0.0b1/panther/openapi/urls.py +5 -0
  31. panther-5.0.0b1/panther/openapi/utils.py +167 -0
  32. panther-5.0.0b1/panther/openapi/views.py +101 -0
  33. {panther-4.3.7 → panther-5.0.0b1}/panther/pagination.py +1 -1
  34. panther-5.0.0b1/panther/panel/middlewares.py +10 -0
  35. panther-5.0.0b1/panther/panel/templates/base.html +14 -0
  36. panther-5.0.0b1/panther/panel/templates/create.html +21 -0
  37. panther-5.0.0b1/panther/panel/templates/create.js +1270 -0
  38. panther-5.0.0b1/panther/panel/templates/detail.html +55 -0
  39. panther-5.0.0b1/panther/panel/templates/home.html +9 -0
  40. panther-5.0.0b1/panther/panel/templates/home.js +30 -0
  41. panther-5.0.0b1/panther/panel/templates/login.html +47 -0
  42. panther-5.0.0b1/panther/panel/templates/sidebar.html +13 -0
  43. panther-5.0.0b1/panther/panel/templates/table.html +73 -0
  44. panther-5.0.0b1/panther/panel/templates/table.js +339 -0
  45. panther-5.0.0b1/panther/panel/urls.py +13 -0
  46. panther-5.0.0b1/panther/panel/utils.py +115 -0
  47. panther-5.0.0b1/panther/panel/views.py +143 -0
  48. {panther-4.3.7 → panther-5.0.0b1}/panther/request.py +3 -0
  49. {panther-4.3.7 → panther-5.0.0b1}/panther/response.py +91 -53
  50. {panther-4.3.7 → panther-5.0.0b1}/panther/routings.py +7 -2
  51. {panther-4.3.7 → panther-5.0.0b1}/panther/serializer.py +1 -1
  52. {panther-4.3.7 → panther-5.0.0b1}/panther/utils.py +34 -26
  53. {panther-4.3.7 → panther-5.0.0b1}/panther/websocket.py +3 -0
  54. {panther-4.3.7 → panther-5.0.0b1}/panther.egg-info/PKG-INFO +19 -17
  55. {panther-4.3.7 → panther-5.0.0b1}/panther.egg-info/SOURCES.txt +21 -0
  56. panther-5.0.0b1/panther.egg-info/requires.txt +23 -0
  57. {panther-4.3.7 → panther-5.0.0b1}/setup.py +19 -17
  58. {panther-4.3.7 → panther-5.0.0b1}/tests/test_authentication.py +8 -8
  59. {panther-4.3.7 → panther-5.0.0b1}/tests/test_database.py +10 -6
  60. panther-5.0.0b1/tests/test_database_advance.py +336 -0
  61. panther-5.0.0b1/tests/test_middlewares.py +275 -0
  62. panther-5.0.0b1/tests/test_openapi.py +275 -0
  63. {panther-4.3.7 → panther-5.0.0b1}/tests/test_panel_apis.py +5 -4
  64. {panther-4.3.7 → panther-5.0.0b1}/tests/test_request.py +2 -2
  65. {panther-4.3.7 → panther-5.0.0b1}/tests/test_response.py +1 -1
  66. {panther-4.3.7 → panther-5.0.0b1}/tests/test_routing.py +118 -11
  67. {panther-4.3.7 → panther-5.0.0b1}/tests/test_run.py +3 -3
  68. {panther-4.3.7 → panther-5.0.0b1}/tests/test_utils.py +16 -13
  69. {panther-4.3.7 → panther-5.0.0b1}/tests/test_websockets.py +2 -2
  70. panther-4.3.7/panther/app.py +0 -223
  71. panther-4.3.7/panther/cli/main.py +0 -60
  72. panther-4.3.7/panther/db/queries/mongodb_queries.py +0 -145
  73. panther-4.3.7/panther/main.py +0 -227
  74. panther-4.3.7/panther/middlewares/__init__.py +0 -1
  75. panther-4.3.7/panther/middlewares/base.py +0 -30
  76. panther-4.3.7/panther/panel/urls.py +0 -8
  77. panther-4.3.7/panther/panel/utils.py +0 -17
  78. panther-4.3.7/panther.egg-info/requires.txt +0 -21
  79. {panther-4.3.7 → panther-5.0.0b1}/LICENSE +0 -0
  80. {panther-4.3.7 → panther-5.0.0b1}/README.md +0 -0
  81. {panther-4.3.7 → panther-5.0.0b1}/panther/background_tasks.py +0 -0
  82. {panther-4.3.7 → panther-5.0.0b1}/panther/caching.py +0 -0
  83. {panther-4.3.7 → panther-5.0.0b1}/panther/cli/__init__.py +0 -0
  84. {panther-4.3.7 → panther-5.0.0b1}/panther/cli/run_command.py +0 -0
  85. {panther-4.3.7 → panther-5.0.0b1}/panther/db/__init__.py +0 -0
  86. {panther-4.3.7 → panther-5.0.0b1}/panther/db/connections.py +0 -0
  87. {panther-4.3.7 → panther-5.0.0b1}/panther/db/queries/__init__.py +0 -0
  88. {panther-4.3.7 → panther-5.0.0b1}/panther/db/utils.py +0 -0
  89. {panther-4.3.7 → panther-5.0.0b1}/panther/file_handler.py +0 -0
  90. {panther-4.3.7 → panther-5.0.0b1}/panther/logging.py +0 -0
  91. {panther-4.3.7 → panther-5.0.0b1}/panther/monitoring.py +0 -0
  92. {panther-4.3.7 → panther-5.0.0b1}/panther/panel/__init__.py +0 -0
  93. {panther-4.3.7 → panther-5.0.0b1}/panther/panel/apis.py +0 -0
  94. {panther-4.3.7 → panther-5.0.0b1}/panther/permissions.py +0 -0
  95. {panther-4.3.7 → panther-5.0.0b1}/panther/status.py +0 -0
  96. {panther-4.3.7 → panther-5.0.0b1}/panther/test.py +0 -0
  97. {panther-4.3.7 → panther-5.0.0b1}/panther/throttling.py +0 -0
  98. {panther-4.3.7 → panther-5.0.0b1}/panther.egg-info/dependency_links.txt +0 -0
  99. {panther-4.3.7 → panther-5.0.0b1}/panther.egg-info/entry_points.txt +0 -0
  100. {panther-4.3.7 → panther-5.0.0b1}/panther.egg-info/top_level.txt +0 -0
  101. {panther-4.3.7 → panther-5.0.0b1}/pyproject.toml +0 -0
  102. {panther-4.3.7 → panther-5.0.0b1}/setup.cfg +0 -0
  103. {panther-4.3.7 → panther-5.0.0b1}/tests/test_background_tasks.py +0 -0
  104. {panther-4.3.7 → panther-5.0.0b1}/tests/test_caching.py +0 -0
  105. {panther-4.3.7 → panther-5.0.0b1}/tests/test_cli.py +0 -0
  106. {panther-4.3.7 → panther-5.0.0b1}/tests/test_events.py +0 -0
  107. {panther-4.3.7 → panther-5.0.0b1}/tests/test_generics.py +0 -0
  108. {panther-4.3.7 → panther-5.0.0b1}/tests/test_multipart.py +0 -0
  109. {panther-4.3.7 → panther-5.0.0b1}/tests/test_serializer.py +0 -0
  110. {panther-4.3.7 → panther-5.0.0b1}/tests/test_status.py +0 -0
  111. {panther-4.3.7 → panther-5.0.0b1}/tests/test_throttling.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: panther
3
- Version: 4.3.7
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,27 @@ 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.1.1
18
- Requires-Dist: pydantic~=2.8.2
19
- Requires-Dist: rich~=13.7.1
20
- Requires-Dist: uvicorn~=0.27.1
21
- Requires-Dist: pytz~=2024.1
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: httptools~=0.6.1
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.0.1; extra == "full"
26
- Requires-Dist: motor~=3.5.0; extra == "full"
27
- Requires-Dist: bpython~=0.24; extra == "full"
28
- Requires-Dist: python-jose~=3.3.0; extra == "full"
29
- Requires-Dist: ruff~=0.1.9; extra == "full"
30
- Requires-Dist: websockets~=12.0; extra == "full"
31
- Requires-Dist: cryptography~=42.0.8; extra == "full"
32
- Requires-Dist: watchfiles~=0.21.0; extra == "full"
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.1.9; extra == "dev"
35
- Requires-Dist: pytest~=8.3.3; extra == "dev"
36
+ Requires-Dist: ruff~=0.11.2; extra == "dev"
37
+ Requires-Dist: pytest~=8.3.5; extra == "dev"
36
38
  Dynamic: author
37
39
  Dynamic: author-email
38
40
  Dynamic: classifier
@@ -1,6 +1,6 @@
1
1
  from panther.main import Panther # noqa: F401
2
2
 
3
- __version__ = '4.3.7'
3
+ __version__ = '5.0.0beta1'
4
4
 
5
5
 
6
6
  def version():
@@ -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.panel.urls import urls as panel_urls
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(loader=jinja2.FileSystemLoader(config.TEMPLATES_DIR))
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
- if not isinstance(middleware, list | tuple):
157
- path_or_type = middleware
158
- data = {}
159
-
160
- elif len(middleware) == 1:
161
- path_or_type = middleware[0]
162
- data = {}
163
-
164
- elif len(middleware) > 2:
165
- raise _exception_handler(field='MIDDLEWARES', error=f'{middleware} too many arguments')
166
-
167
- else:
168
- path_or_type, data = middleware
169
-
170
- if callable(path_or_type):
171
- middleware_class = path_or_type
172
- else:
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
- middleware_class = import_class(path_or_type)
175
+ middleware = import_class(middleware)
175
176
  except (AttributeError, ModuleNotFoundError):
176
- raise _exception_handler(field='MIDDLEWARES', error=f'{path_or_type} is not a valid middleware path')
177
+ raise _exception_handler(
178
+ field='MIDDLEWARES', error=f'{middleware} is not a valid middleware path or type')
177
179
 
178
- if issubclass(middleware_class, BaseMiddleware) is False:
179
- raise _exception_handler(field='MIDDLEWARES', error='is not a sub class of BaseMiddleware')
180
+ if issubclass(middleware, (MonitoringMiddleware, WebsocketMonitoringMiddleware)):
181
+ config.MONITORING = True
180
182
 
181
- if middleware_class.__bases__[0] in (BaseMiddleware, HTTPMiddleware):
182
- middlewares['http'].append((middleware_class, data))
183
-
184
- if middleware_class.__bases__[0] in (BaseMiddleware, WebsocketMiddleware):
185
- middlewares['ws'].append((middleware_class, data))
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
- config.URLS['_panel'] = finalize_urls(flatten_urls(panel_urls))
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
 
@@ -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
- @staticmethod
33
- def exception(message: str, /) -> type[AuthenticationAPIError]:
34
- logger.error(f'Authentication Error: "{message}"')
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 = 'Authorization is required'
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
@@ -1,4 +1,3 @@
1
- from collections import namedtuple
2
1
  from collections.abc import Callable
3
2
  from urllib.parse import parse_qsl
4
3
 
@@ -46,14 +45,40 @@ class Headers:
46
45
  items = ', '.join(f'{k}={v}' for k, v in self.__headers.items())
47
46
  return f'Headers({items})'
48
47
 
48
+ def __contains__(self, item):
49
+ return (item in self.__headers) or (item in self.__pythonic_headers)
50
+
49
51
  __repr__ = __str__
50
52
 
51
53
  @property
52
54
  def __dict__(self):
53
55
  return self.__headers
54
56
 
57
+ def get_cookies(self) -> dict:
58
+ """
59
+ request.headers.cookie:
60
+ 'csrftoken=aaa; sessionid=bbb; access_token=ccc; refresh_token=ddd'
61
+
62
+ request.headers.get_cookies():
63
+ {
64
+ 'csrftoken': 'aaa',
65
+ 'sessionid': 'bbb',
66
+ 'access_token': 'ccc',
67
+ 'refresh_token': 'ddd',
68
+ }
69
+ """
70
+ if self.cookie:
71
+ return {k.strip(): v for k, v in (c.split('=', maxsplit=1) for c in self.cookie.split(';'))}
72
+ return {}
73
+
74
+
75
+ class Address:
76
+ def __init__(self, ip, port):
77
+ self.ip = ip
78
+ self.port = port
55
79
 
56
- Address = namedtuple('Address', ['ip', 'port'])
80
+ def __str__(self):
81
+ return f'{self.ip}:{self.port}'
57
82
 
58
83
 
59
84
  class BaseRequest: