panther 4.3.7__py3-none-any.whl → 5.0.0b1__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 (59) hide show
  1. panther/__init__.py +1 -1
  2. panther/_load_configs.py +78 -64
  3. panther/_utils.py +1 -1
  4. panther/app.py +126 -60
  5. panther/authentications.py +26 -9
  6. panther/base_request.py +27 -2
  7. panther/base_websocket.py +26 -27
  8. panther/cli/create_command.py +1 -0
  9. panther/cli/main.py +19 -27
  10. panther/cli/monitor_command.py +8 -4
  11. panther/cli/template.py +11 -6
  12. panther/cli/utils.py +3 -2
  13. panther/configs.py +7 -9
  14. panther/db/cursor.py +23 -7
  15. panther/db/models.py +26 -19
  16. panther/db/queries/base_queries.py +1 -1
  17. panther/db/queries/mongodb_queries.py +172 -10
  18. panther/db/queries/pantherdb_queries.py +5 -5
  19. panther/db/queries/queries.py +1 -1
  20. panther/events.py +10 -4
  21. panther/exceptions.py +24 -2
  22. panther/generics.py +2 -2
  23. panther/main.py +80 -117
  24. panther/middlewares/__init__.py +1 -1
  25. panther/middlewares/base.py +15 -19
  26. panther/middlewares/monitoring.py +42 -0
  27. panther/openapi/__init__.py +1 -0
  28. panther/openapi/templates/openapi.html +27 -0
  29. panther/openapi/urls.py +5 -0
  30. panther/openapi/utils.py +167 -0
  31. panther/openapi/views.py +101 -0
  32. panther/pagination.py +1 -1
  33. panther/panel/middlewares.py +10 -0
  34. panther/panel/templates/base.html +14 -0
  35. panther/panel/templates/create.html +21 -0
  36. panther/panel/templates/create.js +1270 -0
  37. panther/panel/templates/detail.html +55 -0
  38. panther/panel/templates/home.html +9 -0
  39. panther/panel/templates/home.js +30 -0
  40. panther/panel/templates/login.html +47 -0
  41. panther/panel/templates/sidebar.html +13 -0
  42. panther/panel/templates/table.html +73 -0
  43. panther/panel/templates/table.js +339 -0
  44. panther/panel/urls.py +10 -5
  45. panther/panel/utils.py +98 -0
  46. panther/panel/views.py +143 -0
  47. panther/request.py +3 -0
  48. panther/response.py +91 -53
  49. panther/routings.py +7 -2
  50. panther/serializer.py +1 -1
  51. panther/utils.py +34 -26
  52. panther/websocket.py +3 -0
  53. {panther-4.3.7.dist-info → panther-5.0.0b1.dist-info}/METADATA +19 -17
  54. panther-5.0.0b1.dist-info/RECORD +75 -0
  55. {panther-4.3.7.dist-info → panther-5.0.0b1.dist-info}/WHEEL +1 -1
  56. panther-4.3.7.dist-info/RECORD +0 -57
  57. {panther-4.3.7.dist-info → panther-5.0.0b1.dist-info}/entry_points.txt +0 -0
  58. {panther-4.3.7.dist-info → panther-5.0.0b1.dist-info}/licenses/LICENSE +0 -0
  59. {panther-4.3.7.dist-info → panther-5.0.0b1.dist-info}/top_level.txt +0 -0
@@ -1,12 +1,19 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import types
4
+ import typing
3
5
  from sys import version_info
4
- from typing import Iterable, Sequence
6
+ from typing import get_args, get_origin, Iterable, Sequence, Any, Union
7
+
8
+ from pydantic import BaseModel, ValidationError
9
+ from pymongo.results import InsertOneResult, InsertManyResult
5
10
 
6
11
  from panther.db.connections import db
7
12
  from panther.db.cursor import Cursor
13
+ from panther.db.models import Model
8
14
  from panther.db.queries.base_queries import BaseQuery
9
15
  from panther.db.utils import prepare_id_for_query
16
+ from panther.exceptions import DatabaseError
10
17
 
11
18
  try:
12
19
  from bson.codec_options import CodecOptions
