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
panther/panel/utils.py CHANGED
@@ -1,6 +1,96 @@
1
+ from collections import defaultdict
2
+
3
+ from panther.configs import config
1
4
  from panther.db.models import Model
2
5
 
3
6
 
7
+ def _ref_name(ref: str) -> str:
8
+ obj_name = ref.rsplit('/', maxsplit=1)[1]
9
+ return f'${obj_name}'
10
+
11
+
12
+ def clean_model_schema(schema: dict) -> dict:
13
+ """
14
+ Example:
15
+ {
16
+ 'title': 'Author',
17
+ '$': {
18
+ 'Book': {
19
+ 'title': 'Book',
20
+ 'fields': {
21
+ 'title': {'title': 'Title', 'type': ['string'], 'required': True},
22
+ 'pages_count': {'title': 'Pages Count', 'type': ['integer'], 'required': True},
23
+ 'readers': {'title': 'Readers', 'type': ['array', 'null'], 'items': '$Person', 'default': None, 'required': False},
24
+ 'co_owner': {'type': ['$Person', 'null'], 'default': None, 'required': False}
25
+ }
26
+ },
27
+ 'Parent': {
28
+ 'title': 'Parent',
29
+ 'fields': {
30
+ 'name': {'title': 'Name', 'type': ['string'], 'required': True},
31
+ 'age': {'title': 'Age', 'type': ['string'], 'required': True},
32
+ 'has_child': {'title': 'Has Child', 'type': ['boolean'], 'required': True}
33
+ }
34
+ },
35
+ 'Person': {
36
+ 'title': 'Person',
37
+ 'fields': {
38
+ 'age': {'title': 'Age', 'type': ['integer'], 'required': True},
39
+ 'real_name': {'title': 'Real Name', 'type': ['string'], 'required': True},
40
+ 'parent': {'type': '$Parent', 'required': True},
41
+ 'is_alive': {'title': 'Is Alive', 'type': ['boolean'], 'required': True},
42
+ 'friends': {'title': 'Friends', 'type': ['array'], 'items': '$Person', 'required': True}
43
+ }
44
+ }
45
+ },
46
+ 'fields': {
47
+ '_id': {'title': ' Id', 'type': ['string', 'null'], 'default': None, 'required': False},
48
+ 'name': {'title': 'Name', 'type': ['string'], 'required': True},
49
+ 'person': {'type': ['$Person', 'null'], 'default': None, 'required': False},
50
+ 'books': {'title': 'Books', 'type': ['array'], 'items': '$Book', 'required': True},
51
+ 'is_male': {'title': 'Is Male', 'type': ['boolean', 'null'], 'required': True}
52
+ }
53
+ }
54
+ """
55
+
56
+ result = defaultdict(dict)
57
+ result['title'] = schema['title']
58
+ if '$defs' in schema:
59
+ for sk, sv in schema['$defs'].items():
60
+ result['$'][sk] = clean_model_schema(sv)
61
+
62
+ for k, v in schema['properties'].items():
63
+ result['fields'][k] = {}
64
+ if 'title' in v:
65
+ result['fields'][k]['title'] = v['title']
66
+
67
+ if 'type' in v:
68
+ result['fields'][k]['type'] = [v['type']]
69
+
70
+ if 'anyOf' in v:
71
+ result['fields'][k]['type'] = [i['type'] if 'type' in i else _ref_name(i['$ref']) for i in v['anyOf']]
72
+ if 'array' in result['fields'][k]['type']:
73
+ # One of them was array, so add the `items` field
74
+ for t in v['anyOf']:
75
+ if 'items' in t:
76
+ result['fields'][k]['items'] = _ref_name(t['items']['$ref'])
77
+
78
+ if 'default' in v:
79
+ result['fields'][k]['default'] = v['default']
80
+
81
+ if '$ref' in v: # For obj
82
+ result['fields'][k]['type'] = _ref_name(v['$ref'])
83
+
84
+ if 'items' in v: # For array
85
+ result['fields'][k]['items'] = _ref_name(v['items']['$ref'])
86
+
87
+ result['fields'][k]['required'] = k in schema.get('required', [])
88
+
89
+ # Cast it to have a more clear stdout
90
+ return dict(result)
91
+
92
+
93
+ # TODO: Remove this
4
94
  def get_model_fields(model):
