panther 3.9.0__py3-none-any.whl → 4.0.0__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 (52) hide show
  1. panther/__init__.py +1 -1
  2. panther/_load_configs.py +168 -171
  3. panther/_utils.py +26 -49
  4. panther/app.py +85 -105
  5. panther/authentications.py +86 -55
  6. panther/background_tasks.py +25 -14
  7. panther/base_request.py +38 -14
  8. panther/base_websocket.py +172 -94
  9. panther/caching.py +60 -25
  10. panther/cli/create_command.py +20 -10
  11. panther/cli/monitor_command.py +63 -37
  12. panther/cli/template.py +38 -20
  13. panther/cli/utils.py +32 -18
  14. panther/configs.py +65 -58
  15. panther/db/connections.py +139 -0
  16. panther/db/cursor.py +43 -0
  17. panther/db/models.py +64 -29
  18. panther/db/queries/__init__.py +1 -1
  19. panther/db/queries/base_queries.py +127 -0
  20. panther/db/queries/mongodb_queries.py +77 -38
  21. panther/db/queries/pantherdb_queries.py +59 -30
  22. panther/db/queries/queries.py +232 -117
  23. panther/db/utils.py +17 -18
  24. panther/events.py +44 -0
  25. panther/exceptions.py +26 -12
  26. panther/file_handler.py +2 -2
  27. panther/generics.py +163 -0
  28. panther/logging.py +7 -2
  29. panther/main.py +111 -188
  30. panther/middlewares/base.py +3 -0
  31. panther/monitoring.py +8 -5
  32. panther/pagination.py +48 -0
  33. panther/panel/apis.py +32 -5
  34. panther/panel/urls.py +2 -1
  35. panther/permissions.py +3 -3
  36. panther/request.py +6 -13
  37. panther/response.py +114 -34
  38. panther/routings.py +83 -66
  39. panther/serializer.py +131 -25
  40. panther/test.py +31 -21
  41. panther/utils.py +28 -16
  42. panther/websocket.py +7 -4
  43. {panther-3.9.0.dist-info → panther-4.0.0.dist-info}/METADATA +93 -71
  44. panther-4.0.0.dist-info/RECORD +57 -0
  45. {panther-3.9.0.dist-info → panther-4.0.0.dist-info}/WHEEL +1 -1
  46. panther/db/connection.py +0 -92
  47. panther/middlewares/db.py +0 -18
  48. panther/middlewares/redis.py +0 -47
  49. panther-3.9.0.dist-info/RECORD +0 -54
  50. {panther-3.9.0.dist-info → panther-4.0.0.dist-info}/LICENSE +0 -0
  51. {panther-3.9.0.dist-info → panther-4.0.0.dist-info}/entry_points.txt +0 -0
  52. {panther-3.9.0.dist-info → panther-4.0.0.dist-info}/top_level.txt +0 -0
panther/__init__.py CHANGED
@@ -1,6 +1,6 @@
1
1
  from panther.main import Panther # noqa: F401
2
2
 
3
- __version__ = '3.9.0'
3
+ __version__ = '4.0.0'
4
4
 
5
5
 
6
6
  def version():
panther/_load_configs.py CHANGED
@@ -1,48 +1,49 @@
1
- import ast
2
- import platform
1
+ import logging
3
2
  import sys
4
- from datetime import timedelta
5
3
  from importlib import import_module
6
- from pathlib import Path
7
- from typing import Callable
8
-
9
- from pydantic._internal._model_construction import ModelMetaclass
4
+ from multiprocessing import Manager
10
5
 
11
6
  from panther._utils import import_class
7
+ from panther.background_tasks import background_tasks
8
+ from panther.base_websocket import WebsocketConnections
9
+ from panther.cli.utils import import_error
12
10
  from panther.configs import JWTConfig, config
11
+ from panther.db.connections import redis
13
12
  from panther.db.queries.mongodb_queries import BaseMongoDBQuery
