panther 4.1.2__tar.gz → 4.2.0__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 (81) hide show
  1. {panther-4.1.2 → panther-4.2.0}/PKG-INFO +1 -1
  2. {panther-4.1.2 → panther-4.2.0}/panther/__init__.py +1 -1
  3. {panther-4.1.2 → panther-4.2.0}/panther/app.py +25 -5
  4. {panther-4.1.2 → panther-4.2.0}/panther/base_request.py +2 -5
  5. {panther-4.1.2 → panther-4.2.0}/panther/cli/create_command.py +1 -1
  6. {panther-4.1.2 → panther-4.2.0}/panther/cli/template.py +1 -0
  7. {panther-4.1.2 → panther-4.2.0}/panther/db/models.py +1 -1
  8. {panther-4.1.2 → panther-4.2.0}/panther/db/queries/mongodb_queries.py +25 -10
  9. {panther-4.1.2 → panther-4.2.0}/panther/db/queries/queries.py +7 -3
  10. {panther-4.1.2 → panther-4.2.0}/panther/db/utils.py +2 -2
  11. {panther-4.1.2 → panther-4.2.0}/panther/file_handler.py +18 -1
  12. {panther-4.1.2 → panther-4.2.0}/panther/generics.py +23 -4
  13. {panther-4.1.2 → panther-4.2.0}/panther/serializer.py +2 -2
  14. {panther-4.1.2 → panther-4.2.0}/panther.egg-info/PKG-INFO +1 -1
  15. {panther-4.1.2 → panther-4.2.0}/tests/test_database.py +18 -0
  16. {panther-4.1.2 → panther-4.2.0}/LICENSE +0 -0
  17. {panther-4.1.2 → panther-4.2.0}/README.md +0 -0
  18. {panther-4.1.2 → panther-4.2.0}/panther/_load_configs.py +0 -0
  19. {panther-4.1.2 → panther-4.2.0}/panther/_utils.py +0 -0
  20. {panther-4.1.2 → panther-4.2.0}/panther/authentications.py +0 -0
  21. {panther-4.1.2 → panther-4.2.0}/panther/background_tasks.py +0 -0
  22. {panther-4.1.2 → panther-4.2.0}/panther/base_websocket.py +0 -0
  23. {panther-4.1.2 → panther-4.2.0}/panther/caching.py +0 -0
  24. {panther-4.1.2 → panther-4.2.0}/panther/cli/__init__.py +0 -0
  25. {panther-4.1.2 → panther-4.2.0}/panther/cli/main.py +0 -0
  26. {panther-4.1.2 → panther-4.2.0}/panther/cli/monitor_command.py +0 -0
  27. {panther-4.1.2 → panther-4.2.0}/panther/cli/run_command.py +0 -0
  28. {panther-4.1.2 → panther-4.2.0}/panther/cli/utils.py +0 -0
  29. {panther-4.1.2 → panther-4.2.0}/panther/configs.py +0 -0
  30. {panther-4.1.2 → panther-4.2.0}/panther/db/__init__.py +0 -0
  31. {panther-4.1.2 → panther-4.2.0}/panther/db/connections.py +0 -0
  32. {panther-4.1.2 → panther-4.2.0}/panther/db/cursor.py +0 -0
  33. {panther-4.1.2 → panther-4.2.0}/panther/db/queries/__init__.py +0 -0
  34. {panther-4.1.2 → panther-4.2.0}/panther/db/queries/base_queries.py +0 -0
  35. {panther-4.1.2 → panther-4.2.0}/panther/db/queries/pantherdb_queries.py +0 -0
  36. {panther-4.1.2 → panther-4.2.0}/panther/events.py +0 -0
  37. {panther-4.1.2 → panther-4.2.0}/panther/exceptions.py +0 -0
  38. {panther-4.1.2 → panther-4.2.0}/panther/logging.py +0 -0
  39. {panther-4.1.2 → panther-4.2.0}/panther/main.py +0 -0
  40. {panther-4.1.2 → panther-4.2.0}/panther/middlewares/__init__.py +0 -0
  41. {panther-4.1.2 → panther-4.2.0}/panther/middlewares/base.py +0 -0
  42. {panther-4.1.2 → panther-4.2.0}/panther/monitoring.py +0 -0
  43. {panther-4.1.2 → panther-4.2.0}/panther/pagination.py +0 -0
  44. {panther-4.1.2 → panther-4.2.0}/panther/panel/__init__.py +0 -0
  45. {panther-4.1.2 → panther-4.2.0}/panther/panel/apis.py +0 -0
  46. {panther-4.1.2 → panther-4.2.0}/panther/panel/urls.py +0 -0
  47. {panther-4.1.2 → panther-4.2.0}/panther/panel/utils.py +0 -0
  48. {panther-4.1.2 → panther-4.2.0}/panther/permissions.py +0 -0
  49. {panther-4.1.2 → panther-4.2.0}/panther/request.py +0 -0
  50. {panther-4.1.2 → panther-4.2.0}/panther/response.py +0 -0
  51. {panther-4.1.2 → panther-4.2.0}/panther/routings.py +0 -0
  52. {panther-4.1.2 → panther-4.2.0}/panther/status.py +0 -0
  53. {panther-4.1.2 → panther-4.2.0}/panther/test.py +0 -0
  54. {panther-4.1.2 → panther-4.2.0}/panther/throttling.py +0 -0
  55. {panther-4.1.2 → panther-4.2.0}/panther/utils.py +0 -0
  56. {panther-4.1.2 → panther-4.2.0}/panther/websocket.py +0 -0
  57. {panther-4.1.2 → panther-4.2.0}/panther.egg-info/SOURCES.txt +0 -0
  58. {panther-4.1.2 → panther-4.2.0}/panther.egg-info/dependency_links.txt +0 -0
  59. {panther-4.1.2 → panther-4.2.0}/panther.egg-info/entry_points.txt +0 -0
  60. {panther-4.1.2 → panther-4.2.0}/panther.egg-info/requires.txt +0 -0
  61. {panther-4.1.2 → panther-4.2.0}/panther.egg-info/top_level.txt +0 -0
  62. {panther-4.1.2 → panther-4.2.0}/pyproject.toml +0 -0
  63. {panther-4.1.2 → panther-4.2.0}/setup.cfg +0 -0
  64. {panther-4.1.2 → panther-4.2.0}/setup.py +0 -0
  65. {panther-4.1.2 → panther-4.2.0}/tests/test_authentication.py +0 -0
  66. {panther-4.1.2 → panther-4.2.0}/tests/test_background_tasks.py +0 -0
  67. {panther-4.1.2 → panther-4.2.0}/tests/test_caching.py +0 -0
  68. {panther-4.1.2 → panther-4.2.0}/tests/test_cli.py +0 -0
  69. {panther-4.1.2 → panther-4.2.0}/tests/test_events.py +0 -0
  70. {panther-4.1.2 → panther-4.2.0}/tests/test_generics.py +0 -0
  71. {panther-4.1.2 → panther-4.2.0}/tests/test_multipart.py +0 -0
  72. {panther-4.1.2 → panther-4.2.0}/tests/test_panel_apis.py +0 -0
  73. {panther-4.1.2 → panther-4.2.0}/tests/test_request.py +0 -0
  74. {panther-4.1.2 → panther-4.2.0}/tests/test_response.py +0 -0
  75. {panther-4.1.2 → panther-4.2.0}/tests/test_routing.py +0 -0
  76. {panther-4.1.2 → panther-4.2.0}/tests/test_run.py +0 -0
  77. {panther-4.1.2 → panther-4.2.0}/tests/test_serializer.py +0 -0
  78. {panther-4.1.2 → panther-4.2.0}/tests/test_status.py +0 -0
  79. {panther-4.1.2 → panther-4.2.0}/tests/test_throttling.py +0 -0
  80. {panther-4.1.2 → panther-4.2.0}/tests/test_utils.py +0 -0
  81. {panther-4.1.2 → panther-4.2.0}/tests/test_websockets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: panther
