panther 3.7.0__py3-none-any.whl → 3.8.1__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.
panther/__init__.py CHANGED
@@ -1,6 +1,6 @@
1
1
  from panther.main import Panther # noqa: F401
2
2
 
3
- __version__ = '3.7.0'
3
+ __version__ = '3.8.1'
4
4
 
5
5
 
6
6
  def version():
panther/base_websocket.py CHANGED
@@ -3,7 +3,8 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import contextlib
5
5
  import logging
6
- from typing import TYPE_CHECKING
6
+ from multiprocessing import Manager
7
+ from typing import TYPE_CHECKING, Literal
7
8
 
8
9
  import orjson as json
9
10
 
@@ -11,19 +12,35 @@ from panther import status
11
12
  from panther._utils import generate_ws_connection_id
12
13
  from panther.base_request import BaseRequest
13
14
  from panther.configs import config
15
+ from panther.db.connection import redis
14
16
  from panther.utils import Singleton
15
17
 
16
18
  if TYPE_CHECKING:
17
19
  from redis import Redis
18
20
 
19
-
20
21
  logger = logging.getLogger('panther')
21
22
 
22
23
 
24
+ class PubSub:
25
+ def __init__(self, manager):
26
+ self._manager = manager
27
+ self._subscribers = self._manager.list()
28
+
29
+ def subscribe(self):
30
+ queue = self._manager.Queue()
31
+ self._subscribers.append(queue)
32
+ return queue
33
+
34
+ def publish(self, msg):
35
+ for queue in self._subscribers:
36
+ queue.put(msg)
37
+
38
+
23
39
  class WebsocketConnections(Singleton):
24
- def __init__(self):
40
+ def __init__(self, manager: Manager = None):
25
41
  self.connections = {}
26
42
  self.connections_count = 0
43
+ self.manager = manager
27
44
 
28
45
  def __call__(self, r: Redis | None):
29
46
  if r:
@@ -31,39 +48,61 @@ class WebsocketConnections(Singleton):
31
48
  subscriber.subscribe('websocket_connections')
32
49
  logger.info("Subscribed to 'websocket_connections' channel")
33
50
  for channel_data in subscriber.listen():
34
- # Check Type of PubSub Message
35
51
  match channel_data['type']:
52
+ # Subscribed
36
53
  case 'subscribe':
37
54
  continue
38
55
 
56
+ # Message Received
39
57
  case 'message':
40
58
  loaded_data = json.loads(channel_data['data'].decode())
41
- if (
42
- isinstance(loaded_data, dict)
43
- and (connection_id := loaded_data.get('connection_id'))
44
- and (data := loaded_data.get('data'))
45
- and (action := loaded_data.get('action'))
46
- and (connection := self.connections.get(connection_id))
47
- ):
48
- # Check Action of WS
49
- match action:
50
- case 'send':
51
- logger.debug(f'Sending Message to {connection_id}')
52
- asyncio.run(connection.send(data=data))
53
- case 'close':
54
- with contextlib.suppress(RuntimeError):
55
- asyncio.run(connection.close(code=data['code'], reason=data['reason']))
56
- # We are trying to disconnect the connection between a thread and a user
57
- # from another thread, it's working, but we have to find another solution it
58
- #
59
- # Error:
60
- # Task <Task pending coro=<Websocket.close()>> got Future
61
- # <Task pending coro=<WebSocketCommonProtocol.transfer_data()>>
62
- # attached to a different loop
63
- case _:
64
- logger.debug(f'Unknown Message Action: {action}')
65
- case _:
66
- logger.debug(f'Unknown Channel Type: {channel_data["type"]}')
59
+ self._handle_received_message(received_message=loaded_data)
60
+
61
+ case unknown_type:
62
+ logger.debug(f'Unknown Channel Type: {unknown_type}')
63
+ else:
64
+ self.pubsub = PubSub(manager=self.manager)
65
+ queue = self.pubsub.subscribe()
66
+ logger.info("Subscribed to 'websocket_connections' queue")
67
+ while True:
68
+ received_message = queue.get()
69
+ self._handle_received_message(received_message=received_message)
70
+
71
+ def _handle_received_message(self, received_message):
72
+ if (
73
+ isinstance(received_message, dict)
74
+ and (connection_id := received_message.get('connection_id'))
75
+ and connection_id in self.connections
76
+ and 'action' in received_message
77
+ and 'data' in received_message
78
+ ):
79
+ # Check Action of WS
80
+ match received_message['action']:
81
+ case 'send':
82
+ asyncio.run(self.connections[connection_id].send(data=received_message['data']))
83
+ case 'close':
84
+ with contextlib.suppress(RuntimeError):
85
+ asyncio.run(self.connections[connection_id].close(
86
+ code=received_message['data']['code'],
87
+ reason=received_message['data']['reason']
88
+ ))
89
+ # We are trying to disconnect the connection between a thread and a user
90
+ # from another thread, it's working, but we have to find another solution for it
91
+ #
92
+ # Error:
93
+ # Task <Task pending coro=<Websocket.close()>> got Future
94
+ # <Task pending coro=<WebSocketCommonProtocol.transfer_data()>>
95
+ # attached to a different loop
96
+ case unknown_action:
97
+ logger.debug(f'Unknown Message Action: {unknown_action}')
98
+
99
+ def publish(self, connection_id: str, action: Literal['send', 'close'], data: any):
100
+ publish_data = {'connection_id': connection_id, 'action': action, 'data': data}
101
+
102
+ if redis.is_connected:
103
+ redis.publish('websocket_connections', json.dumps(publish_data))
104
+ else:
105
+ self.pubsub.publish(publish_data)
67
106
 