5
95
  result = {}
6
96
 
@@ -15,3 +105,11 @@ def get_model_fields(model):
15
105
  else:
16
106
  result[k] = getattr(v.annotation, '__name__', str(v.annotation))
17
107
  return result
108
+
109
+
110
+ def get_models():
111
+ return [{
112
+ 'index': i,
113
+ 'name': model.__name__,
114
+ 'module': model.__module__,
115
+ } for i, model in enumerate(config.MODELS)]
panther/panel/views.py ADDED
@@ -0,0 +1,143 @@
1
+ import logging
2
+
3
+ from panther import status
4
+ from panther.app import API, GenericAPI
5
+ from panther.authentications import JWTAuthentication, CookieJWTAuthentication
6
+ from panther.configs import config
7
+ from panther.db.models import BaseUser
8
+ from panther.exceptions import RedirectAPIError, AuthenticationAPIError
9
+ from panther.panel.middlewares import RedirectToSlashMiddleware
10
+ from panther.panel.utils import get_models, clean_model_schema
11
+ from panther.permissions import BasePermission
12
+ from panther.request import Request
13
+ from panther.response import TemplateResponse, Response, Cookie, RedirectResponse
14
+
15
+ logger = logging.getLogger('panther')
16
+
17
+
18
+ class AdminPanelPermission(BasePermission):
19
+ @classmethod
20
+ async def authorization(cls, request: Request) -> bool:
21
+ try: # We don't want to set AUTHENTICATION class, so we have to use permission classes
22
+ await CookieJWTAuthentication.authentication(request=request)
23
+ return True
24
+ except AuthenticationAPIError:
25
+ raise RedirectAPIError(url=f'login?redirect_to={request.path}')
26
+
27
+
28
+ class LoginView(GenericAPI):
29
+ middlewares = [RedirectToSlashMiddleware]
30
+
31
+ def get(self, request: Request):
32
+ return TemplateResponse(name='login.html')
33
+
34
+ async def post(self, request: Request):
35
+ user: BaseUser = await config.USER_MODEL.find_one({config.USER_MODEL.USERNAME_FIELD: request.data['username']})
36
+ if user is None:
37
+ logger.debug('User not found.')
38
+ return TemplateResponse(
39
+ name='login.html',
40
+ status_code=status.HTTP_400_BAD_REQUEST,
41
+ context={'error': 'Authentication Error'},
42
+ )
43
+ if user.check_password(password=request.data['password']) is False:
44
+ logger.debug('Password is incorrect.')
45
+ return TemplateResponse(
46
+ name='login.html',
47
+ status_code=status.HTTP_400_BAD_REQUEST,
48
+ context={'error': 'Authentication Error'},
49
+ )
50
+ tokens = JWTAuthentication.login(user.id)
51
+ return RedirectResponse(
52
+ url=request.query_params.get('redirect_to', '..'),
53
+ status_code=status.HTTP_302_FOUND,
54
+ set_cookies=[
55
+ Cookie(
56
+ key='access_token',
57
+ value=tokens['access_token'],
58
+ max_age=config.JWT_CONFIG.life_time
59
+ ),
60
+ Cookie(
61
+ key='refresh_token',
62
+ value=tokens['refresh_token'],
63
+ max_age=config.JWT_CONFIG.refresh_life_time
64
+ )
65
+ ]
66
+ )
67
+
68
+
69
+ class HomeView(GenericAPI):
70
+ permissions = [AdminPanelPermission]
71
+
72
+ def get(self):
73
+ return TemplateResponse(name='home.html', context={'tables': get_models()})
74
+
75
+
76
+ class TableView(GenericAPI):
77
+ permissions = [AdminPanelPermission]
78
+ middlewares = [RedirectToSlashMiddleware]
79
+
80
+ async def get(self, request: Request, index: int):
81
+ model = config.MODELS[index]
82
+ if data := await model.find():
83
+ data = data
84
+ else:
85
+ data = []
86
+
87
+ return TemplateResponse(
88
+ name='table.html',
89
+ context={
90
+ 'fields': clean_model_schema(model.schema()),
91
+ 'tables': get_models(),
92
+ 'records': Response.prepare_data(data),
93
+ }
94
+ )
95
+
96
+
97
+ class CreateView(GenericAPI):
98
+ permissions = [AdminPanelPermission]
99
+ middlewares = [RedirectToSlashMiddleware]
100
+
101
+ async def get(self, request: Request, index: int):
102
+ model = config.MODELS[index]
103
+ return TemplateResponse(
104
+ name='create.html',
105
+ context={
106
+ 'fields': clean_model_schema(model.schema()),
107
+ 'tables': get_models(),
108
+ }
109
+ )
110
+
111
+ async def post(self, request: Request, index: int):
112
+ model = config.MODELS[index]
113
+ validated_data = API.validate_input(model=model, request=request)
114
+ instance = await model.insert_one(validated_data.model_dump())
115
+ if issubclass(model, BaseUser):
116
+ await instance.set_password(password=instance.password)
117
+ return instance
118
+
119
+
120
+ class DetailView(GenericAPI):
121
+ permissions = [AdminPanelPermission]
122
+ middlewares = [RedirectToSlashMiddleware]
123
+
124
+ async def get(self, index: int, document_id: str):
125
+ model = config.MODELS[index]
126
+ obj = await model.find_one_or_raise(id=document_id)
127
+ return TemplateResponse(
128
+ name='detail.html',
129
+ context={
130
+ 'fields': clean_model_schema(model.schema()),
131
+ 'data': obj.model_dump()
132
+ }
133
+ )
134
+
135
+ async def put(self, request: Request, index: int, document_id: str):
136
+ model = config.MODELS[index]
137
+ validated_data = API.validate_input(model=model, request=request)
138
+ return await model.update_one({'id': document_id}, validated_data.model_dump())
139
+
140
+ async def delete(self, index: int, document_id: str):
141
+ model = config.MODELS[index]
142
+ await model.delete_one(id=document_id)
143
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
panther/request.py CHANGED
@@ -1,5 +1,6 @@
1
1
  import logging