3
- Version: 4.1.2
3
+ Version: 4.2.0
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
@@ -1,6 +1,6 @@
1
1
  from panther.main import Panther # noqa: F401
2
2
 
3
- __version__ = '4.1.2'
3
+ __version__ = '4.2.0'
4
4
 
5
5
 
6
6
  def version():
@@ -60,6 +60,10 @@ class API:
60
60
  async def wrapper(request: Request) -> Response:
61
61
  self.request = request
62
62
 
63
+ # 0. Preflight
64
+ if self.request.method == 'OPTIONS':
65
+ return self.options()
66
+
63
67
  # 1. Check Method
64
68
  if self.methods and self.request.method not in self.methods:
65
69
  raise MethodNotAllowedAPIError
@@ -141,6 +145,14 @@ class API:
141
145
  if self.input_model:
142
146
  self.request.validated_data = self.validate_input(model=self.input_model, request=self.request)
143
147
 
148
+ @classmethod
149
+ def options(cls):
150
+ headers = {
151
+ 'Access-Control-Allow-Methods': 'DELETE, GET, PATCH, POST, PUT, OPTIONS',
152
+ 'Access-Control-Allow-Headers': 'Accept, Authorization, User-Agent, Content-Type',
153
+ }
154
+ return Response(headers=headers)
155
+
144
156
  @classmethod
