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
panther/base_websocket.py CHANGED
@@ -1,18 +1,19 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
- import orjson as json
4
+ import logging
5
5
  from multiprocessing.managers import SyncManager
6
6
  from typing import TYPE_CHECKING, Literal
7
7
 
8
- import logging
8
+ import orjson as json
9
+ import ulid
10
+
9
11
  from panther import status
10
12
  from panther.base_request import BaseRequest
11
13
  from panther.configs import config
12
14
  from panther.db.connections import redis
13
- from panther.exceptions import AuthenticationAPIError, InvalidPathVariableAPIError
14
- from panther.monitoring import Monitoring
15
- from panther.utils import Singleton, ULID
15
+ from panther.exceptions import InvalidPathVariableAPIError, BaseError
16
+ from panther.utils import Singleton
16
17
 
17
18
  if TYPE_CHECKING:
18
19
  from redis.asyncio import Redis
@@ -108,7 +109,7 @@ class WebsocketConnections(Singleton):
108
109
  else:
109
110
  self.pubsub.publish(publish_data)
110
111
 
111
- async def listen(self, connection: Websocket) -> None:
112
+ async def listen(self, connection: Websocket):
112
113
  # 1. Authentication
113
114
  if not connection.is_rejected:
114
115
  await self.handle_authentication(connection=connection)
@@ -125,7 +126,7 @@ class WebsocketConnections(Singleton):
125
126
  try:
126
127
  kwargs = connection.clean_parameters(connection.connect)
127
128
  except InvalidPathVariableAPIError as e:
128
- connection.log(e.detail)
129
+ connection.change_state(state='Rejected', message=e.detail)
129
130
  return await connection.close()
130
131
 
131
132
  # 4. Connect To Endpoint
@@ -139,6 +140,8 @@ class WebsocketConnections(Singleton):
139
140
  # 6. Listen Connection
140
141
  await self.listen_connection(connection=connection)
141
142
 
143
+ return connection
144
+
142
145
  async def listen_connection(self, connection: Websocket):
143
146
  while True:
144
147
  response = await connection.asgi_receive()
@@ -146,7 +149,7 @@ class WebsocketConnections(Singleton):
146
149
  continue
147
150
 
148
151
  if response['type'] == 'websocket.disconnect':
149
- # Connect has to be closed by the client
152
+ # Connection has to be closed by the client.
150
153
  await self.connection_closed(connection=connection)
151
154
  break
152
155
 
@@ -157,25 +160,20 @@ class WebsocketConnections(Singleton):
157
160
 
158
161
  async def connection_accepted(self, connection: Websocket) -> None:
159
162
  # Generate ConnectionID
160
- connection._connection_id = ULID.new()
163
+ connection._connection_id = ulid.new()
161
164
 
162
165
  # Save Connection
163
166
  self.connections[connection.connection_id] = connection
164
-
165
- # Logs
166
- await connection.monitoring.after('Accepted')
167
- connection.log(f'Accepted {connection.connection_id}')
167
+ connection.change_state(state='Accepted')
168
168
 
169
169
  async def connection_closed(self, connection: Websocket, from_server: bool = False) -> None:
170
170
  if connection.is_connected:
171
171
  del self.connections[connection.connection_id]
172
- await connection.monitoring.after('Closed')
173
- connection.log(f'Closed {connection.connection_id}')
172
+ connection.change_state(state='Closed')
174
173
  connection._connection_id = ''
175
174
 
176
175
  elif connection.is_rejected is False and from_server is True:
177
- await connection.monitoring.after('Rejected')
178
- connection.log('Rejected')
176
+ connection.change_state(state='Rejected')
179
177
  connection._is_rejected = True
180
178
 
181
179
  async def start(self):
@@ -203,8 +201,8 @@ class WebsocketConnections(Singleton):
203
201
  else:
204
202
  try:
205
203
  connection.user = await config.WS_AUTHENTICATION.authentication(connection)
206
- except AuthenticationAPIError as e:
207
- connection.log(e.detail)
204
+ except BaseError as e:
205
+ connection.change_state(state='Rejected', message=e.detail)
208
206
  await connection.close()
209
207
 
210
208
  @classmethod
@@ -215,16 +213,16 @@ class WebsocketConnections(Singleton):
215
213
  logger.critical(f'{perm.__name__}.authorization should be "classmethod"')
216
214
  await connection.close()