2
2
  from typing import Literal, Callable
3
+ from urllib.parse import parse_qsl
3
4
 
4
5
  import orjson as json
5
6
 
@@ -26,6 +27,8 @@ class Request(BaseRequest):
26
27
  match (self.headers.content_type or '').split('; boundary='):
27
28
  case ['' | 'application/json']:
28
29
  self._data = json.loads(self.__body or b'{}')
30
+ case ['application/x-www-form-urlencoded']:
31
+ self._data = {k.decode(): v.decode() for k, v in parse_qsl(self.__body)}
29
32
  case ['multipart/form-data', boundary]:
30
33
  self._data = read_multipart_form_data(boundary=boundary, body=self.__body)
31
34
  case [unknown]:
panther/response.py CHANGED
@@ -1,7 +1,9 @@
1
1
  import asyncio
2
+ from dataclasses import dataclass
3
+ from http import cookies
2
4
  from sys import version_info
3
5
  from types import NoneType
4
- from typing import Generator, AsyncGenerator, Any, Type
6
+ from typing import Generator, AsyncGenerator, Any, Type, Literal
5
7
 
6
8
  if version_info >= (3, 11):
7
9
  from typing import LiteralString
@@ -10,7 +12,6 @@ else:
10
12
 
11
13
  LiteralString = TypeVar('LiteralString')
12
14
 
13
-
14
15
  import orjson as json
15
16
  from pydantic import BaseModel
16
17
 
@@ -19,7 +20,6 @@ from panther.configs import config
19
20
  from panther._utils import to_async_generator
20
21
  from panther.db.cursor import Cursor
21
22
  from pantherdb import Cursor as PantherDBCursor
22
- from panther.monitoring import Monitoring
23
23
  from panther.pagination import Pagination
24
24
 
25
25
  ResponseDataTypes = list | tuple | set | Cursor | PantherDBCursor | dict | int | float | str | bool | bytes | NoneType | Type[BaseModel]
@@ -27,30 +27,75 @@ IterableDataTypes = list | tuple | set | Cursor | PantherDBCursor
27
27
  StreamingDataTypes = Generator | AsyncGenerator
28
28
 
29
29
 
30
+ @dataclass(slots=True)
31
+ class Cookie:
32
+ """
33
+ path: [Optional] Indicates the path that must exist in the requested URL for the browser to send the Cookie header.
34
+ Default is `/`
35
+ domain: [Optional] Defines the host to which the cookie will be sent.
36
+ Default is the host of the current document URL, not including subdomains.
37
+ max_age: [Optional] Indicates the number of seconds until the cookie expires.
38
+ A zero or negative number will expire the cookie immediately.
39
+ secure: [Optional] Indicates that the cookie is sent to the server
40
+ only when a request is made with the https: scheme (except on localhost)
41
+ httponly: [Optional] Forbids JavaScript from accessing the cookie,
42
+ for example, through the `Document.cookie` property.
43
+ samesite: [Optional] Controls whether a cookie is sent with cross-site requests or not,
44
+ `lax` is the default behavior if not specified.
45
+ expires: [Deprecated] In HTTP version 1.1, `expires` was deprecated and replaced with the easier-to-use `max-age`
46
+ """
47
+ key: str
48
+ value: str
49
+ domain: str = None
50
+ max_age: int = None
51
+ secure: bool = False
52
+ httponly: bool = False
53
+ samesite: Literal['none', 'lax', 'strict'] = 'lax'
54
+ path: str = '/'
55
+
56
+
30
57
  class Response:
31
58
  content_type = 'application/json'
32
59
 
33
60
  def __init__(
34
61
  self,
35
62
  data: ResponseDataTypes = None,
36
- headers: dict | None = None,
37
63
  status_code: int = status.HTTP_200_OK,
64
+ headers: dict | None = None,
38
65
  pagination: Pagination | None = None,
66
+ set_cookies: list[Cookie] | None = None
39
67
  ):
40
68
  """
41
69
  :param data: should be an instance of ResponseDataTypes
42
- :param headers: should be dict of headers
43
70
  :param status_code: should be int
44
- :param pagination: instance of Pagination or None
71
+ :param headers: should be dict of headers
72
+ :param pagination: an instance of Pagination or None
45
73
  The `pagination.template()` method will be used
74
+ :param set_cookies: list of cookies you want to set on the client
75
+ Set the `max_age` to `0` if you want to delete a cookie
46
76
  """
47
- self.headers = headers or {}
77
+ headers = headers or {}
48
78
  self.pagination: Pagination | None = pagination
49
79
  if isinstance(data, Cursor):
50
80
  data = list(data)
51
81
  self.initial_data = data
52
82
  self.data = self.prepare_data(data=data)
53
83
  self.status_code = self.check_status_code(status_code=status_code)