145
157
  def validate_input(cls, model, request: Request):
146
158
  if isinstance(request.data, bytes):
@@ -151,15 +163,15 @@ class API:
151
163
  # `request` will be ignored in regular `BaseModel`
152
164
  return model(**request.data, request=request)
153
165
  except ValidationError as validation_error:
154
- error = {'.'.join(loc for loc in e['loc']): e['msg'] for e in validation_error.errors()}
166
+ error = {'.'.join(str(loc) for loc in e['loc']): e['msg'] for e in validation_error.errors()}
155
167
  raise BadRequestAPIError(detail=error)
156
168
  except JSONDecodeError:
157
169
  raise JSONDecodeAPIError
158
170
 
159
171
 
160
172
  class GenericAPI:
161
- input_model: type[ModelSerializer] | type[BaseModel] = None
162
- output_model: type[ModelSerializer] | type[BaseModel] = None
173
+ input_model: type[ModelSerializer] | type[BaseModel] | None = None
174
+ output_model: type[ModelSerializer] | type[BaseModel] | None = None
163
175
  auth: bool = False
164
176
  permissions: list | None = None
165
177
  throttling: Throttling | None = None
@@ -181,6 +193,12 @@ class GenericAPI:
181
193
  async def delete(self, *args, **kwargs):
182
194
  raise MethodNotAllowedAPIError
183
195
 
196
+ async def get_input_model(self, request: Request) -> type[ModelSerializer] | type[BaseModel] | None:
197
+ return None
198
+
199
+ async def get_output_model(self, request: Request) -> type[ModelSerializer] | type[BaseModel] | None:
200
+ return None
201
+
184
202
  async def call_method(self, request: Request):
185
203
  match request.method:
186
204
  case 'GET':
@@ -193,12 +211,14 @@ class GenericAPI:
193
211
  func = self.patch
194
212
  case 'DELETE':
195
213
  func = self.delete
214
+ case 'OPTIONS':
215
+ func = API.options
196
216
  case _:
197
217
  raise MethodNotAllowedAPIError
198
218
 