14
13
  from panther.db.queries.pantherdb_queries import BasePantherDBQuery
15
- from panther.exceptions import PantherException
14
+ from panther.exceptions import PantherError
16
15
  from panther.middlewares.base import WebsocketMiddleware, HTTPMiddleware
16
+ from panther.panel.urls import urls as panel_urls
17
17
  from panther.routings import finalize_urls, flatten_urls
18
- from panther.throttling import Throttling
19
18
 
20
19
  __all__ = (
21
20
  'load_configs_module',
21
+ 'load_redis',
22
+ 'load_startup',
23
+ 'load_shutdown',
24
+ 'load_timezone',
25
+ 'load_database',
22
26
  'load_secret_key',
23
27
  'load_monitoring',
28
+ 'load_throttling',
29
+ 'load_user_model',
24
30
  'load_log_queries',
31
+ 'load_middlewares',
32
+ 'load_auto_reformat',
25
33
  'load_background_tasks',
26
- 'load_throttling',
27
34
  'load_default_cache_exp',
28
- 'load_pantherdb_encryption',
29
- 'load_middlewares',
30
- 'load_user_model',
31
35
  'load_authentication_class',
32
- 'load_jwt_config',
33
- 'load_startup',
34
- 'load_shutdown',
35
- 'load_auto_reformat',
36
- 'collect_all_models',
37
36
  'load_urls',
38
- 'load_panel_urls',
37
+ 'load_websocket_connections',
39
38
  )
40
39
 
40
+ logger = logging.getLogger('panther')
41
+
41
42
 
42
- def load_configs_module(_configs, /) -> dict:
43
- """Read the config file and put it as dict in self.configs"""
44
- if _configs:
45
- _module = sys.modules[_configs]
43
+ def load_configs_module(module_name: str, /) -> dict:
44
+ """Read the config file as dict"""
45
+ if module_name:
46
+ _module = sys.modules[module_name]
46
47
  else:
47
48
  try:
48
49
  _module = import_module('core.configs')
@@ -51,45 +52,92 @@ def load_configs_module(_configs, /) -> dict:
51
52
  return _module.__dict__
52
53
 
53
54
 
54
- def load_secret_key(configs: dict, /) -> bytes | None:
55
- if secret_key := configs.get('SECRET_KEY'):
56
- return secret_key.encode()
57
- return secret_key
55
+ def load_redis(_configs: dict, /) -> None:
56
+ if redis_config := _configs.get('REDIS'):
57
+ # Check redis module installation
58
+ try:
59
+ from redis.asyncio import Redis
60
+ except ImportError as e:
61
+ raise import_error(e, package='redis')
62
+ redis_class_path = redis_config.get('class', 'panther.db.connections.RedisConnection')
63
+ redis_class = import_class(redis_class_path)
64
+ # We have to create another dict then pop the 'class' else we can't pass the tests
65
+ args = redis_config.copy()
66
+ args.pop('class', None)
67
+ redis_class(**args, init=True)
58
68
 
59
69
 
60
- def load_monitoring(configs: dict, /) -> bool:
61
- return configs.get('MONITORING', config['monitoring'])
70
+ def load_startup(_configs: dict, /) -> None:
71
+ if startup := _configs.get('STARTUP'):
72
+ config.STARTUP = import_class(startup)
62
73
 
63
74
 
64
- def load_log_queries(configs: dict, /) -> bool:
65
- return configs.get('LOG_QUERIES', config['log_queries'])
75
+ def load_shutdown(_configs: dict, /) -> None:
76
+ if shutdown := _configs.get('SHUTDOWN'):
77
+ config.SHUTDOWN = import_class(shutdown)
66
78
 
67
79
 