@@ -24,6 +31,32 @@ else:
24
31
  Self = TypeVar('Self', bound='BaseMongoDBQuery')
25
32
 
26
33
 
34
+ def get_annotation_type(annotation: Any) -> type | None:
35
+ """
36
+ Extracts the underlying, non-optional type from a type annotation.
37
+ Handles basic types, Pydantic BaseModels, lists, and unions (optionals).
38
+ Returns None if no single underlying type can be determined (e.g., for list[NoneType]).
39
+ Raises DatabaseError for unsupported annotations.
40
+ """
41
+ origin = get_origin(annotation)
42
+
43
+ # Handle list[T] and Union[T, None] (T | None or typing.Union[T, None])
44
+ if origin is list or origin is types.UnionType or origin is Union:
45
+ # Extracts the first non-None type from a tuple of type arguments.
46
+ for arg in get_args(annotation):
47
+ if arg is not type(None):
48
+ return arg
49
+ return None
50
+
51
+ # Handle basic types (str, int, bool, dict) and Pydantic BaseModel subclasses
52
+ if isinstance(annotation, type) and (
53
+ annotation in (str, int, bool, dict) or issubclass(annotation, BaseModel)
54
+ ):
55
+ return annotation
56
+
57
+ raise DatabaseError(f'Panther does not support {annotation} as a field type for unwrapping.')
58
+
59
+
27
60
  class BaseMongoDBQuery(BaseQuery):
28
61
  @classmethod
29
62
  def _merge(cls, *args, is_mongo: bool = True) -> dict:
@@ -34,11 +67,126 @@ class BaseMongoDBQuery(BaseQuery):
34
67
  # def collection(cls):
35
68
  # return db.session.get_collection(name=cls.__name__, codec_options=CodecOptions(document_class=cls))
36
69
 
70
+ @classmethod
71
+ async def _create_list(cls, field_type: type, value: Any) -> Any:
72
+ # `field_type` is the expected type of items in the list (e.g., int, Model, list[str])
73
+ # `value` is a single item from the input list that needs processing.
74
+
75
+ # Handles list[list[int]], list[dict[str,int]] etc.
76
+ if isinstance(field_type, (types.GenericAlias, typing._GenericAlias)):
77
+ element_type = get_annotation_type(field_type) # Unwrap further (e.g. list[str] -> str)
78
+ if element_type is None:
79
+ raise DatabaseError(f"Cannot determine element type for generic list item: {field_type}")
80
+ if not isinstance(value, list): # Or check if iterable, matching the structure
81
+ raise DatabaseError(f"Expected a list for nested generic type {field_type}, got {type(value)}")
82
+ return [await cls._create_list(field_type=element_type, value=item) for item in value]
83
+
84
+ # Make sure Model condition is before BaseModel.
85
+ if isinstance(field_type, type) and issubclass(field_type, Model):
86
+ # `value` is assumed to be an ID for the Model instance.
87
+ return await field_type.first(id=value)
88
+
89
+ if isinstance(field_type, type) and issubclass(field_type, BaseModel):
90
+ if not isinstance(value, dict):
91
+ raise DatabaseError(f"Expected a dictionary for BaseModel {field_type.__name__}, got {type(value)}")
92
+
93
+ return {
94
+ field_name: await cls._create_field(model=field_type, field_name=field_name, value=value[field_name])
95
+ for field_name in value
96
+ }
97
+
98
+ # Base case: value is a primitive type (str, int, etc.)
99
+ return value
100
+
101
+ @classmethod
102
+ async def _create_field(cls, model: type, field_name: str, value: Any) -> Any:
103
+ # Handle primary key field directly
104
+ if field_name == '_id':
105
+ return value
106
+
107
+ if field_name not in model.model_fields:
108
+ # Field from input data is not defined in the model.
109
+ # Pydantic's `extra` config on the model will handle this upon instantiation.
110
+ return value
111
+
112
+ field_annotation = model.model_fields[field_name].annotation
113
+ unwrapped_type = get_annotation_type(field_annotation)
114
+
115
+ if unwrapped_type is None:
116
+ raise DatabaseError(
117
+ f"Could not determine a valid underlying type for field '{field_name}' "
118
+ f"with annotation {field_annotation} in model {model.__name__}."
119
+ )
120
+
121
+ if get_origin(field_annotation) is list:
122
+ # Or check for general iterables if applicable
123
+ if not isinstance(value, list):
124
+ raise DatabaseError(
125
+ f"Field '{field_name}' expects a list, got {type(value)} for model {model.__name__}")
126
+ return [await cls._create_list(field_type=unwrapped_type, value=item) for item in value]
127
+
128
+ if isinstance(unwrapped_type, type) and issubclass(unwrapped_type, Model):
129
+ if obj := await unwrapped_type.first(id=value):
130
+ return obj.model_dump(by_alias=True)
131
+ return None
132
+
133
+ if isinstance(unwrapped_type, type) and issubclass(unwrapped_type, BaseModel):
134
+ if not isinstance(value, dict):
135
+ raise DatabaseError(
136
+ f"Field '{field_name}' expects a dictionary for BaseModel {unwrapped_type.__name__}, "
137
+ f"got {type(value)} in model {model.__name__}"
138
+ )
139
+ return {
140
+ nested_field_name: await cls._create_field(
141
+ model=unwrapped_type,
142
+ field_name=nested_field_name,
143
+ value=value[nested_field_name],
144
+ )
145
+ for nested_field_name in unwrapped_type.model_fields if nested_field_name in value
146
+ }
147
+
148
+ return value
149
+
150
+ @classmethod
151
+ async def _create_model_instance(cls, document: dict) -> Self:
152
+ """Prepares document and creates an instance of the model."""
153
+ processed_document = {
154
+ field_name: await cls._create_field(model=cls, field_name=field_name, value=field_value)
155
+ for field_name, field_value in document.items()
156
+ }
157
+ try:
158
+ return cls(**processed_document)
159
+ except ValidationError as validation_error:
160
+ error = cls._clean_error_message(validation_error=validation_error)
161
+ raise DatabaseError(error) from validation_error
162
+
163
+ @classmethod
164
+ def clean_value(cls, field: str | None, value: Any) -> dict[str, Any] | list[Any]:
165
+ match value:
166
+ case None:
167
+ return None
168
+ case Model() as model:
169
+ if model.id is None:
170
+ raise DatabaseError(f'Model instance{" in " + field if field else ""} has no ID.')
171
+ # We save full object because user didn't specify the type.
172
+ return model._id
173
+ case BaseModel() as model:
174
+ return {
175
+ field_name: cls.clean_value(field=field_name, value=getattr(model, field_name))
176
+ for field_name in model.model_fields
177
+ }
178
+ case dict() as d:
179
+ return {k: cls.clean_value(field=k, value=v) for k, v in d.items()}
180
+ case list() as l:
181
+ return [cls.clean_value(field=None, value=item) for item in l]
182
+
183
+ return value
184
+
37
185
  # # # # # Find # # # # #
38
186
  @classmethod
39
187
  async def find_one(cls, _filter: dict | None = None, /, **kwargs) -> Self | None:
40
188
  if document := await db.session[cls.__name__].find_one(cls._merge(_filter, kwargs)):
41
- return cls._create_model_instance(document=document)
189
+ return await cls._create_model_instance(document=document)
42
190
  return None
43
191
 
44
192
  @classmethod
@@ -48,14 +196,14 @@ class BaseMongoDBQuery(BaseQuery):
48
196
  @classmethod
49
197
  async def first(cls, _filter: dict | None = None, /, **kwargs) -> Self | None:
50
198
  cursor = await cls.find(_filter, **kwargs)
51
- for result in cursor.sort('_id', 1).limit(-1):
199
+ async for result in cursor.sort('_id', 1).limit(-1):
52
200
  return result
53
201
  return None
54
202
 
55
203
  @classmethod
56
204
  async def last(cls, _filter: dict | None = None, /, **kwargs) -> Self | None:
57
205
  cursor = await cls.find(_filter, **kwargs)
58
- for result in cursor.sort('_id', -1).limit(-1):
206
+ async for result in cursor.sort('_id', -1).limit(-1):
59
207
  return result
60
208
  return None
61
209
 
@@ -73,18 +221,32 @@ class BaseMongoDBQuery(BaseQuery):
73
221
  async def insert_one(cls, _document: dict | None = None, /, **kwargs) -> Self:
74
222
  document = cls._merge(_document, kwargs)
75
223
  cls._validate_data(data=document)
76
-
77
- await db.session[cls.__name__].insert_one(document)
78
- return cls._create_model_instance(document=document)
224
+ final_document = {
225
+ field: cls.clean_value(field=field, value=value)
226
+ for field, value in document.items()
227
+ }
228
+ result = await cls._create_model_instance(document=final_document)
229
+ insert_one_result: InsertOneResult = await db.session[cls.__name__].insert_one(final_document)
230
+ result.id = insert_one_result.inserted_id
231
+ return result
79
232
 
80
233
  @classmethod
81
234
  async def insert_many(cls, documents: Iterable[dict]) -> list[Self]:
235
+ final_documents = []
236
+ results = []
82
237
  for document in documents:
83
238
  prepare_id_for_query(document, is_mongo=True)
84
239
  cls._validate_data(data=document)
85
-
86
- await db.session[cls.__name__].insert_many(documents)
87
- return [cls._create_model_instance(document=document) for document in documents]
240
+ cleaned_document = {
241
+ field: cls.clean_value(field=field, value=value)
242
+ for field, value in document.items()
243
+ }
244
+ final_documents.append(cleaned_document)
245
+ results.append(await cls._create_model_instance(document=cleaned_document))
246
+ insert_many_result: InsertManyResult = await db.session[cls.__name__].insert_many(final_documents)
247
+ for obj, _id in zip(results, insert_many_result.inserted_ids):
248
+ obj.id = _id
249
+ return results
88
250
 
89
251
  # # # # # Delete # # # # #
90
252
  async def delete(self) -> None:
@@ -25,7 +25,7 @@ class BasePantherDBQuery(BaseQuery):
25
25
  @classmethod
26
26
  async def find_one(cls, _filter: dict | None = None, /, **kwargs) -> Self | None:
27
27
  if document := db.session.collection(cls.__name__).find_one(**cls._merge(_filter, kwargs)):
28
- return cls._create_model_instance(document=document)
28
+ return await cls._create_model_instance(document=document)
29
29
  return None
30
30
 
31
31
  @classmethod
@@ -38,13 +38,13 @@ class BasePantherDBQuery(BaseQuery):
38
38
  @classmethod
39
39
  async def first(cls, _filter: dict | None = None, /, **kwargs) -> Self | None:
40
40
  if document := db.session.collection(cls.__name__).first(**cls._merge(_filter, kwargs)):
41
- return cls._create_model_instance(document=document)
41
+ return await cls._create_model_instance(document=document)
42
42
  return None
43
43
 
44
44
  @classmethod
45
45
  async def last(cls, _filter: dict | None = None, /, **kwargs) -> Self | None:
46
46
  if document := db.session.collection(cls.__name__).last(**cls._merge(_filter, kwargs)):
47
- return cls._create_model_instance(document=document)
47
+ return await cls._create_model_instance(document=document)
48
48
  return None
49
49
 
50
50
  @classmethod
@@ -64,7 +64,7 @@ class BasePantherDBQuery(BaseQuery):
64
64
  cls._validate_data(data=_document)
65
65
 
66
66
  document = db.session.collection(cls.__name__).insert_one(**_document)
67
- return cls._create_model_instance(document=document)
67
+ return await cls._create_model_instance(document=document)
68
68
 
69
69
  @classmethod
70
70
  async def insert_many(cls, documents: Iterable[dict]) -> list[Self]:
@@ -74,7 +74,7 @@ class BasePantherDBQuery(BaseQuery):
74
74
  cls._validate_data(data=document)
75
75
  inserted_document = db.session.collection(cls.__name__).insert_one(**document)
76
76
  document['_id'] = inserted_document['_id']
77
- result.append(cls._create_model_instance(document=document))
77
+ result.append(await cls._create_model_instance(document=document))
78
78
  return result
79
79
 
80
80
  # # # # # Delete # # # # #
@@ -404,7 +404,7 @@ class Query(BaseQuery):
404
404
  >>> await user.save()
405
405
  """
406
406
  document = {
407
- field: getattr(self, field).model_dump()
407
+ field: getattr(self, field).model_dump(by_alias=True)
408
408
  if issubclass(type(getattr(self, field)), BaseModel)
409
409
  else getattr(self, field)
410
410
  for field in self.model_fields.keys() if field != 'request'
panther/events.py CHANGED
@@ -1,8 +1,11 @@
1
1
  import asyncio
2
+ import logging
2
3
 
3
4
  from panther._utils import is_function_async
4
5
  from panther.configs import config
5
6
 
7
+ logger = logging.getLogger('panther')
8
+
6
9
 
7
10
  class Event:
8
11
  @staticmethod
@@ -24,10 +27,13 @@ class Event:
24
27
  @staticmethod
25
28
  async def run_startups():
26
29
  for func in config.STARTUPS:
27
- if is_function_async(func):
28
- await func()
29
- else:
30
- func()
30
+ try:
31
+ if is_function_async(func):
32
+ await func()
33
+ else:
34
+ func()
35
+ except Exception as e:
36
+ logger.error(f'{func.__name__}() startup event got error: {e}')
31
37
 
32
38
  @staticmethod
33
39
  def run_shutdowns():
panther/exceptions.py CHANGED
@@ -9,17 +9,34 @@ class DatabaseError(Exception):
9
9
  pass
10
10
 
11
11
 
12
- class APIError(Exception):
12
+ class BaseError(Exception):
13
13
  detail: str | dict | list = 'Internal Server Error'
14
14
  status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR
15
15
 
16
16
  def __init__(
17
17
  self,
18
18
  detail: str | dict | list = None,
19
- status_code: int = None
19
+ status_code: int = None,
20
+ headers: dict = None
20
21
  ):
21
22
  self.detail = detail or self.detail
22
23
  self.status_code = status_code or self.status_code
24
+ self.headers = headers
25
+
26
+
27
+ class APIError(BaseError):
28
+ detail: str | dict | list = 'Internal Server Error'
29
+ status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR
30
+
31
+
32
+ class WebsocketError(BaseError):
33
+ detail: str | dict | list = 'Internal Error'
34
+ status_code: int = status.WS_1011_INTERNAL_ERROR
35
+
36
+
37
+ class RedirectAPIError(APIError):
38
+ def __init__(self, url: str, status_code: int = status.HTTP_302_FOUND):
39
+ super().__init__(headers={'Location': url}, status_code=status_code)
23
40
 
24
41
 
25
42
  class BadRequestAPIError(APIError):
@@ -52,6 +69,11 @@ class JSONDecodeAPIError(APIError):
52
69
  status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
53
70
 
54
71
 
72
+ class UpgradeRequiredError(APIError):
73
+ detail = 'This service requires use of the WebSocket protocol.'
74
+ status_code = status.HTTP_426_UPGRADE_REQUIRED
75
+
76
+
55
77
  class ThrottlingAPIError(APIError):
56
78
  detail = 'Too Many Request'
57
79
  status_code = status.HTTP_429_TOO_MANY_REQUESTS
panther/generics.py CHANGED
@@ -146,7 +146,7 @@ class UpdateAPI(GenericAPI, ObjectRequired):
146
146
 
147
147
  await request.validated_data.update(
148
148
  instance=instance,
149
- validated_data=request.validated_data.model_dump()
149
+ validated_data=request.validated_data.model_dump(by_alias=True)
150
150
  )
151
151
  return Response(data=instance, status_code=status.HTTP_200_OK)
152
152
 
@@ -156,7 +156,7 @@ class UpdateAPI(GenericAPI, ObjectRequired):
156
156
 
157
157
  await request.validated_data.partial_update(
158
158
  instance=instance,
159
- validated_data=request.validated_data.model_dump(exclude_none=True)
159
+ validated_data=request.validated_data.model_dump(exclude_none=True, by_alias=True)
160
160
  )
161
161
  return Response(data=instance, status_code=status.HTTP_200_OK)
162
162
 
panther/main.py CHANGED
@@ -1,4 +1,3 @@
1
- import contextlib
2
1
  import logging
3
2
  import sys
4
3
  import types
@@ -6,20 +5,20 @@ from collections.abc import Callable
6
5
  from logging.config import dictConfig
7
6
  from pathlib import Path
8
7
 
9
- import orjson as json
10
-
11
8
  import panther.logging
12
9
  from panther import status
13
10
  from panther._load_configs import *
14
11
  from panther._utils import traceback_message, reformat_code
12
+ from panther.app import GenericAPI
13
+ from panther.base_websocket import Websocket
15
14
  from panther.cli.utils import print_info
16
15
  from panther.configs import config
17
16
  from panther.events import Event
18
- from panther.exceptions import APIError, PantherError
19
- from panther.monitoring import Monitoring
17
+ from panther.exceptions import APIError, PantherError, NotFoundAPIError, BaseError, UpgradeRequiredError
20
18
  from panther.request import Request
21
19
  from panther.response import Response
22
20
  from panther.routings import find_endpoint
21
+ from panther.websocket import GenericWebsocket
23
22
 
24
23
  dictConfig(panther.logging.LOGGING)
25
24
  logger = logging.getLogger('panther')
@@ -53,7 +52,6 @@ class Panther:
53
52
  load_timezone(self._configs_module)
54
53
  load_database(self._configs_module)
55
54
  load_secret_key(self._configs_module)
56
- load_monitoring(self._configs_module)
57
55
  load_throttling(self._configs_module)
58
56
  load_user_model(self._configs_module)
59
57
  load_log_queries(self._configs_module)
@@ -62,8 +60,8 @@ class Panther:
62
60
  load_auto_reformat(self._configs_module)
63
61
  load_background_tasks(self._configs_module)
64
62
  load_default_cache_exp(self._configs_module)
65
- load_authentication_class(self._configs_module)
66
63
  load_urls(self._configs_module, urls=self._urls)
64
+ load_authentication_class(self._configs_module)
67
65
  load_websocket_connections()
68
66
 
69
67
  check_endpoints_inheritance()
@@ -74,7 +72,11 @@ class Panther:
74
72
  if message["type"] == 'lifespan.startup':
75
73
  if config.HAS_WS:
76
74
  await config.WEBSOCKET_CONNECTIONS.start()
77
- await Event.run_startups()
75
+ try:
76
+ await Event.run_startups()
77
+ except Exception as e:
78
+ logger.error(e)
79
+ raise e
78
80
  elif message["type"] == 'lifespan.shutdown':
79
81
  # It's not happening :\, so handle the shutdowns in __del__ ...
80
82
  pass
@@ -83,145 +85,106 @@ class Panther:
83
85
  func = self.handle_http if scope['type'] == 'http' else self.handle_ws
84
86
  await func(scope=scope, receive=receive, send=send)
85
87
 
86
- async def handle_ws(self, scope: dict, receive: Callable, send: Callable) -> None:
87
- from panther.websocket import GenericWebsocket, Websocket
88
-
89
- # Monitoring
90
- monitoring = Monitoring(is_ws=True)
91
-
92
- # Create Temp Connection
93
- temp_connection = Websocket(scope=scope, receive=receive, send=send)
94
- await monitoring.before(request=temp_connection)
95
- temp_connection._monitoring = monitoring
96
-
88
+ @classmethod
89
+ async def handle_ws_endpoint(cls, connection: Websocket):
97
90
  # Find Endpoint
98
- endpoint, found_path = find_endpoint(path=temp_connection.path)
91
+ endpoint, found_path = find_endpoint(path=connection.path)
99
92
  if endpoint is None:
100
- logger.debug(f'Path `{temp_connection.path}` not found')
101
- return await temp_connection.close()
93
+ logger.debug(f'Path `{connection.path}` not found')
94
+ await connection.close()
95
+ return connection
102
96
 
103
97
  # Check Endpoint Type
104
98
  if not issubclass(endpoint, GenericWebsocket):
105
- logger.critical(f'You may have forgotten to inherit from `GenericWebsocket` on the `{endpoint.__name__}()`')
106
- return await temp_connection.close()
99
+ logger.warning(f'{endpoint.__name__}() class is not a Websocket class.')
100
+ await connection.close()
101
+ return connection
107
102
 
108
103
  # Create The Connection
109
- del temp_connection
110
- connection = endpoint(scope=scope, receive=receive, send=send)
111
- connection._monitoring = monitoring
104
+ final_connection = endpoint(parent=connection)
105
+ del connection
112
106
 
113
107
  # Collect Path Variables
114
- connection.collect_path_variables(found_path=found_path)
108
+ final_connection.collect_path_variables(found_path=found_path)
115
109
 
116
- middlewares = [middleware(**data) for middleware, data in config.WS_MIDDLEWARES]
110
+ return await config.WEBSOCKET_CONNECTIONS.listen(connection=final_connection)
117
111
 
118
- # Call Middlewares .before()
119
- await self._run_ws_middlewares_before_listen(connection=connection, middlewares=middlewares)
120
112
 
121
- # Listen The Connection
122
- await config.WEBSOCKET_CONNECTIONS.listen(connection=connection)
113
+ async def handle_ws(self, scope: dict, receive: Callable, send: Callable) -> None:
114
+ # Create Temp Connection
115
+ connection = Websocket(scope=scope, receive=receive, send=send)
123
116
 
124
- # Call Middlewares .after()
125
- middlewares.reverse()
126
- await self._run_ws_middlewares_after_listen(connection=connection, middlewares=middlewares)
117
+ # Create Middlewares chain
118
+ chained_func = self.handle_ws_endpoint
119
+ for middleware in reversed(config.WS_MIDDLEWARES):
120
+ chained_func = middleware(dispatch=chained_func)
127
121
 
128
- @classmethod
129
- async def _run_ws_middlewares_before_listen(cls, *, connection, middlewares):
122
+ # Call Middlewares & Endpoint
130
123
  try:
131
- for middleware in middlewares:
132
- new_connection = await middleware.before(request=connection)
133
- if new_connection is None:
134
- logger.critical(
135
- f'Make sure to return the `request` at the end of `{middleware.__class__.__name__}.before()`')
136
- await connection.close()
137
- connection = new_connection
138
- except APIError as e:
124
+ connection = await chained_func(connection=connection)
125
+ except BaseError as e:
139
126
  connection.log(e.detail)
140
127
  await connection.close()
128
+ except Exception as e:
129
+ logger.error(traceback_message(exception=e))
130
+ await connection.close()
141
131
 
142
- @classmethod
143
- async def _run_ws_middlewares_after_listen(cls, *, connection, middlewares):
144
- for middleware in middlewares:
145
- with contextlib.suppress(APIError):
146
- connection = await middleware.after(response=connection)
147
- if connection is None:
148
- logger.critical(
149
- f'Make sure to return the `response` at the end of `{middleware.__class__.__name__}.after()`')
150
- break
151
-
152
- async def handle_http(self, scope: dict, receive: Callable, send: Callable) -> None:
153
- # Monitoring
154
- monitoring = Monitoring()
155
-
156
- request = Request(scope=scope, receive=receive, send=send)
157
-
158
- await monitoring.before(request=request)
159
-
160
- # Read Request Payload
161
- await request.read_body()
162
132
 
133
+ @classmethod
134
+ async def handle_http_endpoint(cls, request: Request) -> Response:
163
135
  # Find Endpoint
164
136
  endpoint, found_path = find_endpoint(path=request.path)
165
137
  if endpoint is None:
166
- return await self._raise(send, monitoring=monitoring, status_code=status.HTTP_404_NOT_FOUND)
138
+ raise NotFoundAPIError
167
139
 
168
140
  # Collect Path Variables
169
141
  request.collect_path_variables(found_path=found_path)
170
142
 
171
- middlewares = [middleware(**data) for middleware, data in config.HTTP_MIDDLEWARES]
172
- try: # They Both(middleware.before() & _endpoint()) Have The Same Exception (APIError)
173
- # Call Middlewares .before()
174
- for middleware in middlewares:
175
- request = await middleware.before(request=request)
176
- if request is None:
177
- logger.critical(
178
- f'Make sure to return the `request` at the end of `{middleware.__class__.__name__}.before()`')
179
- return await self._raise(send, monitoring=monitoring)
143
+ # Prepare the method
144
+ if not isinstance(endpoint, types.FunctionType):
145
+ if not issubclass(endpoint, GenericAPI):
146
+ raise UpgradeRequiredError
180
147
 
181
- # Prepare the method
182
- if not isinstance(endpoint, types.FunctionType):
183
- endpoint = endpoint().call_method
148
+ endpoint = endpoint().call_method
149
+ # Call Endpoint
150
+ return await endpoint(request=request)
151
+
152
+ async def handle_http(self, scope: dict, receive: Callable, send: Callable) -> None:
153
+ # Create `Request` and its body
154
+ request = Request(scope=scope, receive=receive, send=send)
155
+ await request.read_body()
184
156
 
185
- # Call Endpoint
186
- response = await endpoint(request=request)
157
+ # Create Middlewares chain
158
+ chained_func = self.handle_http_endpoint
159
+ for middleware in reversed(config.HTTP_MIDDLEWARES):
160
+ chained_func = middleware(dispatch=chained_func)
187
161
 
162
+ # Call Middlewares & Endpoint
163
+ try:
164
+ response = await chained_func(request=request)
165
+ if response is None:
166
+ logger.error('You forgot to return `response` on the `Middlewares.__call__()`')
167
+ response = Response(
168
+ data={'detail': 'Internal Server Error'},
169
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
170
+ )
171
+ # Handle `APIError` Exceptions
188
172
  except APIError as e:
189
- response = self._handle_exceptions(e)
190
-
191
- except Exception as e: # noqa: BLE001
192
- # All unhandled exceptions are caught here
193
- exception = traceback_message(exception=e)
194
- logger.error(exception)
195
- return await self._raise(send, monitoring=monitoring)
196
-
197
- # Call Middlewares .after()
198
- middlewares.reverse()
199
- for middleware in middlewares:
200
- try:
201
- response = await middleware.after(response=response)
202
- if response is None:
203
- logger.critical(
204
- f'Make sure to return the `response` at the end of `{middleware.__class__.__name__}.after()`')
205
- return await self._raise(send, monitoring=monitoring)
206
- except APIError as e: # noqa: PERF203
207
- response = self._handle_exceptions(e)
208
-
209
- await response.send(send, receive, monitoring=monitoring)
173
+ response = Response(
174
+ data=e.detail if isinstance(e.detail, dict) else {'detail': e.detail},
175
+ headers=e.headers,
176
+ status_code=e.status_code,
177
+ )
178
+ # Handle Unknown Exceptions
179
+ except Exception as e: # noqa: BLE001 - Blind Exception
180
+ logger.error(traceback_message(exception=e))
181
+ response = Response(
182
+ data={'detail': 'Internal Server Error'},
183
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
184
+ )
185
+
186
+ # Return Response
187
+ await response.send(send, receive)
210
188
 
211
189
  def __del__(self):
212
190
  Event.run_shutdowns()
213
-
214
- @classmethod
215
- def _handle_exceptions(cls, e: APIError, /) -> Response:
216
- return Response(
217
- data=e.detail if isinstance(e.detail, dict) else {'detail': e.detail},
218
- status_code=e.status_code,
219
- )
220
-
221
- @classmethod
222
- async def _raise(cls, send, *, monitoring, status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR):
223
- headers = [[b'Content-Type', b'application/json']]
224
- body = json.dumps({'detail': status.status_text[status_code]})
225
- await monitoring.after(status_code)
226
- await send({'type': 'http.response.start', 'status': status_code, 'headers': headers})
227
- await send({'type': 'http.response.body', 'body': body, 'more_body': False})
@@ -1 +1 @@
1
- from panther.middlewares.base import BaseMiddleware # noqa: F401
1
+ from panther.middlewares.base import HTTPMiddleware, WebsocketMiddleware # noqa: F401