199
219
  return await API(
200
- input_model=self.input_model,
201
- output_model=self.output_model,
220
+ input_model=self.input_model or await self.get_input_model(request=request),
221
+ output_model=self.output_model or await self.get_output_model(request=request),
202
222
  auth=self.auth,
203
223
  permissions=self.permissions,
204
224
  throttling=self.throttling,
@@ -1,5 +1,6 @@
1
1
  from collections import namedtuple
2
2
  from collections.abc import Callable
3
+ from urllib.parse import parse_qsl
3
4
 
4
5
  from panther.db import Model
5
6
  from panther.exceptions import InvalidPathVariableAPIError
@@ -74,11 +75,7 @@ class BaseRequest:
74
75
  @property
75
76
  def query_params(self) -> dict:
76
77
  if self._params is None:
77
- self._params = {}
78
- if (query_string := self.scope['query_string']) != b'':
79
- for param in query_string.decode('utf-8').split('&'):
80
- k, *_, v = param.split('=')
81
- self._params[k] = v
78
+ self._params = {k: v for k, v in parse_qsl(self.scope['query_string'].decode('utf-8'))}
82
79
  return self._params
83
80
 
84
81
  @property
@@ -61,7 +61,7 @@ class CreateProject:
61
61
  },
62
62
  {
63
63
  'field': 'database',
64
- 'message': ' 0: PantherDB (File-Base, No Requirements)\n 1: MongoDB (Required `pymongo`)\n 2: No Database\nChoose Your Database (default is 0)',
64
+ 'message': ' 0: PantherDB (File-Base, No Requirements)\n 1: MongoDB (Required `motor`)\n 2: No Database\nChoose Your Database (default is 0)',
65
65
  'validation_func': lambda x: x in ['0', '1', '2'],
66
66
  'error_message': "Invalid Choice, '{}' not in ['0', '1', '2']",
67
67
  },
@@ -140,6 +140,7 @@ InfoThrottling = Throttling(rate=5, duration=timedelta(minutes=1))
140
140
 
141
141
  TIMEZONE = 'UTC'
142
142
 
143
+
143
144
  @API()
144
145
  async def hello_world_api():
145
146
  return {'detail': 'Hello World'}
@@ -21,7 +21,7 @@ def validate_object_id(value, handler):
21
21
  else:
22
22
  try:
23
23
  return bson.ObjectId(value)
24
- except bson.objectid.InvalidId as e:
24
+ except Exception as e:
25
25
  msg = 'Invalid ObjectId'
26
26
  raise ValueError(msg) from e
27
27
  return str(value)
@@ -61,7 +61,7 @@ class BaseMongoDBQuery(BaseQuery):
61
61
 
62
62
  @classmethod
63
63
  async def aggregate(cls, pipeline: Sequence[dict]) -> Iterable[dict]:
64
- return await db.session[cls.__name__].aggregate(pipeline)
64
+ return await db.session[cls.__name__].aggregate(pipeline).to_list(None)
65
65
 
66
66
  # # # # # Count # # # # #
67
67
  @classmethod
@@ -102,21 +102,36 @@ class BaseMongoDBQuery(BaseQuery):
102
102
 
103
103
  # # # # # Update # # # # #
104
104
  async def update(self, _update: dict | None = None, /, **kwargs) -> None:
105
- document = self._merge(_update, kwargs)
106
- document.pop('_id', None)
107
- self._validate_data(data=document, is_updating=True)
105
+ merged_update_query = self._merge(_update, kwargs)
106
+ merged_update_query.pop('_id', None)
108
107
 
109
- for field, value in document.items():
110
- setattr(self, field, value)
111
- update_fields = {'$set': document}
112
- await db.session[self.__class__.__name__].update_one({'_id': self._id}, update_fields)
108
+ self._validate_data(data=merged_update_query, is_updating=True)
109
+
110
+ update_query = {}
111
+ for field, value in merged_update_query.items():
112
+ if field.startswith('$'):
113
+ update_query[field] = value
114
+ else:
115
+ update_query['$set'] = update_query.get('$set', {})
116
+ update_query['$set'][field] = value
117
+ setattr(self, field, value)
118
+
119
+ await db.session[self.__class__.__name__].update_one({'_id': self._id}, update_query)
113
120
 