68
- def load_background_tasks(configs: dict, /) -> bool:
69
- return configs.get('BACKGROUND_TASKS', config['background_tasks'])
80
+ def load_timezone(_configs: dict, /) -> None:
81
+ if timezone := _configs.get('TIMEZONE'):
82
+ config.TIMEZONE = timezone
70
83
 
71
84
 
72
- def load_throttling(configs: dict, /) -> Throttling | None:
73
- return configs.get('THROTTLING', config['throttling'])
85
+ def load_database(_configs: dict, /) -> None:
86
+ database_config = _configs.get('DATABASE', {})
87
+ if 'engine' in database_config:
88
+ if 'class' not in database_config['engine']:
89
+ raise _exception_handler(field='DATABASE', error=f'`engine["class"]` not found.')
74
90
 
91
+ engine_class_path = database_config['engine']['class']
92
+ engine_class = import_class(engine_class_path)
93
+ # We have to create another dict then pop the 'class' else we can't pass the tests
94
+ args = database_config['engine'].copy()
95
+ args.pop('class')
96
+ config.DATABASE = engine_class(**args)
75
97
 
76
- def load_default_cache_exp(configs: dict, /) -> timedelta | None:
77
- return configs.get('DEFAULT_CACHE_EXP', config['default_cache_exp'])
98
+ if engine_class_path == 'panther.db.connections.PantherDBConnection':
99
+ config.QUERY_ENGINE = BasePantherDBQuery
100
+ elif engine_class_path == 'panther.db.connections.MongoDBConnection':
101
+ config.QUERY_ENGINE = BaseMongoDBQuery
78
102
 
103
+ if 'query' in database_config:
104
+ if config.QUERY_ENGINE:
105
+ logger.warning('`DATABASE.query` has already been filled.')
106
+ config.QUERY_ENGINE = import_class(database_config['query'])
79
107
 
80
- def load_pantherdb_encryption(configs: dict, /) -> bool:
81
- return configs.get('PANTHERDB_ENCRYPTION', config['pantherdb_encryption'])
82
108
 
109
+ def load_secret_key(_configs: dict, /) -> None:
110
+ if secret_key := _configs.get('SECRET_KEY'):
111
+ config.SECRET_KEY = secret_key.encode()
83
112
 
84
- def load_middlewares(configs: dict, /) -> dict:
85
- """
86
- Collect The Middlewares & Set db_engine If One Of Middlewares Was For DB
87
- And Return a dict with two list, http and ws middlewares"""
113
+
114
+ def load_monitoring(_configs: dict, /) -> None:
115
+ if _configs.get('MONITORING'):
116
+ config.MONITORING = True
117
+
118
+
119
+ def load_throttling(_configs: dict, /) -> None:
120
+ if throttling := _configs.get('THROTTLING'):
121
+ config.THROTTLING = throttling
122
+
123
+
124
+ def load_user_model(_configs: dict, /) -> None:
125
+ config.USER_MODEL = import_class(_configs.get('USER_MODEL', 'panther.db.models.BaseUser'))
126
+ config.MODELS.append(config.USER_MODEL)
127
+
128
+
129
+ def load_log_queries(_configs: dict, /) -> None:
130
+ if _configs.get('LOG_QUERIES'):
131
+ config.LOG_QUERIES = True
132
+
133
+
134
+ def load_middlewares(_configs: dict, /) -> None:
88
135
  from panther.middlewares import BaseMiddleware
89
136
 
90
137
  middlewares = {'http': [], 'ws': []}
91
138
 
92
- for middleware in configs.get('MIDDLEWARES') or []:
139
+ # Collect Middlewares
140
+ for middleware in _configs.get('MIDDLEWARES') or []:
93
141
  if not isinstance(middleware, list | tuple):
94
142
  raise _exception_handler(field='MIDDLEWARES', error=f'{middleware} should have 2 part: (path, kwargs)')
95
143
 
@@ -103,164 +151,113 @@ def load_middlewares(configs: dict, /) -> dict:
103
151
  else:
104
152
  path, data = middleware
105
153
 
106
- if path.find('panther.middlewares.db.DatabaseMiddleware') != -1:
107
- # Keep it simple for now, we are going to make it dynamic in the next patch
108
- if data['url'].split(':')[0] == 'pantherdb':
109
- config['query_engine'] = BasePantherDBQuery
110
- else:
111
- config['query_engine'] = BaseMongoDBQuery
112
154
  try:
113
- Middleware = import_class(path) # noqa: N806
155
+ middleware_class = import_class(path)
114
156
  except (AttributeError, ModuleNotFoundError):
115
157
  raise _exception_handler(field='MIDDLEWARES', error=f'{path} is not a valid middleware path')
116
158
 
117
- if issubclass(Middleware, BaseMiddleware) is False:
159
+ if issubclass(middleware_class, BaseMiddleware) is False:
118
160
  raise _exception_handler(field='MIDDLEWARES', error='is not a sub class of BaseMiddleware')
119
161
 
120
- middleware_instance = Middleware(**data)
121
- if isinstance(middleware_instance, BaseMiddleware | HTTPMiddleware):
122
- middlewares['http'].append(middleware_instance)
123
- if isinstance(middleware_instance, BaseMiddleware | WebsocketMiddleware):
124
- middlewares['ws'].append(middleware_instance)
125
- return middlewares
162
+ if middleware_class.__bases__[0] in (BaseMiddleware, HTTPMiddleware):
163
+ middlewares['http'].append((middleware_class, data))
164
+
165
+ if middleware_class.__bases__[0] in (BaseMiddleware, WebsocketMiddleware):
166
+ middlewares['ws'].append((middleware_class, data))
167
+
168
+ config.HTTP_MIDDLEWARES = middlewares['http']
169
+ config.WS_MIDDLEWARES = middlewares['ws']
126
170
 
127
171
 
128
- def load_user_model(configs: dict, /) -> ModelMetaclass:
129
- return import_class(configs.get('USER_MODEL', 'panther.db.models.BaseUser'))
172
+ def load_auto_reformat(_configs: dict, /) -> None:
173
+ if _configs.get('AUTO_REFORMAT'):
174
+ config.AUTO_REFORMAT = True
130
175
 
131
176
 
132
- def load_authentication_class(configs: dict, /) -> ModelMetaclass | None:
133
- return configs.get('AUTHENTICATION') and import_class(configs['AUTHENTICATION'])
177
+ def load_background_tasks(_configs: dict, /) -> None:
178
+ if _configs.get('BACKGROUND_TASKS'):
179
+ config.BACKGROUND_TASKS = True
180
+ background_tasks.initialize()
134
181
 
135
182
 
136
- def load_jwt_config(configs: dict, /) -> JWTConfig | None:
183
+ def load_default_cache_exp(_configs: dict, /) -> None:
184
+ if default_cache_exp := _configs.get('DEFAULT_CACHE_EXP'):
185
+ config.DEFAULT_CACHE_EXP = default_cache_exp
186
+
187
+
188
+ def load_authentication_class(_configs: dict, /) -> None:
189
+ """Should be after `load_secret_key()`"""
190
+ if authentication := _configs.get('AUTHENTICATION'):
191
+ config.AUTHENTICATION = import_class(authentication)
192
+
193
+ if ws_authentication := _configs.get('WS_AUTHENTICATION'):
194
+ config.WS_AUTHENTICATION = import_class(ws_authentication)
195
+
196
+ load_jwt_config(_configs)
197
+
198
+
199
+ def load_jwt_config(_configs: dict, /) -> None:
137
200
  """Only Collect JWT Config If Authentication Is JWTAuthentication"""