68
107
  async def new_connection(self, connection: Websocket) -> None:
69
108
  await connection.connect(**connection.path_variables)
@@ -106,6 +145,7 @@ class Websocket(BaseRequest):
106
145
  pass
107
146
 
108
147
  async def send(self, data: any = None) -> None:
148
+ logger.debug(f'Sending WS Message to {self.connection_id}')
109
149
  if data:
110
150
  if isinstance(data, bytes):
111
151
  await self.send_bytes(bytes_data=data)
@@ -121,6 +161,7 @@ class Websocket(BaseRequest):
121
161
  await self.asgi_send({'type': 'websocket.send', 'bytes': bytes_data})
122
162
 
123
163
  async def close(self, code: int = status.WS_1000_NORMAL_CLOSURE, reason: str = '') -> None:
164
+ logger.debug(f'Closing WS Connection {self.connection_id}')
124
165
  self.is_connected = False
125
166
  config['websocket_connections'].remove_connection(self)
126
167
  await self.asgi_send({'type': 'websocket.close', 'code': code, 'reason': reason})
panther/cli/template.py CHANGED
@@ -33,7 +33,7 @@ async def info_api(request: Request):
33
33
  models_py = """from panther.db import Model
34
34
  """
35
35
 
36
- serializers_py = """from pydantic import BaseModel
36
+ serializers_py = """from panther.serializer import ModelSerializer
37
37
  """
38
38
 
39
39
  throttling_py = """from datetime import timedelta
@@ -70,8 +70,7 @@ SECRET_KEY = env['SECRET_KEY']{DATABASE}{USER_MODEL}{AUTHENTICATION}{MONITORING}
70
70
  URLs = 'core.urls.url_routing'
71
71
  """ % datetime.now().date().isoformat()
72
72
 
73
- env = """
74
- SECRET_KEY = '%s'
73
+ env = """SECRET_KEY='%s'
75
74
  """ % generate_secret_key()
76
75
 