217
215
  elif await perm.authorization(connection) is False:
218
- connection.log('Permission Denied')
216
+ connection.change_state(state='Rejected', message='Permission Denied')
219
217
  await connection.close()
220
218
 
221
219
 
222
220
  class Websocket(BaseRequest):
223
221
  auth: bool = False
224
222
  permissions: list = []
223
+ state: str = 'Connected'
225
224
  _connection_id: str = ''
226
225
  _is_rejected: bool = False
227
- _monitoring: Monitoring
228
226
 
229
227
  def __init_subclass__(cls, **kwargs):
230
228
  if cls.__module__ != 'panther.websocket':
@@ -274,9 +272,10 @@ class Websocket(BaseRequest):
274
272
  def is_rejected(self) -> bool:
275
273
  return self._is_rejected
276
274
 
277
- @property
278
- def monitoring(self) -> Monitoring:
279
- return self._monitoring
280
-
281
- def log(self, message: str):
282
- logger.debug(f'WS {self.path} --> {message}')
275
+ def change_state(self, state: Literal['Accepted', 'Closed', 'Rejected'], message: str = ''):
276
+ self.state = state
277
+ if message:
278
+ message = f' | {message}'
279
+ if self.is_connected:
280
+ message = f' | {self.connection_id}{message}'
281
+ logger.debug(f'WS {self.path} --> {state}{message}')
@@ -90,6 +90,7 @@ class CreateProject:
90
90
  'field': 'log_queries',
91
91
  'message': 'Do You Want To Log Queries',
92
92
  'is_boolean': True,
93
+ 'condition': "self.database != '2'"
93
94
  },
94
95
  {
95
96
  'field': 'auto_reformat',
panther/cli/main.py CHANGED
@@ -1,40 +1,32 @@
1
- import os
1
+ import logging
2
2
  import sys
3
3
 
4
4
  from panther import version as panther_version
5
5
  from panther.cli.create_command import create
6
6
  from panther.cli.monitor_command import monitor
7
7
  from panther.cli.run_command import run
8
- from panther.cli.utils import cli_error, cli_info, cli_warning, print_help_message
8
+ from panther.cli.utils import cli_error, print_help_message
9
9
 
10
+ logger = logging.getLogger('panther')
10
11
 
11
- def shell(args: list) -> None:
12
+
13
+ def shell(args) -> None:
12
14
  if len(args) == 0:
13
- cli_info('You may want to use "bpython" or "ipython" for better interactive shell')
14
- os.system('python')
15
+ return cli_error(
16
+ 'Not Enough Arguments, Give me a file path that contains `Panther()` app.\n'
17
+ ' * Make sure to run `panther shell` in the same directory as that file!\n'
18
+ ' * Example: `panther shell main.py`'
19
+ )
15
20
  elif len(args) != 1:
16
21
  return cli_error('Too Many Arguments.')
17
- shell_type = args[0].lower()
18
- if shell_type not in ['ipython', 'bpython']:
19
- return cli_error(f'"{shell_type}" Is Not Supported.')
20
-
21
- # Bpython
22
- if shell_type == 'bpython':
23
- try:
24
- import bpython
25
- os.system('bpython')
26
- except ImportError as e:
27
- cli_warning(e, 'Hint: "pip install bpython"')
28
- os.system('python')
29
-
30
- # Ipython
31
- elif shell_type == 'ipython':
32
- try:
33
- import IPython
34
- os.system('ipython')
35
- except ImportError as e:
36
- cli_warning(e, 'Hint: "pip install ipython"')
37
- os.system('python')
22
+
23
+ package = args[0].removesuffix('.py')
24
+ try:
25
+ from IPython import start_ipython
26
+
27
+ start_ipython(('--gui', 'asyncio', '-c', f'"import {package}"', '-i'))
28
+ except ImportError:
29
+ logger.error('Make sure `ipython` is installed -> Hint: `pip install ipython`')
38
30
 
39
31
 
40
32
  def version() -> None:
@@ -54,7 +46,7 @@ def start() -> None:
54
46
  shell(args)
55
47
  case 'monitor':
56
48
  monitor()
57
- case 'version':
49
+ case 'version' | '--version':
58
50
  version()
59
51
  case _:
60
52
  cli_error('Invalid Arguments.')
@@ -15,6 +15,7 @@ from rich.table import Table
15
15
 
16
16
  from panther.cli.utils import import_error
17
17
  from panther.configs import config
18
+ from panther.middlewares.monitoring import WebsocketMonitoringMiddleware
18
19
 
19
20
  with contextlib.suppress(ImportError):
20
21
  from watchfiles import watch
@@ -53,7 +54,7 @@ class Monitoring:
53
54
  for line in f.readlines():
54
55
  # line = date_time | method | path | ip:port | response_time(seconds) | status
55
56
  columns = line.split('|')
56
- columns[4] = self._clean_response_time(float(columns[4]))
57
+ columns[4] = self._clean_response_time(columns[4])
57
58
  self.rows.append(columns)
58
59
  live.update(self.generate_table())
59
60
 
@@ -66,7 +67,7 @@ class Monitoring:
66
67
 
67
68
  # Check log file
68
69
  if not self.monitoring_log_file.exists():
69
- return f'`{self.monitoring_log_file}` file not found. (Make sure `MONITORING` is `True` in `configs` and you have at least one record)'
70
+ return f'`{self.monitoring_log_file}` file not found. (Make sure `panther.middlewares.monitoring.MonitoringMiddleware` is in your `MIDDLEWARES`)'
70
71
 
71
72
  # Initialize Deque
72
73
  self.update_rows()
@@ -104,14 +105,17 @@ class Monitoring:
104
105
  self.rows = deque(self.rows, maxlen=lines)
105
106
 
106
107
  @classmethod
107
- def _clean_response_time(cls, response_time: int) -> str:
108
+ def _clean_response_time(cls, response_time: str) -> str:
109
+ if response_time == WebsocketMonitoringMiddleware.ConnectedConnectionTime:
110
+ return response_time
111
+ response_time = float(response_time)
108
112
  time_unit = ' s'
109
113
 
110
114
  if response_time < 0.01:
111
115
  response_time = response_time * 1_000
112
116
  time_unit = 'ms'
113
117
 
114
- elif response_time >= 10:
118
+ elif response_time >= 60:
115
119
  response_time = response_time / 60
116
120
  time_unit = ' m'
117
121
 
panther/cli/template.py CHANGED
@@ -63,7 +63,11 @@ from panther.utils import load_env
63
63
  BASE_DIR = Path(__name__).resolve().parent
64
64
  env = load_env(BASE_DIR / '.env')
65
65
 
66
- SECRET_KEY = env['SECRET_KEY']{DATABASE}{REDIS}{USER_MODEL}{AUTHENTICATION}{MONITORING}{LOG_QUERIES}{AUTO_REFORMAT}
66
+ SECRET_KEY = env['SECRET_KEY']{DATABASE}{REDIS}{USER_MODEL}{AUTHENTICATION}{LOG_QUERIES}{AUTO_REFORMAT}
67
+
68
+ MIDDLEWARES = [
69
+ {MONITORING}
70
+ ]
67
71
 
68
72
  # More Info: https://PantherPy.GitHub.io/urls/
69
73
  URLs = 'core.urls.url_routing'
@@ -134,7 +138,11 @@ from panther.utils import load_env, timezone_now
134
138
  BASE_DIR = Path(__name__).resolve().parent
135
139
  env = load_env(BASE_DIR / '.env')
136
140
 
137
- SECRET_KEY = env['SECRET_KEY']{DATABASE}{REDIS}{USER_MODEL}{AUTHENTICATION}{MONITORING}{LOG_QUERIES}{AUTO_REFORMAT}
141
+ SECRET_KEY = env['SECRET_KEY']{DATABASE}{REDIS}{USER_MODEL}{AUTHENTICATION}{LOG_QUERIES}{AUTO_REFORMAT}
142
+
143
+ MIDDLEWARES = [
144
+ {MONITORING}
145
+ ]
138
146
 
139
147
  InfoThrottling = Throttling(rate=5, duration=timedelta(minutes=1))
140
148
 
@@ -216,10 +224,7 @@ AUTHENTICATION_PART = """
216
224
  # More Info: https://PantherPy.GitHub.io/authentications/
217
225
  AUTHENTICATION = 'panther.authentications.JWTAuthentication'"""
218
226
 
219
- MONITORING_PART = """
220
-
221
- # More Info: https://PantherPy.GitHub.io/monitoring/
222
- MONITORING = True"""
227
+ MONITORING_PART = """'panther.middlewares.monitoring.MonitoringMiddleware'"""
223
228
 
224
229
  LOG_QUERIES_PART = """
225
230
 
panther/cli/utils.py CHANGED
@@ -48,13 +48,14 @@ help_message = f"""{logo}
48
48
  {h} - panther run [--reload | --help] {h}
49
49
  {h} Run your project with uvicorn {h}
50
50
  {h} {h}
51
- {h} - panther shell [ bpython | ipython ] {h}
51
+ {h} - panther shell <application file path> {h}
52
52
  {h} Run interactive python shell {h}
53
+ {h} * Example: `panther shell main.py` {h}
53
54
  {h} {h}
54
55
  {h} - panther monitor {h}
55
56
  {h} Show the monitor :) {h}
56
57
  {h} {h}
57
- {h} - panther version {h}
58
+ {h} - panther version | --version {h}
58
59
  {h} Print the current version of Panther {h}
59
60
  {h} {h}
60
61
  {h} - panther h | help | --help | -h {h}
panther/configs.py CHANGED
@@ -6,11 +6,9 @@ from pathlib import Path
6
6
  from typing import Callable
7
7
 
8
8
  import jinja2
9
- from pydantic._internal._model_construction import ModelMetaclass
10
-
9
+ from pydantic import BaseModel as PydanticBaseModel
11
10
  from panther.throttling import Throttling
12
11
 
13
-
14
12
  class JWTConfig:
15
13
  def __init__(
16
14
  self,
@@ -53,13 +51,13 @@ class Config:
53
51
  DEFAULT_CACHE_EXP: timedelta | None
54
52
  THROTTLING: Throttling | None
55
53
  SECRET_KEY: bytes | None
56
- HTTP_MIDDLEWARES: list[tuple]
57
- WS_MIDDLEWARES: list[tuple]
58
- USER_MODEL: ModelMetaclass | None
59
- AUTHENTICATION: ModelMetaclass | None
60
- WS_AUTHENTICATION: ModelMetaclass | None
54
+ HTTP_MIDDLEWARES: list
55
+ WS_MIDDLEWARES: list
56
+ USER_MODEL: type[PydanticBaseModel] | None # type: type[panther.db.Model]
57
+ AUTHENTICATION: type[PydanticBaseModel] | None
58
+ WS_AUTHENTICATION: type[PydanticBaseModel] | None
61
59
  JWT_CONFIG: JWTConfig | None
62
- MODELS: list[dict]
60
+ MODELS: list[type[PydanticBaseModel]] # type: type[panther.db.Model]
63
61
  FLAT_URLS: dict
64
62
  URLS: dict
65
63
  WEBSOCKET_CONNECTIONS: typing.Callable | None
panther/db/cursor.py CHANGED
@@ -2,6 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  from sys import version_info
4
4
 
5
+ from panther.utils import run_coroutine
6
+
5
7
  try:
6
8
  from pymongo.cursor import Cursor as _Cursor
7
9
  except ImportError:
@@ -31,13 +33,27 @@ class Cursor(_Cursor):
31
33
  self.cls = self.models[collection.name]
32
34
  super().__init__(collection, *args, **kwargs)
33
35
 
34
- def next(self) -> Self:
35
- return self.cls._create_model_instance(document=super().next())
36
+ def __aiter__(self) -> Self:
37
+ return self
38
+
39
+ async def next(self) -> Self:
40
+ return await self.cls._create_model_instance(document=super().next())
41
+
42
+ async def __anext__(self) -> Self:
43
+ try:
44
+ return await self.cls._create_model_instance(document=super().next())
45
+ except StopIteration:
46
+ raise StopAsyncIteration
47
+
48
+ def __next__(self) -> Self:
49
+ try:
50
+ return run_coroutine(self.cls._create_model_instance(document=super().next()))
51
+ except StopIteration:
52
+ raise
36
53
 
37
- __next__ = next
38
54
 
39
55
  def __getitem__(self, index: int | slice) -> Cursor[Self] | Self:
40
- result = super().__getitem__(index)
41
- if isinstance(result, dict):
42
- return self.cls._create_model_instance(document=result)
43
- return result
56
+ document = super().__getitem__(index)
57
+ if isinstance(document, dict):
58
+ return run_coroutine(self.cls._create_model_instance(document=document))
59
+ return document
panther/db/models.py CHANGED
@@ -1,7 +1,7 @@
1
1
  import contextlib
2
2
  import os
3
3
  from datetime import datetime
4
- from typing import Annotated
4
+ from typing import Annotated, ClassVar
5
5
 
6
6
  from pydantic import Field, WrapValidator, PlainSerializer, BaseModel as PydanticBaseModel
7
7
 
@@ -15,16 +15,17 @@ with contextlib.suppress(ImportError):
15
15
 
16
16
 
17
17
  def validate_object_id(value, handler):
18
- if config.DATABASE.__class__.__name__ == 'MongoDBConnection':
19
- if isinstance(value, bson.ObjectId):
20
- return value
21
- else:
22
- try:
23
- return bson.ObjectId(value)
24
- except Exception as e:
25
- msg = 'Invalid ObjectId'
26
- raise ValueError(msg) from e
27
- return str(value)
18
+ if config.DATABASE.__class__.__name__ != 'MongoDBConnection':
19
+ return str(value)
20
+
21
+ if isinstance(value, bson.ObjectId):
22
+ return value
23
+
24
+ try:
25
+ return bson.ObjectId(value)
26
+ except Exception as e:
27
+ msg = 'Invalid ObjectId'
28
+ raise ValueError(msg) from e
28
29
 
29
30
 
30
31
  ID = Annotated[str, WrapValidator(validate_object_id), PlainSerializer(lambda x: str(x), return_type=str)]
@@ -36,23 +37,28 @@ class Model(PydanticBaseModel, Query):
36
37
  return
37
38
  config.MODELS.append(cls)
38
39
 
39
- id: ID | None = Field(None, validation_alias='_id')
40
+ id: ID | None = Field(None, validation_alias='_id', alias='_id')
40
41
 
41
42
  @property
42
43
  def _id(self):
43
44
  """
44
- return
45
- `str` for PantherDB
46
- `ObjectId` for MongoDB
45
+ Returns the actual ID value:
46
+ - For MongoDB: returns ObjectId
47
+ - For PantherDB: returns str
47
48
  """
49
+ if config.DATABASE.__class__.__name__ == 'MongoDBConnection':
50
+ return bson.ObjectId(self.id)
48
51
  return self.id
49
52
 
50
53
 
51
54
  class BaseUser(Model):
55
+ username: str
52
56
  password: str = Field('', max_length=64)
53
57
  last_login: datetime | None = None
54
58
  date_created: datetime | None = Field(default_factory=timezone_now)
55
59
 
60
+ USERNAME_FIELD: ClassVar = 'username'
61
+
56
62
  async def update_last_login(self) -> None:
57
63
  await self.update(last_login=timezone_now())
58
64
 
@@ -63,7 +69,7 @@ class BaseUser(Model):
63
69
  async def logout(self) -> dict:
64
70
  return await config.AUTHENTICATION.logout(self._auth_token)
65
71
 
66
- def set_password(self, password: str):
72
+ async def set_password(self, password: str):
67
73
  """
68
74
  URANDOM_SIZE = 16 char -->
69
75
  salt = 16 bytes
@@ -73,12 +79,13 @@ class BaseUser(Model):
73
79
  salt = os.urandom(URANDOM_SIZE)
74
80
  derived_key = scrypt(password=password, salt=salt, digest=True)
75
81
 
76
- self.password = f'{salt.hex()}{derived_key}'
82
+ hashed_password = f'{salt.hex()}{derived_key}'
83
+ await self.update(password=hashed_password)
77
84
 
78
- def check_password(self, new_password: str) -> bool:
85
+ def check_password(self, password: str) -> bool:
79
86
  size = URANDOM_SIZE * 2
80
87
  salt = self.password[:size]
81
88
  stored_hash = self.password[size:]
82
- derived_key = scrypt(password=new_password, salt=bytes.fromhex(salt), digest=True)
89
+ derived_key = scrypt(password=password, salt=bytes.fromhex(salt), digest=True)
83
90
 
84
91
  return derived_key == stored_hash
@@ -46,7 +46,7 @@ class BaseQuery:
46
46
  raise DatabaseError(error)
47
47
 
48
48
  @classmethod
49
- def _create_model_instance(cls, document: dict):
49
+ async def _create_model_instance(cls, document: dict):
50
50
  """Prevent getting errors from document insertion"""
51
51
  try:
52
52
  return cls(**document)