138
- if getattr(config['authentication'], '__name__', None) == 'JWTAuthentication':
139
- user_config = configs.get('JWTConfig', {})
140
- if 'key' not in user_config:
141
- if config['secret_key'] is None:
142
- raise PantherException('"SECRET_KEY" is required when using "JWTAuthentication"')
143
- user_config['key'] = config['secret_key'].decode()
144
-
145
- return JWTConfig(**user_config)
146
- return None
147
-
148
-
149
- def load_startup(configs: dict, /) -> Callable:
150
- return configs.get('STARTUP') and import_class(configs['STARTUP'])
151
-
152
-
153
- def load_shutdown(configs: dict, /) -> Callable:
154
- return configs.get('SHUTDOWN') and import_class(configs['SHUTDOWN'])
155
-
156
-
157
- def load_auto_reformat(configs: dict, /) -> bool:
158
- return configs.get('AUTO_REFORMAT', config['auto_reformat'])
159
-
160
-
161
- def collect_all_models() -> list[dict]:
162
- """Collecting all models for panel APIs"""
163
- from panther.db.models import Model
164
-
165
- # Just load all the python files from 'base_dir',
166
- # so Model.__subclasses__ can find all the subclasses
167
- slash = '\\' if platform.system() == 'Windows' else '/'
168
- _parts = '_tail' if sys.version_info >= (3, 12) else '_parts'
169
- python_files = [
170
- f for f in config['base_dir'].rglob('*.py')
171
- if not f.name.startswith('_') and 'site-packages' not in getattr(f.parents, _parts)
172
- ]
173
- for file in python_files:
174
- # Analyse the file
175
- with Path(file).open() as f:
176
- node = ast.parse(f.read())
177
-
178
- model_imported = False
179
- panther_imported = False
180
- panther_called = False
181
- for n in node.body:
182
- match n:
183
-
184
- # from panther.db import Model
185
- case ast.ImportFrom(module='panther.db', names=[ast.alias(name='Model')]):
186
- model_imported = True
187
-
188
- # from panther.db.models import ..., Model, ...
189
- case ast.ImportFrom(module='panther.db.models', names=[*names]):
190
- try:
191
- next(v for v in names if v.name == 'Model')
192
- model_imported = True
193
- except StopIteration:
194
- pass
195
-
196
- # from panther import Panther, ...
197
- case ast.ImportFrom(module='panther', names=[ast.alias(name='Panther'), *_]):
198
- panther_imported = True
199
-
200
- # from panther import ..., Panther
201
- case ast.ImportFrom(module='panther', names=[*_, ast.alias(name='Panther')]):
202
- panther_imported = True
203
-
204
- # ... = Panther(...)
205
- case ast.Assign(value=ast.Call(func=ast.Name(id='Panther'))):
206
- panther_called = True
207
-
208
- # Panther() should not be called in the file and Model() should be imported,
209
- # We check the import of the Panther to make sure he is calling the panther.Panther and not any Panther
210
- if panther_imported and panther_called or not model_imported:
211
- continue
212
-
213
- # Load the module
214
- dotted_f = str(file).removeprefix(f'{config["base_dir"]}{slash}').removesuffix('.py').replace(slash, '.')
215
- import_module(dotted_f)
216
-
217
- return [
218
- {
219
- 'name': m.__name__,
220
- 'module': m.__module__,
221
- 'class': m
222
- } for m in Model.__subclasses__() if m.__module__ != 'panther.db.models'
223
- ]
224
-
225
-
226
- def load_urls(configs: dict, /, urls: dict | None) -> tuple[dict, dict]:
201
+ auth_is_jwt = (
202
+ getattr(config.AUTHENTICATION, '__name__', None) == 'JWTAuthentication' or
203
+ getattr(config.WS_AUTHENTICATION, '__name__', None) == 'QueryParamJWTAuthentication'
204
+ )
205
+ jwt = _configs.get('JWTConfig', {})
206
+ if auth_is_jwt or jwt:
207
+ if 'key' not in jwt:
208
+ if config.SECRET_KEY is None:
209
+ raise _exception_handler(field='JWTConfig', error='`JWTConfig.key` or `SECRET_KEY` is required.')
210
+ jwt['key'] = config.SECRET_KEY.decode()
211
+ config.JWT_CONFIG = JWTConfig(**jwt)
212
+
213
+
214
+ def load_urls(_configs: dict, /, urls: dict | None) -> None:
227
215
  """
228
216
  Return tuple of all urls (as a flat dict) and (as a nested dict)
229
217
  """