77
76
  main_py = """from panther import Panther
panther/db/connection.py CHANGED
@@ -54,12 +54,15 @@ class DBSession(Singleton):
54
54
  self._session: Database = self._client.get_database()
55
55
 
56
56
  def _create_pantherdb_session(self, db_url: str) -> None:
57
+ params = {'db_name': db_url, 'return_dict': True}
57
58
  if config['pantherdb_encryption']:
58
59
  try:
59
60
  import cryptography
60
61
  except ModuleNotFoundError as e:
61
62
  import_error(e, package='cryptography')
62
- self._session: PantherDB = PantherDB(db_url, return_dict=True, secret_key=config['secret_key'])
63
+ else:
64
+ params['secret_key'] = config['secret_key']
65
+ self._session: PantherDB = PantherDB(**params)
63
66
 
64
67
  def close(self) -> None:
65
68
  if self._db_name == 'mongodb':
panther/main.py CHANGED
@@ -5,6 +5,7 @@ import sys
5
5
  import types
6
6
  from collections.abc import Callable
7
7
  from logging.config import dictConfig
8
+ from multiprocessing import Manager
8
9
  from pathlib import Path
9
10
  from threading import Thread
10
11
 
@@ -52,14 +53,6 @@ class Panther:
52
53
  # Print Info
53
54
  print_info(config)
54
55
 
55
- # Start Websocket Listener (Redis Required)
56
- if config['has_ws']:
57
- Thread(
58
- target=config['websocket_connections'],
59
- daemon=True,
60
- args=(self.ws_redis_connection,),
61
- ).start()
62
-
63
56
  def load_configs(self) -> None:
64
57
 
65
58
  # Check & Read The Configs File
@@ -98,8 +91,7 @@ class Panther:
98
91
  self._create_ws_connections_instance()
99
92
 
100
93
  def _create_ws_connections_instance(self):
101
- from panther.base_websocket import Websocket
102
- from panther.websocket import WebsocketConnections
94
+ from panther.base_websocket import Websocket, WebsocketConnections
103
95
 
104
96
  # Check do we have ws endpoint
105
97
  for endpoint in config['flat_urls'].values():
@@ -111,7 +103,6 @@ class Panther:
111
103
 
112
104
  # Create websocket connections instance
113
105
  if config['has_ws']:
114
- config['websocket_connections'] = WebsocketConnections()
115
106
  # Websocket Redis Connection
116
107
  for middleware in config['http_middlewares']:
117
108
  if middleware.__class__.__name__ == 'RedisMiddleware':
@@ -120,6 +111,10 @@ class Panther:
120
111
  else:
121
112
  self.ws_redis_connection = None
122
113
 
114
+ # Don't create Manager() if we are going to use Redis for PubSub
115
+ manager = None if self.ws_redis_connection else Manager()
116
+ config['websocket_connections'] = WebsocketConnections(manager=manager)
117
+
123
118
  async def __call__(self, scope: dict, receive: Callable, send: Callable) -> None:
124
119
  """
125
120
  1.
@@ -138,6 +133,7 @@ class Panther:
138
133
  if scope['type'] == 'lifespan':
139
134
  message = await receive()
140
135
  if message["type"] == "lifespan.startup":
136
+ await self.handle_ws_listener()
141
137
  await self.handle_startup()
142
138
  return
143
139
 
@@ -262,6 +258,15 @@ class Panther:
262
258
  body=response.body,
263
259
  )
264
260
 
261
+ async def handle_ws_listener(self):
262
+ # Start Websocket Listener (Redis/ Queue)
263
+ if config['has_ws']:
264
+ Thread(
265
+ target=config['websocket_connections'],
266
+ daemon=True,
267
+ args=(self.ws_redis_connection,),
268
+ ).start()
269
+
265
270
  async def handle_startup(self):
266
271
  if startup := config['startup'] or self._startup:
267
272
  if is_function_async(startup):
@@ -272,7 +277,13 @@ class Panther:
272
277
  def handle_shutdown(self):
273
278
  if shutdown := config['shutdown'] or self._shutdown:
274
279
  if is_function_async(shutdown):
275
- asyncio.run(shutdown())
280
+ try:
281
+ asyncio.run(shutdown())
282
+ except ModuleNotFoundError:
283
+ # Error: import of asyncio halted; None in sys.modules
284
+ # And as I figured it out, it only happens when we running with
285
+ # gunicorn and Uvicorn workers (-k uvicorn.workers.UvicornWorker)
286
+ pass
276
287
  else:
277
288
  shutdown()
278
289
 
panther/response.py CHANGED
@@ -35,9 +35,10 @@ class Response:
35
35
 
36
36
  @property
37
37
  def headers(self) -> dict:
38
+ content_length = 0 if self.body == b'null' else len(self.body)
38
39
  return {
39
40
  'content-type': self.content_type,
40
- 'content-length': len(self.body),
41
+ 'content-length': content_length,
41
42
  'access-control-allow-origin': '*',
42
43
  } | (self._headers or {})
43
44
 
panther/serializer.py CHANGED
@@ -3,31 +3,44 @@ from pydantic_core._pydantic_core import PydanticUndefined
3
3
 
4
4
 
5
5
  class ModelSerializer:
6
- def __new__(cls, *args, **kwargs):
6
+ def __new__(cls, *args, model=None, **kwargs):
7
+ # Check `metaclass`
7
8
  if len(args) == 0:
8
- msg = f"you should not inherit the 'ModelSerializer', you should use it as 'metaclass' -> {cls.__name__}"
9
+ address = f'{cls.__module__}.{cls.__name__}'
10
+ msg = f"you should not inherit the 'ModelSerializer', you should use it as 'metaclass' -> {address}"
9
11
  raise TypeError(msg)
12
+
10
13
  model_name = args[0]