84
+ self.cookies = None
85
+ if set_cookies:
86
+ c = cookies.SimpleCookie()
87
+ for cookie in set_cookies:
88
+ c[cookie.key] = cookie.value
89
+ c[cookie.key]['path'] = cookie.path
90
+ c[cookie.key]['secure'] = cookie.secure
91
+ c[cookie.key]['httponly'] = cookie.httponly
92
+ c[cookie.key]['samesite'] = cookie.samesite
93
+ if cookie.domain is not None:
94
+ c[cookie.key]['domain'] = cookie.domain
95
+ if cookie.max_age is not None:
96
+ c[cookie.key]['max-age'] = cookie.max_age
97
+ self.cookies = [(b'Set-Cookie', cookie.OutputString().encode()) for cookie in c.values()]
98
+ self.headers = headers
54
99
 
55
100
  @property
56
101
  def body(self) -> bytes:
@@ -70,26 +115,30 @@ class Response:
70
115
  } | self._headers
71
116
 
72
117
  @property
73
- def bytes_headers(self) -> list[list[bytes]]:
74
- return [[k.encode(), str(v).encode()] for k, v in (self.headers or {}).items()]
118
+ def bytes_headers(self) -> list[tuple[bytes]]:
119
+ result = [(k.encode(), str(v).encode()) for k, v in (self.headers or {}).items()]
120
+ if self.cookies:
121
+ result.extend(self.cookies)
122
+ return result
75
123
 
76
124
  @headers.setter
77
125
  def headers(self, headers: dict):
78
126
  self._headers = headers
79
127
 
80
- def prepare_data(self, data: Any):
128
+ @classmethod
129
+ def prepare_data(cls, data: Any):
81
130
  """Make sure the response data is only ResponseDataTypes or Iterable of ResponseDataTypes"""
82
131
  if isinstance(data, (int | float | str | bool | bytes | NoneType)):
83
132
  return data
84
133
 
85
134
  elif isinstance(data, dict):
86
- return {key: self.prepare_data(value) for key, value in data.items()}
135
+ return {key: cls.prepare_data(value) for key, value in data.items()}
87
136
 
88
137
  elif issubclass(type(data), BaseModel):
89
138
  return data.model_dump()
90
139
 
91
140
  elif isinstance(data, IterableDataTypes):
92
- return [self.prepare_data(d) for d in data]
141
+ return [cls.prepare_data(d) for d in data]
93
142
 
94
143
  else:
95
144
  msg = f'Invalid Response Type: {type(data)}'
@@ -102,51 +151,15 @@ class Response:
102
151
  raise TypeError(error)
103
152
  return status_code
104
153
 
105
- async def apply_output_model(self, output_model: Type[BaseModel]):
106
- """This method is called in API.__call__"""
107
-
108
- # Dict
109
- if isinstance(self.data, dict):
110
- # Apply `validation_alias` (id -> _id)
111
- for field_name, field in output_model.model_fields.items():
112
- if field.validation_alias and field_name in self.data:
113
- self.data[field.validation_alias] = self.data.pop(field_name)
114
- output = output_model(**self.data)
115
- if hasattr(output_model, 'prepare_response'):
116
- return await output.prepare_response(instance=self.initial_data, data=output.model_dump())
117
- return output.model_dump()
118
-
119
- # Iterable
120
- results = []
121
- if isinstance(self.data, IterableDataTypes):
122
- for i, d in enumerate(self.data):
123
- # Apply `validation_alias` (id -> _id)
124
- for field_name, field in output_model.model_fields.items():
125
- if field.validation_alias and field_name in d:
126
- d[field.validation_alias] = d.pop(field_name)
127
-
128
- output = output_model(**d)
129
- if hasattr(output_model, 'prepare_response'):
130
- result = await output.prepare_response(instance=self.initial_data[i], data=output.model_dump())
131
- else:
132
- result = output.model_dump()
133
- results.append(result)
134
- return results
135
-
136
- # Str | Bool | Bytes
137
- msg = 'Type of Response data is not match with `output_model`.\n*hint: You may want to remove `output_model`'
138
- raise TypeError(msg)
139
-
140
154
  async def send_headers(self, send, /):