230
218
  if isinstance(urls, dict):
231
- collected_urls = flatten_urls(urls)
232
- return collected_urls, finalize_urls(collected_urls)
219
+ pass
233
220
 
234
- if (url_routing := configs.get('URLs')) is None:
235
- raise _exception_handler(field='URLs', error='is required.')
221
+ elif (url_routing := _configs.get('URLs')) is None:
222
+ raise _exception_handler(field='URLs', error='required.')
236
223
 
237
- if isinstance(url_routing, dict):
224
+ elif isinstance(url_routing, dict):
238
225
  error = (
239
226
  "can't be 'dict', you may want to pass it's value directly to Panther(). " 'Example: Panther(..., urls=...)'
240
227
  )
241
228
  raise _exception_handler(field='URLs', error=error)
242
229
 
243
- if not isinstance(url_routing, str):
230
+ elif not isinstance(url_routing, str):
244
231
  error = 'should be dotted string.'
245
232
  raise _exception_handler(field='URLs', error=error)
246
233
 
247
- try:
248
- imported_urls = import_class(url_routing)
249
- except ModuleNotFoundError as e:
250
- raise _exception_handler(field='URLs', error=e)
234
+ else:
235
+ try:
236
+ urls = import_class(url_routing)
237
+ except ModuleNotFoundError as e:
238
+ raise _exception_handler(field='URLs', error=e)
251
239
 
252
- if not isinstance(imported_urls, dict):
253
- raise _exception_handler(field='URLs', error='should point to a dict.')
240
+ if not isinstance(urls, dict):
241
+ raise _exception_handler(field='URLs', error='should point to a dict.')
254
242
 
255
- collected_urls = flatten_urls(imported_urls)
256
- return collected_urls, finalize_urls(collected_urls)
243
+ config.FLAT_URLS = flatten_urls(urls)
244
+ config.URLS = finalize_urls(config.FLAT_URLS)
245
+ config.URLS['_panel'] = finalize_urls(flatten_urls(panel_urls))
257
246
 
258
247
 
259
- def load_panel_urls() -> dict:
260
- from panther.panel.urls import urls
248
+ def load_websocket_connections():
249
+ """Should be after `load_redis()`"""
250
+ if config.HAS_WS:
251
+ # Check `websockets`
252
+ try:
253
+ import websockets
254
+ except ImportError as e:
255
+ raise import_error(e, package='websockets')
261
256
 
262
- return finalize_urls(flatten_urls(urls))
257
+ # Use the redis pubsub if `redis.is_connected`, else use the `multiprocessing.Manager`
258
+ pubsub_connection = redis.create_connection_for_websocket() if redis.is_connected else Manager()
259
+ config.WEBSOCKET_CONNECTIONS = WebsocketConnections(pubsub_connection=pubsub_connection)
263
260
 
264
261
 
265
- def _exception_handler(field: str, error: str | Exception) -> PantherException:
266
- return PantherException(f"Invalid '{field}': {error}")
262
+ def _exception_handler(field: str, error: str | Exception) -> PantherError:
263
+ return PantherError(f"Invalid '{field}': {error}")
panther/_utils.py CHANGED
@@ -4,57 +4,17 @@ import logging
4
4
  import re
5
5
  import subprocess
6
6
  import types
7
+ from typing import Any, Generator, Iterator, AsyncGenerator
7
8
  from collections.abc import Callable
8
9
  from traceback import TracebackException