11
- if 'model' not in kwargs:
12
- msg = f"'model' required while using 'ModelSerializer' metaclass -> {model_name}"
14
+ data = args[2]
15
+ address = f'{data["__module__"]}.{model_name}'
16
+
17
+ # Check `model`
18
+ if model is None:
19
+ msg = f"'model' required while using 'ModelSerializer' metaclass -> {address}"
13
20
  raise AttributeError(msg)
21
+ # Check `fields`
22
+ if 'fields' not in data:
23
+ msg = f"'fields' required while using 'ModelSerializer' metaclass. -> {address}"
24
+ raise AttributeError(msg) from None
14
25
 
15
- model_fields = kwargs['model'].model_fields
26
+ model_fields = model.model_fields
16
27
  field_definitions = {}
17
- if 'fields' not in args[2]:
18
- msg = f"'fields' required while using 'ModelSerializer' metaclass. -> {model_name}"
19
- raise AttributeError(msg) from None
20
- for field_name in args[2]['fields']:
28
+
29
+ # Collect `fields`
30
+ for field_name in data['fields']:
21
31
  if field_name not in model_fields:
22
- msg = f"'{field_name}' is not in '{kwargs['model'].__name__}' -> {model_name}"
32
+ msg = f"'{field_name}' is not in '{model.__name__}' -> {address}"
23
33
  raise AttributeError(msg) from None
24
-
25
34
  field_definitions[field_name] = (model_fields[field_name].annotation, model_fields[field_name])
26
- for required in args[2].get('required_fields', []):
35
+
36
+ # Change `required_fields
37
+ for required in data.get('required_fields', []):
27
38
  if required not in field_definitions:
28
- msg = f"'{required}' is in 'required_fields' but not in 'fields' -> {model_name}"
39
+ msg = f"'{required}' is in 'required_fields' but not in 'fields' -> {address}"
29
40
  raise AttributeError(msg) from None
30
41
  field_definitions[required][1].default = PydanticUndefined