114
121
  @classmethod
115
122
  async def update_one(cls, _filter: dict, _update: dict | None = None, /, **kwargs) -> bool:
116
123
  prepare_id_for_query(_filter, is_mongo=True)
117
- update_fields = {'$set': cls._merge(_update, kwargs)}
124
+ merged_update_query = cls._merge(_update, kwargs)
125
+
126
+ update_query = {}
127
+ for field, value in merged_update_query.items():
128
+ if field.startswith('$'):
129
+ update_query[field] = value
130
+ else:
131
+ update_query['$set'] = update_query.get('$set', {})
132
+ update_query['$set'][field] = value
118
133
 
119
- result = await db.session[cls.__name__].update_one(_filter, update_fields)
134
+ result = await db.session[cls.__name__].update_one(_filter, update_query)
120
135
  return bool(result.matched_count)
121
136
 
122
137
  @classmethod
@@ -364,8 +364,6 @@ class Query(BaseQuery):
364
364
 
365
365
  raise NotFoundAPIError(detail=f'{cls.__name__} Does Not Exist')
366
366
 
367
- @check_connection
368
- @log_query
369
367
  async def save(self) -> None:
370
368
  """
371
369
  Save the document
@@ -384,8 +382,14 @@ class Query(BaseQuery):
384
382
  >>> user = User(name='Ali')
385
383
  >>> await user.save()
386
384
  """
387
- document = self.model_dump(exclude=['_id'])
385
+ document = {field: getattr(self, field) for field in self.model_fields_set if field != 'request'}
386
+
388
387
  if self.id:
389
388
  await self.update(document)
390
389
  else:
391
390
  await self.insert_one(document)
391
+
392
+ async def reload(self) -> Self:
393
+ new_obj = await self.find_one(id=self.id)
394
+ [setattr(self, f, getattr(new_obj, f)) for f in new_obj.model_fields]
395
+ return self
@@ -54,5 +54,5 @@ def _convert_to_object_id(_id):
54
54
  try:
55
55
  return bson.ObjectId(_id)
56
56
  except bson.objectid.InvalidId:
57
- msg = f'id={_id} is invalid bson.ObjectId'
58
- raise bson.errors.InvalidId(msg)
57
+ logger.warning(f'id={_id} is not a valid bson.ObjectId')
58
+ return None
@@ -1,7 +1,7 @@
1
1
  from functools import cached_property
2
2
 
3
3
  from panther import status
4
- from pydantic import BaseModel, field_validator
4
+ from pydantic import BaseModel, field_validator, model_serializer
5
5
 
6
6
  from panther.exceptions import APIError
7
7
 
@@ -15,6 +15,23 @@ class File(BaseModel):
15
15
  def size(self):
16
16
  return len(self.file)
17
17
 
18
+ def save(self) -> str:
19
+ if hasattr(self, '_file_name'):
20
+ return self._file_name
21
+
22
+ self._file_name = self.file_name
23
+ # TODO: check for duplication
24
+ with open(self._file_name, 'wb') as file:
25
+ file.write(self.file)
26
+
27
+ return self.file_name
28
+
29
+ @model_serializer(mode='wrap')
30
+ def _serialize(self, handler):
31
+ result = handler(self)
32
+ result['path'] = self.save()
33
+ return result
34
+
18
35
  def __repr__(self) -> str:
19
36
  return f'{self.__repr_name__()}(file_name={self.file_name}, content_type={self.content_type})'
20
37
 
@@ -23,7 +23,7 @@ logger = logging.getLogger('panther')
23
23
 
24
24
  class ObjectRequired:
25
25
  def _check_object(self, instance):
26
- if issubclass(type(instance), Model) is False:
26
+ if instance and issubclass(type(instance), Model) is False:
27
27
  logger.critical(f'`{self.__class__.__name__}.object()` should return instance of a Model --> `find_one()`')