9
- from uuid import uuid4
10
10
 
11
- import orjson as json
12
-
13
- from panther import status
14
- from panther.exceptions import PantherException
11
+ from panther.exceptions import PantherError
15
12
  from panther.file_handler import File
16
13
 
17
14
  logger = logging.getLogger('panther')
18
15
 
19
16
 
20
- async def _http_response_start(send: Callable, /, headers: dict, status_code: int) -> None:
21
- bytes_headers = [[k.encode(), str(v).encode()] for k, v in (headers or {}).items()]
22
- await send({
23
- 'type': 'http.response.start',
24
- 'status': status_code,
25
- 'headers': bytes_headers,
26
- })
27
-
28
-
29
- async def _http_response_body(send: Callable, /, body: bytes | None = None) -> None:
30
- if body is None:
31
- await send({'type': 'http.response.body'})
32
- else:
33
- await send({'type': 'http.response.body', 'body': body})
34
-
35
-
36
- async def http_response(
37
- send: Callable,
38
- /,
39
- *,
40
- status_code: int,
41
- monitoring=None, # type: MonitoringMiddleware | None
42
- headers: dict | None = None,
43
- body: bytes | None = None,
44
- exception: bool = False,
45
- ) -> None:
46
- if exception:
47
- body = json.dumps({'detail': status.status_text[status_code]})
48
- elif status_code == status.HTTP_204_NO_CONTENT or body == b'null':
49
- body = None
50
-
51
- await monitoring.after(status_code)
52
-
53
- await _http_response_start(send, headers=headers, status_code=status_code)
54
- await _http_response_body(send, body=body)
55
-
56
-
57
- def import_class(dotted_path: str, /) -> type:
17
+ def import_class(dotted_path: str, /) -> type[Any]:
58
18
  """
59
19
  Example:
60
20
  -------
@@ -109,10 +69,6 @@ def read_multipart_form_data(boundary: str, body: bytes) -> dict:
109
69
  return data
110
70
 
111
71
 
112
- def generate_ws_connection_id() -> str:
113
- return uuid4().hex
114
-
115
-
116
72
  def is_function_async(func: Callable) -> bool:
117
73
  """
118
74
  Sync result is 0 --> False
@@ -137,7 +93,7 @@ def reformat_code(base_dir):
137
93
  subprocess.run(['ruff', 'format', base_dir])
138
94
  subprocess.run(['ruff', 'check', '--select', 'I', '--fix', base_dir])
139
95
  except FileNotFoundError:
140
- raise PantherException("No module named 'ruff', Hint: `pip install ruff`")
96
+ raise PantherError("No module named 'ruff', Hint: `pip install ruff`")
141
97
 
142
98
 
143
99
  def check_function_type_endpoint(endpoint: types.FunctionType) -> Callable:
@@ -155,4 +111,25 @@ def check_class_type_endpoint(endpoint: Callable) -> Callable:
155
111
  logger.critical(f'You may have forgotten to inherit from GenericAPI on the {endpoint.__name__}()')
156
112
  raise TypeError
157
113
 
158
- return endpoint.call_method
114
+ return endpoint().call_method
115
+
116
+
117
+ def async_next(iterator: Iterator):
118
+ """
119
+ The StopIteration exception is a special case in Python,
120
+ particularly when it comes to asynchronous programming and the use of asyncio.
121
+ This is because StopIteration is not meant to be caught in the traditional sense;
122
+ it's used internally by Python to signal the end of an iteration.
123
+ """
124
+ try:
125
+ return next(iterator)
126
+ except StopIteration:
127
+ raise StopAsyncIteration
128
+
129
+
130
+ async def to_async_generator(generator: Generator) -> AsyncGenerator:
131
+ while True:
132
+ try:
133
+ yield await asyncio.to_thread(async_next, iter(generator))
134
+ except StopAsyncIteration:
135
+ break