141
155
  await send({'type': 'http.response.start', 'status': self.status_code, 'headers': self.bytes_headers})
142
156
 
143
157
  async def send_body(self, send, receive, /):
144
158
  await send({'type': 'http.response.body', 'body': self.body, 'more_body': False})
145
159
 
146
- async def send(self, send, receive, /, monitoring: Monitoring):
160
+ async def send(self, send, receive, /):
147
161
  await self.send_headers(send)
148
162
  await self.send_body(send, receive)
149
- await monitoring.after(self.status_code)
150
163
 
151
164
  def __str__(self):
152
165
  if len(data := str(self.data)) > 30:
@@ -228,23 +241,31 @@ class PlainTextResponse(Response):
228
241
 
229
242
 
230
243
  class TemplateResponse(HTMLResponse):
244
+ """
245
+ You may want to declare `TEMPLATES_DIR` in your configs
246
+
247
+ Example:
248
+ TEMPLATES_DIR = 'templates/'
249
+ or
250
+ TEMPLATES_DIR = '.'
251
+ """
231
252
  def __init__(
232
253
  self,
233
254
  source: str | LiteralString | NoneType = None,
234
- path: str | NoneType = None,
255
+ name: str | NoneType = None,
235
256
  context: dict | NoneType = None,
236
257
  headers: dict | NoneType = None,
237
258
  status_code: int = status.HTTP_200_OK,
238
259
  ):
239
260
  """
240
261
  :param source: should be a string
241
- :param path: should be path of template file
262
+ :param name: name of the template file (should be with its extension, e.g. index.html)
242
263
  :param context: should be dict of items
243
264
  :param headers: should be dict of headers
244
265
  :param status_code: should be int
245
266
  """
246
- if path:
247
- template = config.JINJA_ENVIRONMENT.get_template(name=path)
267
+ if name:
268
+ template = config.JINJA_ENVIRONMENT.get_template(name=name)
248
269
  else:
249
270
  template = config.JINJA_ENVIRONMENT.from_string(source=source)
250
271
  super().__init__(
@@ -252,3 +273,20 @@ class TemplateResponse(HTMLResponse):
252
273
  headers=headers,
253
274
  status_code=status_code,
254
275
  )
276
+
277
+
278
+ class RedirectResponse(Response):
279
+ def __init__(
280
+ self,
281
+ url: str,
282
+ headers: dict | None = None,
283
+ status_code: int = status.HTTP_307_TEMPORARY_REDIRECT,
284
+ set_cookies: list[Cookie] | None = None
285
+ ):
286
+ headers = headers or {}
287
+ headers['Location'] = url
288
+ super().__init__(
289
+ headers=headers,
290
+ status_code=status_code,
291
+ set_cookies=set_cookies,
292
+ )
panther/routings.py CHANGED
@@ -18,6 +18,11 @@ def _flattening_urls(data: dict | Callable, url: str = ''):
18
18
  url = f'{url}/'
19
19
 
20
20
  if isinstance(data, dict):
21
+ if data == {}:
22
+ # User didn't define any endpoint,
23
+ # So we just reserve this path so won't be used in path variables.
24
+ yield url.removeprefix('/'), {}
25
+
21
26
  for k, v in data.items():
22
27
  yield from _flattening_urls(v, f'{url}{k}')
23
28
  else:
@@ -144,8 +149,8 @@ def find_endpoint(path: str) -> tuple[Callable | None, str]:
144
149
  return found, '/'.join(found_path)
145
150
 
146
151
  # `found` is dict
147
- if isinstance(found, dict) and (endpoint := found.get('')):
148
- if callable(endpoint):
152
+ if isinstance(found, dict):
153
+ if (endpoint := found.get('')) and callable(endpoint):
149
154
  found_path.append(part)
150
155
  return endpoint, '/'.join(found_path)
151
156
  else:
panther/serializer.py CHANGED
@@ -195,7 +195,7 @@ class ModelSerializer(metaclass=MetaModelSerializer):
195
195
  https://pantherpy.github.io/serializer/#style-2-model-serializer
196
196
  Example:
197
197
  class PersonSerializer(ModelSerializer):
198
- class Meta:
198
+ class Config:
199
199
  model = Person
200
200
  fields = '*'
201
201
  exclude = ['created_date'] # Optional
panther/utils.py CHANGED
@@ -1,10 +1,11 @@
1
+ import asyncio
1
2
  import base64
2
3
  import hashlib
3
4
  import logging
4
5
  import os
5
- import secrets
6
- from datetime import datetime, timedelta, timezone
6
+ from datetime import datetime, timedelta
7
7
  from pathlib import Path
8
+ from threading import Thread
8
9
  from typing import ClassVar
9
10
 
10
11
  import pytz
@@ -38,7 +39,10 @@ def load_env(env_file: str | Path, /) -> dict[str, str]:
38
39
  key, value = striped_line.split('=', 1)
39
40
  key = key.strip()
40
41
  value = value.strip().strip('"\'')
41
- variables[key] = value
42
+ if (boolean_value := value.lower()) in ['true', 'false']:
43
+ variables[key] = bool(boolean_value == 'true')
44
+ else:
45
+ variables[key] = value
42
46
 
43
47
  # Load them as system environment variable
44
48
  os.environ[key] = value
@@ -101,27 +105,31 @@ def scrypt(password: str, salt: bytes, digest: bool = False) -> str | bytes:
101
105
  return derived_key
102
106
 
103
107
 
104
- class ULID:
105
- """https://github.com/ulid/spec"""
106
-
107
- crockford_base32_characters = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
108
-
109
- @classmethod
110
- def new(cls):
111
- current_timestamp = int(datetime.now(timezone.utc).timestamp() * 1000)
112
- epoch_bits = '{0:050b}'.format(current_timestamp)
113
- random_bits = '{0:080b}'.format(secrets.randbits(80))
114
- bits = epoch_bits + random_bits
115
- return cls._generate(bits)
116
-
117
- @classmethod
118
- def _generate(cls, bits: str) -> str:
119
- return ''.join(
120
- cls.crockford_base32_characters[int(bits[i: i + 5], base=2)]
121
- for i in range(0, 130, 5)
122
- )
123
-
124
-
125
108
  def timezone_now():
126
- tz = pytz.timezone(config.TIMEZONE)
127
- return datetime.now(tz=tz)
109
+ return datetime.now(tz=pytz.timezone(config.TIMEZONE))
110
+
111
+
112
+ def run_coroutine(coroutine):
113
+ try:
114
+ # Check if there's an event loop already running in this thread
115
+ asyncio.get_running_loop()
116
+ except RuntimeError:
117
+ # No event loop is running in this thread — safe to use asyncio.run
118
+ return asyncio.run(coroutine)
119
+
120
+ # Since we cannot block a running event loop with run_until_complete,
121
+ # we execute the coroutine in a separate thread with its own event loop.
122
+ result = []
123
+
124
+ def run_in_thread():
125
+ new_loop = asyncio.new_event_loop()
126
+ asyncio.set_event_loop(new_loop)
127
+ try:
128
+ result.append(new_loop.run_until_complete(coroutine))
129
+ finally:
130
+ new_loop.close()
131
+
132
+ thread = Thread(target=run_in_thread)
133
+ thread.start()
134
+ thread.join()
135
+ return result[0]
panther/websocket.py CHANGED
@@ -9,6 +9,9 @@ class GenericWebsocket(Websocket):
9
9
  auth: bool = False
10
10
  permissions: list = []
11
11
 
12
+ def __init__(self, parent):
13
+ self.__dict__ = parent.__dict__.copy()
14
+
12
15
  async def connect(self, **kwargs):
13
16
  """
14
17
  Check your conditions then `accept()` or `close()` the connection