28
28
  raise APIError
29
29
 
@@ -129,9 +129,11 @@ class CreateAPI(GenericAPI):
129
129
  input_model: type[ModelSerializer]
130
130
 
131
131
  async def post(self, request: Request, **kwargs):
132
- instance = await request.validated_data.create(
133
- validated_data=request.validated_data.model_dump()
134
- )
132
+ instance = await request.validated_data.create(validated_data={
133
+ field: getattr(request.validated_data, field)
134
+ for field in request.validated_data.model_fields_set
135
+ if field != 'request'
136
+ })
135
137
  return Response(data=instance, status_code=status.HTTP_201_CREATED)
136
138
 
137
139
 
@@ -160,13 +162,30 @@ class UpdateAPI(GenericAPI, ObjectRequired):
160
162
 
161
163
 
162
164
  class DeleteAPI(GenericAPI, ObjectRequired):
165
+ async def pre_delete(self, instance, request: Request, **kwargs):
166
+ pass
167
+
168
+ async def post_delete(self, instance, request: Request, **kwargs):
169
+ pass
170
+
163
171
  async def delete(self, request: Request, **kwargs):
164
172
  instance = await self.object(request=request, **kwargs)
165
173
  self._check_object(instance)
166
174
 
175
+ await self.pre_delete(instance, request=request, **kwargs)
167
176
  await instance.delete()
177
+ await self.post_delete(instance, request=request, **kwargs)
178
+
168
179
  return Response(status_code=status.HTTP_204_NO_CONTENT)
169
180
 
170
181
 
171
182
  class ListCreateAPI(CreateAPI, ListAPI):
172
183
  pass
184
+
185
+
186
+ class UpdateDeleteAPI(UpdateAPI, DeleteAPI):
187
+ pass
188
+
189
+
190
+ class RetrieveUpdateDeleteAPI(RetrieveAPI, UpdateAPI, DeleteAPI):
191
+ pass
@@ -65,8 +65,8 @@ class MetaModelSerializer:
65
65
 
66
66
  # Check `model` type
67
67
  try:
68
- if not issubclass(model, Model):
69
- msg = f'`{cls_name}.Config.model` is not subclass of `panther.db.Model`.'
68
+ if not issubclass(model, (Model, BaseModel)):
69
+ msg = f'`{cls_name}.Config.model` is not subclass of `panther.db.Model` or `pydantic.BaseModel`.'
70
70
  raise AttributeError(msg) from None
71
71
  except TypeError:
72
72
  msg = f'`{cls_name}.Config.model` is not subclass of `panther.db.Model`.'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: panther
3
- Version: 4.1.2
3
+ Version: 4.2.0
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
@@ -227,6 +227,21 @@ class _BaseDatabaseTestCase:
227
227
  for book in books:
228
228
  assert isinstance(book, Book)
229
229
 
230
+ async def test_aggregation(self):
231
+ # Insert Many
232
+ insert_count = await self._insert_many()
233
+
234
+ # Find All with aggregate
235
+ books = await Book.aggregate([])
236
+ _len = sum(1 for _ in books)
237
+
238
+ assert isinstance(books, list)
239
+
240
+ assert _len == insert_count
241
+ for book in books:
242
+ assert isinstance(book, dict)
243
+ assert {*book.keys()} == {'_id', 'name', 'author', 'pages_count'}
244
+
230
245
  # # # Count
231
246
  async def test_count_all(self):
232
247
  # Insert Many
@@ -502,6 +517,9 @@ class TestPantherDB(_BaseDatabaseTestCase, IsolatedAsyncioTestCase):
502
517
  def tearDown(self) -> None:
503
518
  Path(self.DB_PATH).unlink()
504
519
 
520
+ async def test_aggregation(self):
521
+ pass
522
+
505
523
 
506
524
  @pytest.mark.mongodb
507
525
  class TestMongoDB(_BaseDatabaseTestCase, IsolatedAsyncioTestCase):
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes