panther 4.3.7__py3-none-any.whl → 5.0.0b2__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 +177 -13
  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 +90 -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.0b2.dist-info}/METADATA +19 -17
  54. panther-5.0.0b2.dist-info/RECORD +75 -0
  55. {panther-4.3.7.dist-info → panther-5.0.0b2.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.0b2.dist-info}/entry_points.txt +0 -0
  58. {panther-4.3.7.dist-info → panther-5.0.0b2.dist-info}/licenses/LICENSE +0 -0
  59. {panther-4.3.7.dist-info → panther-5.0.0b2.dist-info}/top_level.txt +0 -0
@@ -1,20 +1,29 @@
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
5
9
 
6
10
  from panther.db.connections import db
7
11
  from panther.db.cursor import Cursor
12
+ from panther.db.models import Model
8
13
  from panther.db.queries.base_queries import BaseQuery
9
14
  from panther.db.utils import prepare_id_for_query
15
+ from panther.exceptions import DatabaseError
10
16
 
11
17
  try:
12
18
  from bson.codec_options import CodecOptions
19
+ from pymongo.results import InsertOneResult, InsertManyResult
13
20
  except ImportError:
14
- # This 'CodecOptions' is not going to be used,
15
- # If user really wants to use it,
16
- # we are going to force him to install it in `panther.db.connections.MongoDBConnection.init`
21
+ # MongoDB-related libraries are not required by default.
22
+ # If the user intends to use MongoDB, they must install the required dependencies explicitly.
23
+ # This will be enforced in `panther.db.connections.MongoDBConnection.init`.
17
24
  CodecOptions = type('CodecOptions', (), {})
25
+ InsertOneResult = type('InsertOneResult', (), {})
26
+ InsertManyResult = type('InsertManyResult', (), {})
18
27
 
19
28
  if version_info >= (3, 11):
20
29
  from typing import Self
@@ -24,6 +33,32 @@ else:
24
33
  Self = TypeVar('Self', bound='BaseMongoDBQuery')
25
34
 
26
35
 
36
+ def get_annotation_type(annotation: Any) -> type | None:
37
+ """
38
+ Extracts the underlying, non-optional type from a type annotation.
39
+ Handles basic types, Pydantic BaseModels, lists, and unions (optionals).
40
+ Returns None if no single underlying type can be determined (e.g., for list[NoneType]).
41
+ Raises DatabaseError for unsupported annotations.
42
+ """
43
+ origin = get_origin(annotation)
44
+
45
+ # Handle list[T] and Union[T, None] (T | None or typing.Union[T, None])
46
+ if origin is list or origin is types.UnionType or origin is Union:
47
+ # Extracts the first non-None type from a tuple of type arguments.
48
+ for arg in get_args(annotation):
49
+ if arg is not type(None):
50
+ return arg
51
+ return None
52
+
53
+ # Handle basic types (str, int, bool, dict) and Pydantic BaseModel subclasses
54
+ if isinstance(annotation, type) and (
55
+ annotation in (str, int, bool, dict) or issubclass(annotation, BaseModel)
56
+ ):
57
+ return annotation
58
+
59
+ raise DatabaseError(f'Panther does not support {annotation} as a field type for unwrapping.')
60
+
61
+
27
62
  class BaseMongoDBQuery(BaseQuery):
28
63
  @classmethod
29
64
  def _merge(cls, *args, is_mongo: bool = True) -> dict:
@@ -34,11 +69,126 @@ class BaseMongoDBQuery(BaseQuery):
34
69
  # def collection(cls):
35
70
  # return db.session.get_collection(name=cls.__name__, codec_options=CodecOptions(document_class=cls))
36
71
 
72
+ @classmethod
73
+ async def _create_list(cls, field_type: type, value: Any) -> Any:
74
+ # `field_type` is the expected type of items in the list (e.g., int, Model, list[str])
75
+ # `value` is a single item from the input list that needs processing.
76
+
77
+ # Handles list[list[int]], list[dict[str,int]] etc.
78
+ if isinstance(field_type, (types.GenericAlias, typing._GenericAlias)):
79
+ element_type = get_annotation_type(field_type) # Unwrap further (e.g. list[str] -> str)
80
+ if element_type is None:
81
+ raise DatabaseError(f"Cannot determine element type for generic list item: {field_type}")
82
+ if not isinstance(value, list): # Or check if iterable, matching the structure
83
+ raise DatabaseError(f"Expected a list for nested generic type {field_type}, got {type(value)}")
84
+ return [await cls._create_list(field_type=element_type, value=item) for item in value]
85
+
86
+ # Make sure Model condition is before BaseModel.
87
+ if isinstance(field_type, type) and issubclass(field_type, Model):
88
+ # `value` is assumed to be an ID for the Model instance.
89
+ return await field_type.first(id=value)
90
+
91
+ if isinstance(field_type, type) and issubclass(field_type, BaseModel):
92
+ if not isinstance(value, dict):
93
+ raise DatabaseError(f"Expected a dictionary for BaseModel {field_type.__name__}, got {type(value)}")
94
+
95
+ return {
96
+ field_name: await cls._create_field(model=field_type, field_name=field_name, value=value[field_name])
97
+ for field_name in value
98
+ }
99
+
100
+ # Base case: value is a primitive type (str, int, etc.)
101
+ return value
102
+
103
+ @classmethod
104
+ async def _create_field(cls, model: type, field_name: str, value: Any) -> Any:
105
+ # Handle primary key field directly
106
+ if field_name == '_id':
107
+ return value
108
+
109
+ if field_name not in model.model_fields:
110
+ # Field from input data is not defined in the model.
111
+ # Pydantic's `extra` config on the model will handle this upon instantiation.
112
+ return value
113
+
114
+ field_annotation = model.model_fields[field_name].annotation
115
+ unwrapped_type = get_annotation_type(field_annotation)
116
+
117
+ if unwrapped_type is None:
118
+ raise DatabaseError(
119
+ f"Could not determine a valid underlying type for field '{field_name}' "
120
+ f"with annotation {field_annotation} in model {model.__name__}."
121
+ )
122
+
123
+ if get_origin(field_annotation) is list:
124
+ # Or check for general iterables if applicable
125
+ if not isinstance(value, list):
126
+ raise DatabaseError(
127
+ f"Field '{field_name}' expects a list, got {type(value)} for model {model.__name__}")
128
+ return [await cls._create_list(field_type=unwrapped_type, value=item) for item in value]
129
+
130
+ if isinstance(unwrapped_type, type) and issubclass(unwrapped_type, Model):
131
+ if obj := await unwrapped_type.first(id=value):
132
+ return obj.model_dump(by_alias=True)
133
+ return None
134
+
135
+ if isinstance(unwrapped_type, type) and issubclass(unwrapped_type, BaseModel):
136
+ if not isinstance(value, dict):
137
+ raise DatabaseError(
138
+ f"Field '{field_name}' expects a dictionary for BaseModel {unwrapped_type.__name__}, "
139
+ f"got {type(value)} in model {model.__name__}"
140
+ )
141
+ return {
142
+ nested_field_name: await cls._create_field(
143
+ model=unwrapped_type,
144
+ field_name=nested_field_name,
145
+ value=value[nested_field_name],
146
+ )
147
+ for nested_field_name in unwrapped_type.model_fields if nested_field_name in value
148
+ }
149
+
150
+ return value
151
+
152
+ @classmethod
153
+ async def _create_model_instance(cls, document: dict) -> Self:
154
+ """Prepares document and creates an instance of the model."""
155
+ processed_document = {
156
+ field_name: await cls._create_field(model=cls, field_name=field_name, value=field_value)
157
+ for field_name, field_value in document.items()
158
+ }
159
+ try:
160
+ return cls(**processed_document)
161
+ except ValidationError as validation_error:
162
+ error = cls._clean_error_message(validation_error=validation_error)
163
+ raise DatabaseError(error) from validation_error
164
+
165
+ @classmethod
166
+ def clean_value(cls, field: str | None, value: Any) -> dict[str, Any] | list[Any]:
167
+ match value:
168
+ case None:
169
+ return None
170
+ case Model() as model:
171
+ if model.id is None:
172
+ raise DatabaseError(f'Model instance{" in " + field if field else ""} has no ID.')
173
+ # We save full object because user didn't specify the type.
174
+ return model._id
175
+ case BaseModel() as model:
176
+ return {
177
+ field_name: cls.clean_value(field=field_name, value=getattr(model, field_name))
178
+ for field_name in model.model_fields
179
+ }
180
+ case dict() as d:
181
+ return {k: cls.clean_value(field=k, value=v) for k, v in d.items()}
182
+ case list() as l:
183
+ return [cls.clean_value(field=None, value=item) for item in l]
184
+
185
+ return value
186
+
37
187
  # # # # # Find # # # # #
38
188
  @classmethod
39
189
  async def find_one(cls, _filter: dict | None = None, /, **kwargs) -> Self | None:
40
190
  if document := await db.session[cls.__name__].find_one(cls._merge(_filter, kwargs)):
41
- return cls._create_model_instance(document=document)
191
+ return await cls._create_model_instance(document=document)
42
192
  return None
43
193
 
44
194
  @classmethod
@@ -48,14 +198,14 @@ class BaseMongoDBQuery(BaseQuery):
48
198
  @classmethod
49
199
  async def first(cls, _filter: dict | None = None, /, **kwargs) -> Self | None:
50
200
  cursor = await cls.find(_filter, **kwargs)
51
- for result in cursor.sort('_id', 1).limit(-1):
201
+ async for result in cursor.sort('_id', 1).limit(-1):
52
202
  return result
53
203
  return None
54
204
 
55
205
  @classmethod
56
206
  async def last(cls, _filter: dict | None = None, /, **kwargs) -> Self | None:
57
207
  cursor = await cls.find(_filter, **kwargs)
58
- for result in cursor.sort('_id', -1).limit(-1):
208
+ async for result in cursor.sort('_id', -1).limit(-1):
59
209
  return result
60
210
  return None
61
211
 
@@ -73,18 +223,32 @@ class BaseMongoDBQuery(BaseQuery):
73
223
  async def insert_one(cls, _document: dict | None = None, /, **kwargs) -> Self:
74
224
  document = cls._merge(_document, kwargs)
75
225
  cls._validate_data(data=document)
76
-
77
- await db.session[cls.__name__].insert_one(document)
78
- return cls._create_model_instance(document=document)
226
+ final_document = {
227
+ field: cls.clean_value(field=field, value=value)
228
+ for field, value in document.items()
229
+ }
230
+ result = await cls._create_model_instance(document=final_document)
231
+ insert_one_result: InsertOneResult = await db.session[cls.__name__].insert_one(final_document)
232
+ result.id = insert_one_result.inserted_id
233
+ return result
79
234
 
80
235
  @classmethod
81
236
  async def insert_many(cls, documents: Iterable[dict]) -> list[Self]:
237
+ final_documents = []
238
+ results = []
82
239
  for document in documents:
83
240
  prepare_id_for_query(document, is_mongo=True)
84
241
  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]
242
+ cleaned_document = {
243
+ field: cls.clean_value(field=field, value=value)
244
+ for field, value in document.items()
245
+ }
246
+ final_documents.append(cleaned_document)
247
+ results.append(await cls._create_model_instance(document=cleaned_document))
248
+ insert_many_result: InsertManyResult = await db.session[cls.__name__].insert_many(final_documents)
249
+ for obj, _id in zip(results, insert_many_result.inserted_ids):
250
+ obj.id = _id
251
+ return results
88
252
 
89
253
  # # # # # Delete # # # # #
90
254
  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