42
+
43
+ # Create Model
31
44
  return create_model(
32
45
  __model_name=model_name,
33
46
  **field_definitions
panther/websocket.py CHANGED
@@ -1,13 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Literal
4
-
5
- import orjson as json
6
-
7
3
  from panther import status
8
- from panther.base_websocket import Websocket, WebsocketConnections
4
+ from panther.base_websocket import Websocket
9
5
  from panther.configs import config
10
- from panther.db.connection import redis
11
6
 
12
7
 
13
8
  class GenericWebsocket(Websocket):
@@ -31,31 +26,9 @@ class GenericWebsocket(Websocket):
31
26
 
32
27
 
33
28
  async def send_message_to_websocket(connection_id: str, data: any):
34
- if redis.is_connected:
35
- _publish_to_ws_channel(connection_id=connection_id, action='send', data=data)
36
- else:
37
- websocket_connections: WebsocketConnections = config['websocket_connections']
38
- if connection := websocket_connections.connections.get(connection_id):
39
- await connection.send(data=data)
29
+ config.websocket_connections.publish(connection_id=connection_id, action='send', data=data)
40
30
 
41
31
 
42
32
  async def close_websocket_connection(connection_id: str, code: int = status.WS_1000_NORMAL_CLOSURE, reason: str = ''):
43
- if redis.is_connected:
44
- data = {
45
- 'code': code,
46
- 'reason': reason,
47
- }
48
- _publish_to_ws_channel(connection_id=connection_id, action='close', data=data)
49
- else:
50
- websocket_connections: WebsocketConnections = config['websocket_connections']
51
- if connection := websocket_connections.connections.get(connection_id):
52
- await connection.close(code=code, reason=reason)
53
-
54
-
55
- def _publish_to_ws_channel(connection_id: str, action: Literal['send', 'close'], data: any):
56
- from panther.db.connection import redis
57
-
58
- assert redis.is_connected, 'Redis Is Not Connected.'
59
-
60
- p_data = json.dumps({'connection_id': connection_id, 'action': action, 'data': data})
61
- redis.publish('websocket_connections', p_data)
33
+ data = {'code': code, 'reason': reason}
34
+ config.websocket_connections.publish(connection_id=connection_id, action='close', data=data)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: panther
3
- Version: 3.7.0
3
+ Version: 3.8.1
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,37 +1,37 @@
1
- panther/__init__.py,sha256=xRfhzl4lfI9PNr6JDl51kcAwX9xCDez97xYed08Hyds,110
1
+ panther/__init__.py,sha256=sxGEJd2i5R8BqUGWsC5YBeZeY0C0YICZHJM27Wu09bc,110
2
2
  panther/_load_configs.py,sha256=jlDO5rK040z3LLmMFMJ6jUL9oTKaDKCWebwmdYJPDjw,9567
3
3
  panther/_utils.py,sha256=BHAOdpESn3dt90FtpPKxEZdnkXnDC7MFcncJYburEaQ,4846
4
4
  panther/app.py,sha256=3Qf0b-OtczqU5xs7AT7_hFr3t1eFMqzaThQOFu-RoqE,8060
5
5
  panther/authentications.py,sha256=II3397DOtB2A1zvPbnCibQXekO_dKeclQ8dgU0s_WAc,4397
6
6
  panther/background_tasks.py,sha256=J6JrjiEzyGohYsaFZHnZtXeP52piP84RlGFLsKOFa-Y,7225
7
7
  panther/base_request.py,sha256=94mmloCULYtmiRWz-egb5annYp1KQAKFYIAcmIJZZ3o,2897
8
- panther/base_websocket.py,sha256=I8-ppL7DxFfcZdyt6lfDXeyTa_8xojXK_ADZUYElLBE,6377
8
+ panther/base_websocket.py,sha256=p8nNr3EolttHmMPtIGgFB3evM2N5XZiX9TwXnZbyMqI,7608
9
9
  panther/caching.py,sha256=YTFP40aLFR0VabbVYAlHilaALWlCJOrlTNDhG0cRC7c,3075
10
10
  panther/configs.py,sha256=8pVMWa2QKaeNOlIUCLX3qfNXl0vNdraiGL4OGykFPXI,2884
11
11
  panther/exceptions.py,sha256=DB6nGfU5LxrglEN_I-HInMqdIA3ZmN8rRv0ynEuQyGA,1332
12
12
  panther/file_handler.py,sha256=YhqYpGhNybcQBqcxqDEYwI1Hqd-7U5JcwxMb7UM_DbA,854
13
13
  panther/logging.py,sha256=DZVf3nxzLodT-hD4820J1jEAffU8zIxXRPKs2lbP8ho,2074
14
- panther/main.py,sha256=8F9NDzYJIG3cjXgIB18_TsghLK7AUPb2yc3auKXkpXA,11655
14
+ panther/main.py,sha256=0q-ViNYzrR6EgNLZiGqWOlOhpRiJEnunmiqJVfHq6o8,12246
15
15
  panther/monitoring.py,sha256=krmcoTUcV12pHwCFVAHywgsp1-US9cyiMlgsrMJdUQ0,1203
16
16
  panther/permissions.py,sha256=Q-l25369yQaP-tY11tFQm-v9PPh8iVImbfUpY3pnQUk,355
17
17
  panther/request.py,sha256=u_-pyttspRimlSwpSwyVIV0U87hHDIthsFU9Qr8LDvI,1762
18
- panther/response.py,sha256=Kus8zf5fQLfCgmpKjCMOcEPnAfsQDkSd2NIBvI69lJ8,3653
18
+ panther/response.py,sha256=5t9-HwgwRo3egqzs1uFNhIq5i5wZW-TRrVX8YEdFrgA,3724
19
19
  panther/routings.py,sha256=GfwBZL7bt3yVOnpdjOZ-49kiGVrRoF0i8VwS1qkF_oI,5273
20
- panther/serializer.py,sha256=26l-SDQ194fXAuirrvHIABHlrKQI-9FHsUw-1oOvD20,1581
20
+ panther/serializer.py,sha256=ZvLWwHV2KQdl49ZFRshz4HWXVmFNQtUClggSWkH9pa8,1839
21
21
  panther/status.py,sha256=Gc_PnYrHfInTsZpGbqiCfDB-py1C7Rh8KMdb6Lq9Exs,3346
22
22
  panther/test.py,sha256=Se0cF51JRAmABpB_QTIJeTDRKDcyOMlibEN6rrr4Sd8,6152
23
23
  panther/throttling.py,sha256=mVa_mGv6w_Ad7LLtV4eG5QpDwwNsk4QjFFi0mIHQBnE,231
24
24
  panther/utils.py,sha256=fWzUrVnXc-98iOC6RIOD4E1KL2rkd6EtbSqqg5ZTG_U,3142
25
- panther/websocket.py,sha256=tsG5AYT8RNFmgsb9pYpx1YirRO0qaqmVKQ5V-U1hNmE,2139
25
+ panther/websocket.py,sha256=HEyp3lOTKwitPPX76IfwYdp_gbjjmmrHMizBlLHRmNA,1158
26
26
  panther/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
27
27
  panther/cli/create_command.py,sha256=AshVxzhSpX-L_63OLP7ZgQ5a45NINGomTbvJkCaadks,9671
28
28
  panther/cli/main.py,sha256=pCqnOTazgMhTvFHTugutIsiFXueU5kx2VmGngwAl54Q,1679
29
29
  panther/cli/monitor_command.py,sha256=cZ7wOxeQ8jKXgRLP28SgFI9pbIEp4RK6VmVf74WKT6c,2447
30
30
  panther/cli/run_command.py,sha256=X_T9jP_Z8X_fm9S4LSoR6ARsPp4rCTMi1E5c7QWREjM,2120
31
- panther/cli/template.py,sha256=GwQE8kadjAgKN3-Nd250xqOO5WThCpEE3HlsJOTZlDA,4928
31
+ panther/cli/template.py,sha256=i4YUgsdVjYhWGoCpoCNQOBo2rKSVk0yx-o_GBmG4yx8,4941
32
32
  panther/cli/utils.py,sha256=nhtdr9nhYAbCm2S-17vTc3_unjQJ450uxT0ZVyZueIw,4838
33
33
  panther/db/__init__.py,sha256=w9lEL0vRqb18Qx_iUJipUR_fi5GQ5uVX0DWycx14x08,50
34
- panther/db/connection.py,sha256=yPD3WeSPtbGLMm-C81s09exbsj7Bm2n57rAQkqD2EdY,2952
34
+ panther/db/connection.py,sha256=9lXzMinGNgQraeu4Sjy92AueR7_AGRsDb0EE0_K4VP4,3039
35
35
  panther/db/models.py,sha256=7dJ_l0ejVOAVc6qietOYenaRVXBb_O5LNXoyBCLi55g,1434
36
36
  panther/db/utils.py,sha256=AFVQz-vvfnAj3hcNq4IMo0T7EB8BR9snM-tGsK-Saw4,1667
37
37
  panther/db/queries/__init__.py,sha256=FpSQGNHGMs5PJow8Qan4eBAld6QH6wfMvj7lC92vKcU,55
@@ -46,9 +46,9 @@ panther/panel/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
46
46
  panther/panel/apis.py,sha256=HUf9Km81wWtO3pbgW0Oi5MDbLvX5LIWdiFRy05UsitE,1773
47
47
  panther/panel/urls.py,sha256=BQkWqSJBPP3VEQYeorKSHIRx-PUl21Y7Z6NFylmhs1I,192
48
48
  panther/panel/utils.py,sha256=0Rv79oR5IEqalqwpRKQHMn1p5duVY5mxMqDKiA5mWx4,437
49
- panther-3.7.0.dist-info/LICENSE,sha256=2aF1hL2aC0zRPjzUkSxJUzZbn2_uLoOkn7DHjzZni-I,1524
50
- panther-3.7.0.dist-info/METADATA,sha256=ziap0s_p1ParA6EN0iPFu6s0L3etUKQyEg2PFwgEx90,6023
51
- panther-3.7.0.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
52
- panther-3.7.0.dist-info/entry_points.txt,sha256=6GPxYFGuzVfNB4YpHFJvYex6iWah5_tLnirAHwj2Qsg,51
53
- panther-3.7.0.dist-info/top_level.txt,sha256=VbBs02JGXTIoHMzsX-eLOk2MCbBZzQbLhWiYpI7xI2g,8
54
- panther-3.7.0.dist-info/RECORD,,
49
+ panther-3.8.1.dist-info/LICENSE,sha256=2aF1hL2aC0zRPjzUkSxJUzZbn2_uLoOkn7DHjzZni-I,1524
50
+ panther-3.8.1.dist-info/METADATA,sha256=KMHrczETrp-LQzP2SxiPcHqaiKuLz2TkUT6dLv5TSU4,6023
51
+ panther-3.8.1.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
52
+ panther-3.8.1.dist-info/entry_points.txt,sha256=6GPxYFGuzVfNB4YpHFJvYex6iWah5_tLnirAHwj2Qsg,51
53
+ panther-3.8.1.dist-info/top_level.txt,sha256=VbBs02JGXTIoHMzsX-eLOk2MCbBZzQbLhWiYpI7xI2g,8
54
+ panther-3.8.1.dist-info/RECORD,,