crypto-ws-api 2.1.2__tar.gz → 2.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,10 @@
1
+ ## 2.1.3 - 2025-10-16
2
+ ✨ feat(ws_session.py): Add `tasks_cancel()` for structured task cancellation with logging
3
+ ♻️ refactor(ws_session.py): Refactor task cancellation and stop logic to use `tasks_cancel()`
4
+ 🔧 fix(ws_session.py): Update logger references for consistent logging throughout the module
5
+ 🔧 chore(requirements.txt): Upgrade `platformdirs` to 4.5.0, `ujson` to 5.11.0, and `cryptography` to 46.0.3
6
+ 🔧 fix(ws_session.py): _improve memory usage_
7
+
1
8
  ## 2.1.2 - 2025-08-10
2
9
  ♻️ refactor(demo/demo_loop): Store demo_loop coroutines in variables for reference
3
10
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.1
2
2
  Name: crypto-ws-api
3
- Version: 2.1.2
3
+ Version: 2.1.3
4
4
  Summary: Crypto WS API connector for ASYNC requests
5
5
  Author-email: Jerry Fedorenko <jerry.fedorenko@yahoo.com>
6
6
  Requires-Python: >=3.10
@@ -11,13 +11,12 @@ Classifier: License :: OSI Approved :: MIT License
11
11
  Classifier: Operating System :: Unix
12
12
  Classifier: Operating System :: Microsoft :: Windows
13
13
  Classifier: Operating System :: MacOS
14
- License-File: LICENSE.md
15
14
  Requires-Dist: shortuuid~=1.0.13
16
- Requires-Dist: platformdirs==4.3.8
15
+ Requires-Dist: platformdirs==4.5.0
17
16
  Requires-Dist: toml~=0.10.2
18
17
  Requires-Dist: websockets==15.0.1
19
- Requires-Dist: ujson~=5.10.0
20
- Requires-Dist: cryptography~=45.0.6
18
+ Requires-Dist: ujson~=5.11.0
19
+ Requires-Dist: cryptography~=46.0.3
21
20
  Project-URL: Source, https://github.com/DogsTailFarmer/crypto-ws-api
22
21
 
23
22
  <h1 align="center"><img align="center" src="https://user-images.githubusercontent.com/77513676/250364389-cbedc171-a930-4467-a0cd-21627a6a41ed.svg" width="75">Crypto WS API connector for ASYNC requests</h1>
@@ -13,7 +13,7 @@ __maintainer__ = "Jerry Fedorenko"
13
13
  __contact__ = "https://github.com/DogsTailFarmer"
14
14
  __email__ = "jerry.fedorenko@yahoo.com"
15
15
  __credits__ = ["https://github.com/DanyaSWorlD"]
16
- __version__ = "2.1.2"
16
+ __version__ = "2.1.3"
17
17
 
18
18
  from pathlib import Path
19
19
  import shutil
@@ -22,7 +22,7 @@ from platformdirs import user_config_path
22
22
  VERSION = __version__
23
23
  TIMEOUT = 5 # sec timeout for WSS initialization and get response
24
24
  DELAY = 0.1 # sec delay in keepalive loop
25
- DEBUG_LOG = 'debug' # The name of the exchange for which log files, separated by trade_id with DEBUG level, will be generated
25
+ DEBUG_LOG = 'debug' # The exchange for which log files, separated by trade_id with DEBUG level, will be generated
26
26
  # Maximum str size for unique query ID
27
27
  ID_LEN_LIMIT = {
28
28
  "binance": 36,
@@ -75,7 +75,7 @@ def generate_signature(exchange: str, secret: str, data: str) -> str:
75
75
  return signature.hexdigest()
76
76
 
77
77
  def compose_binance_ws_auth(_id: str, api_key: str, api_secret: str) -> dict:
78
- req = {"id": _id, "method": "session.logon"}
78
+ req: dict[str, str | dict[str, str | int]] = {"id": _id, "method": "session.logon"}
79
79
  params = {
80
80
  "apiKey": api_key,
81
81
  "timestamp": int(time.time() * 1000)
@@ -109,6 +109,18 @@ def tasks_manage(tasks_set: set, coro, name=None, add_done_callback=True):
109
109
  if add_done_callback:
110
110
  _t.add_done_callback(tasks_set.discard)
111
111
 
112
+ async def tasks_cancel(tasks_set: set, _logger=logger):
113
+ tasks = tasks_set.copy()
114
+ for task in tasks:
115
+ task.cancel()
116
+ flag = None
117
+ try:
118
+ await task
119
+ except asyncio.CancelledError: # NOSONAR
120
+ flag = True
121
+ finally:
122
+ _logger.info(f"The task {task.get_name()} was cancelled {'by force' if flag else ''}")
123
+ tasks_set.clear()
112
124
 
113
125
  # https://binance-docs.github.io/apidocs/websocket_api/en/#rate-limits
114
126
  class RateLimitInterval(Enum):
@@ -136,7 +148,8 @@ class UserWSS:
136
148
  "_response_pool",
137
149
  "tasks",
138
150
  "ping",
139
- "logger"
151
+ "logger",
152
+ "stopped"
140
153
  )
141
154
 
142
155
  def __init__(self, ws_id, exchange, endpoint, api_key, api_secret, passphrase=None, trade_id=None):
@@ -157,28 +170,27 @@ class UserWSS:
157
170
  self.in_event = asyncio.Event()
158
171
  self.tasks = set()
159
172
  self.ping = 0
173
+ self.stopped = False
160
174
 
161
175
  if self.exchange == DEBUG_LOG:
162
- if trade_id in logging.root.manager.loggerDict:
163
- logger.info(f"Logger {trade_id} already exists")
164
- self.logger = logging.root.manager.loggerDict[trade_id]
176
+ log_key = f"ws_{trade_id}"
177
+ if log_key in logging.root.manager.loggerDict:
178
+ self.logger = logging.root.manager.loggerDict[log_key]
165
179
  else:
166
- self.logger = set_logger(trade_id, Path(LOG_PATH, f"ws_{trade_id}.log"), logging.DEBUG)
180
+ self.logger = set_logger(log_key, Path(LOG_PATH, f"{log_key}.log"), logging.DEBUG)
167
181
  else:
168
182
  self.logger = logger
169
183
 
170
- async def _ws_listener(self): #NOSONAR
184
+ async def _ws_listener(self): # NOSONAR(S3776)
171
185
  tasks_manage(self.tasks, self.ws_login(), f"ws_login-{self.ws_id}")
172
186
  async for msg in self._ws:
173
- # logger.info(f"_ws_listener: ws_id: {self.ws_id} msg: {msg}")
174
187
  if isinstance(msg, str):
175
188
  try:
176
189
  _msg = json.loads(msg)
177
190
  except JSONDecodeError:
178
- logger.warning(f"UserWSS: {self.ws_id}: {msg}")
191
+ self.logger.warning(f"UserWSS JSONDecodeError: {self.ws_id}: {msg}")
179
192
  else:
180
193
  res = await self._handle_msg(_msg)
181
- # logger.info(f"_ws_listener: res: {self.ws_id}: {res}")
182
194
  if res != 'pass':
183
195
  if res is None:
184
196
  self._response_pool[f"NoneResponse{self.ws_id}"] = None
@@ -189,50 +201,70 @@ class UserWSS:
189
201
  elif self.exchange == 'huobi':
190
202
  self._response_pool[res.get('cid') or self.ws_id] = res.get('data')
191
203
  self.in_event.set()
192
- # logger.info(f"_ws_listener: _response_pool: {self._response_pool}")
193
204
  await asyncio.sleep(0)
194
205
  else:
195
- logger.warning(f"UserWSS: {self.ws_id}: {msg}")
196
- await self.stop()
206
+ self.logger.warning(f"UserWSS: {self.ws_id}: {msg}")
207
+ break
208
+ self.logger.info(f"UserWSS listener loop for {self.ws_id} stopped")
209
+
210
+ async def stop(self):
211
+ """
212
+ Stop data stream
213
+ """
214
+ if not self.stopped:
215
+ self.stopped = True
216
+ self.operational_status = None # Not restart and break all loops
217
+ self.order_handling = False
218
+ if self._ws:
219
+ await self._ws.close(code=4000)
220
+ self._ws = None
221
+ await self.cycle_init()
222
+ self.logger.info(f"User WSS for {self.ws_id} stopped")
223
+
224
+ async def cycle_init(self):
225
+ await tasks_cancel(self.tasks, _logger=self.logger)
226
+ self._response_pool.clear()
227
+ gc.collect()
197
228
 
198
229
  async def start_wss(self):
199
- try:
200
- async for self._ws in connect(
201
- self.endpoint,
202
- logger=self.logger,
203
- ping_interval=None if self.exchange in ('binance', 'huobi') else 20
204
- ):
205
- try:
206
- await self._ws_listener()
207
- except ConnectionClosed as ex:
208
- if ex.rcvd and ex.rcvd.code == 4000:
209
- logger.info(f"WSS closed for {self.ws_id}")
210
- break
211
- else:
212
- self.operational_status = False
213
- [task.cancel() for task in self.tasks if not task.done()]
214
- self.tasks.clear()
215
- logger.warning(f"Restart UserWSS for {self.ws_id}")
216
- continue
217
- except Exception as ex:
218
- logger.error(f"UserWSS start other exception: {ex}")
219
- except asyncio.CancelledError:
220
- await self.stop()
221
- raise
230
+ async for self._ws in connect(
231
+ self.endpoint,
232
+ logger=self.logger,
233
+ ping_interval=None if self.exchange in ('binance', 'huobi') else 20
234
+ ):
235
+ try:
236
+ await self._ws_listener()
237
+ except ConnectionClosed as ex:
238
+ self._ws = None
239
+ if ex.rcvd and ex.rcvd.code == 4000:
240
+ self.logger.info(f"WSS closed for {self.ws_id}")
241
+ break
242
+ else:
243
+ self.operational_status = False
244
+ await self.cycle_init()
245
+ self.logger.warning(f"Restart UserWSS for {self.ws_id}")
246
+ continue
247
+ except Exception as ex:
248
+ self.logger.error(f"UserWSS start other exception: {ex}")
249
+ break
250
+ break
251
+ self.logger.info(f"UserWSS start loop for {self.ws_id} stopped")
252
+ await self.stop()
222
253
 
223
254
  async def ws_login(self):
224
255
  res = await self.request(CONST_WS_START)
225
256
  if res is None:
226
- logger.warning(f"UserWSS: Not 'logged in' for {self.ws_id}")
257
+ self.logger.warning(f"UserWSS: Not 'logged in' for {self.ws_id}")
227
258
  await self.stop()
228
259
  else:
260
+ self.operational_status = True
261
+ self.order_handling = True
262
+
229
263
  if self.exchange == 'huobi':
230
264
  tasks_manage(self.tasks, self.htx_keepalive(), f"htx_keepalive-{self.ws_id}")
231
265
 
232
- self.operational_status = True
233
- self.order_handling = True
234
266
  tasks_manage(self.tasks, self._keepalive(), f"keepalive-{self.ws_id}")
235
- logger.info(f"UserWSS: 'logged in' for {self.ws_id}")
267
+ self.logger.info(f"UserWSS: 'logged in' for {self.ws_id}")
236
268
 
237
269
  async def request(self, method, _params=None, _signed=False):
238
270
  """
@@ -241,13 +273,13 @@ class UserWSS:
241
273
  :return: result: {} or None if temporary Out-of-Service state
242
274
  """
243
275
  if self.request_limit_reached:
244
- logger.warning(f"UserWSS {self.ws_id}: request limit reached, try later")
276
+ self.logger.warning(f"UserWSS {self.ws_id}: request limit reached, try later")
245
277
  return None
246
278
  if method != CONST_WS_START and not self.operational_status:
247
- logger.warning("UserWSS temporary in Out-of-Service state")
279
+ self.logger.warning("UserWSS temporary in Out-of-Service state")
248
280
  return None
249
281
  if method in ('order.place', 'order.cancelReplace', 'order', 'create-order') and not self.order_handling:
250
- logger.warning("UserWSS: exceeded order placement limit, try later")
282
+ self.logger.warning("UserWSS: exceeded order placement limit, try later")
251
283
  return None
252
284
  params = _params.copy() if _params else None
253
285
  r_id = f"{self.exchange}{method}{''.join(random.choices(ALPHABET, k=8))}" #NOSONAR
@@ -262,7 +294,7 @@ class UserWSS:
262
294
  try:
263
295
  res = await asyncio.wait_for(self._response_distributor(_id), timeout=TIMEOUT)
264
296
  except asyncio.exceptions.TimeoutError:
265
- logger.warning(f"UserWSS: get response timeout error: {self.ws_id}")
297
+ self.logger.warning(f"UserWSS: get response timeout error: {self.ws_id}")
266
298
  await self.stop()
267
299
  return None
268
300
  else:
@@ -343,37 +375,24 @@ class UserWSS:
343
375
  while self.operational_status is not None:
344
376
  if self.request_limit_reached and (int(time.time() * 1000) - self._retry_after >= 0):
345
377
  self.request_limit_reached = False
346
- logger.info(f"UserWSS: request limit reached restored for {self.ws_id}")
378
+ self.logger.info(f"UserWSS: request limit reached restored for {self.ws_id}")
347
379
  if not self.order_handling and (int(time.time() * 1000) - self._retry_after >= 0):
348
380
  self.order_handling = True
349
- logger.info(f"UserWSS order handling status restored for {self.ws_id}")
381
+ self.logger.info(f"UserWSS order handling status restored for {self.ws_id}")
350
382
  await asyncio.sleep(interval)
351
383
 
352
384
  async def htx_keepalive(self, interval=60):
353
385
  await asyncio.sleep(interval * 10)
354
- while True:
386
+ while self.operational_status is not None:
355
387
  await asyncio.sleep(interval)
356
388
  if self.ping:
389
+ self.logger.warning("From HTX server PING timeout exceeded")
357
390
  break
358
391
  else:
359
392
  self.ping = 1
360
- logger.warning("From HTX server PING timeout exceeded")
393
+ self.logger.info(f"UserWSS htx_keepalive loop for {self.ws_id} stopped")
361
394
  await self.stop()
362
395
 
363
- async def stop(self):
364
- """
365
- Stop data stream
366
- """
367
- self.operational_status = None # Not restart and break all loops
368
- self.order_handling = False
369
- self.init = True
370
- [task.cancel() for task in self.tasks if not task.done()]
371
- self.tasks.clear()
372
- if self._ws:
373
- await self._ws.close(code=4000)
374
- gc.collect()
375
- logger.info(f"User WSS for {self.ws_id} stopped")
376
-
377
396
  async def _handle_msg(self, msg):
378
397
  if self.exchange == 'binance':
379
398
  self._handle_rate_limits(msg.pop('rateLimits', []))
@@ -408,13 +427,13 @@ class UserWSS:
408
427
 
409
428
  def htx_error_handle(self, msg):
410
429
  if msg.get('code') == 500:
411
- logger.warning(f"An issue occurred on exchange's side: {msg}")
430
+ self.logger.warning(f"An issue occurred on exchange's side: {msg}")
412
431
  elif msg.get('code') in {429, 4000}:
413
432
  self._retry_after = int((time.time() + TIMEOUT) * 1000)
414
433
  self.request_limit_reached = True
415
- logger.warning(f"HTX WSS exceed limit: {msg}")
434
+ self.logger.warning(f"HTX WSS exceed limit: {msg}")
416
435
  else:
417
- logger.warning(f"Malformed request: status: {msg}")
436
+ self.logger.warning(f"Malformed request: status: {msg}")
418
437
 
419
438
  # region BitfinexErrorHandle
420
439
  async def bitfinex_error_handle(self, msg):
@@ -437,20 +456,20 @@ class UserWSS:
437
456
 
438
457
  async def _handle_info_event(self, msg):
439
458
  if not msg.get('platform', {}).get('status'):
440
- logger.warning(f"UserWSS Bitfinex platform in maintenance mode: {msg}")
459
+ self.logger.warning(f"UserWSS Bitfinex platform in maintenance mode: {msg}")
441
460
  await self.stop()
442
461
  elif msg.get('version') != 2:
443
- logger.critical('Bitfinex WSS platform: version change detected')
462
+ self.logger.critical('Bitfinex WSS platform: version change detected')
444
463
  return 'pass'
445
464
 
446
465
  def _handle_error_code(self, msg):
447
466
  code = msg.get('code')
448
467
  if code == 10305:
449
- logger.warning('UserWSS Bitfinex: Reached limit of open channels')
468
+ self.logger.warning('UserWSS Bitfinex: Reached limit of open channels')
450
469
  self._retry_after = int((time.time() + TIMEOUT) * 1000)
451
470
  self.request_limit_reached = True
452
471
  else:
453
- logger.warning(f"Malformed request for {self.ws_id}: {msg}")
472
+ self.logger.warning(f"Malformed request for {self.ws_id}: {msg}")
454
473
 
455
474
  @staticmethod
456
475
  def _transform_list_message(msg):
@@ -471,18 +490,18 @@ class UserWSS:
471
490
 
472
491
  def okx_error_handle(self, msg):
473
492
  if msg.get('code') == '1':
474
- logger.warning(f"OKX User WSS operation failed: {msg}")
493
+ self.logger.warning(f"OKX User WSS operation failed: {msg}")
475
494
  elif msg.get('code') == '63999':
476
- logger.warning(f"An issue occurred on exchange's side: {msg}")
495
+ self.logger.warning(f"An issue occurred on exchange's side: {msg}")
477
496
  elif msg.get('code') == '60014':
478
497
  self._retry_after = int((time.time() + TIMEOUT) * 1000)
479
498
  self.request_limit_reached = True
480
499
  else:
481
- logger.warning(f"Malformed request: status: {msg}")
500
+ self.logger.warning(f"Malformed request: status: {msg}")
482
501
 
483
502
  async def binance_error_handle(self, msg):
484
503
  error_msg = msg.get('error')
485
- logger.error(f"Malformed request: status: {error_msg}")
504
+ self.logger.error(f"Malformed request: status: {error_msg}")
486
505
  if msg.get('status') in (401, 403):
487
506
  await self.stop()
488
507
  if msg.get('status') in (418, 429):
@@ -511,6 +530,7 @@ class UserWSSession:
511
530
  "_passphrase",
512
531
  "user_wss",
513
532
  "tasks_wss",
533
+ "logger",
514
534
  )
515
535
 
516
536
  def __init__(self, exchange, endpoint, api_key, api_secret, passphrase=None):
@@ -524,8 +544,9 @@ class UserWSSession:
524
544
  self._passphrase = passphrase
525
545
  self.user_wss = {}
526
546
  self.tasks_wss = set()
547
+ self.logger = logger
527
548
 
528
- async def handle_request(
549
+ async def handle_request( # NOSONAR(S3776)
529
550
  self,
530
551
  trade_id: str,
531
552
  method: str,
@@ -533,15 +554,29 @@ class UserWSSession:
533
554
  _signed=False,
534
555
  ):
535
556
 
557
+ if self.exchange == DEBUG_LOG:
558
+ log_key = f"ws_{trade_id}"
559
+ if log_key in logging.root.manager.loggerDict:
560
+ self.logger = logging.root.manager.loggerDict[log_key]
561
+ else:
562
+ self.logger = set_logger(log_key, Path(LOG_PATH, f"{log_key}.log"), logging.DEBUG)
563
+
536
564
  ws_id = f"{self.exchange}-{trade_id}"
537
565
  if self.exchange in ('binance', 'vertex'):
538
566
  ws_id = f"{ws_id}{'_ST' if _signed else '_SF'}"
539
567
  else:
540
568
  ws_id = f"{ws_id}-{method}"
541
569
 
570
+
571
+ init_session = True
542
572
  if ws_id in self.user_wss:
543
573
  user_wss = self.user_wss[ws_id]
544
- else:
574
+ if user_wss.stopped:
575
+ await self.stop(ws_id)
576
+ else:
577
+ init_session = False
578
+
579
+ if init_session:
545
580
  user_wss = UserWSS(
546
581
  ws_id,
547
582
  self.exchange,
@@ -553,6 +588,7 @@ class UserWSSession:
553
588
  )
554
589
  self.user_wss[ws_id] = user_wss
555
590
 
591
+ # noinspection PyUnboundLocalVariable
556
592
  if user_wss.init:
557
593
  user_wss.init = False
558
594
  user_wss.operational_status = False
@@ -562,7 +598,7 @@ class UserWSSession:
562
598
  while not (user_wss.operational_status and user_wss.order_handling):
563
599
  await asyncio.sleep(DELAY)
564
600
  if duration > TIMEOUT:
565
- logger.warning(f"{trade_id}: Register timeout for method '{method}'")
601
+ self.logger.warning(f"{trade_id}: Register timeout for method '{method}'")
566
602
  return None
567
603
  duration += DELAY
568
604
 
@@ -571,14 +607,24 @@ class UserWSSession:
571
607
  except KeyboardInterrupt:
572
608
  pass # Task cancellation should not be logged as an error
573
609
  except Exception as ex:
574
- logger.error(f"crypto_ws_api.ws_session.handle_request(): {ex}")
575
- logger.warning(f"{trade_id}: {method}: None response")
610
+ self.logger.error(f"crypto_ws_api.ws_session.handle_request(): {ex}")
611
+ self.logger.warning(f"{trade_id}: {method}: None response")
576
612
  return None
577
613
 
578
- async def stop(self):
579
- user_wss_copy = dict(self.user_wss)
580
- for ws in user_wss_copy.values():
581
- await ws.stop()
582
- self.user_wss.clear()
583
- [task.cancel() for task in self.tasks_wss if not task.done()]
584
- self.tasks_wss.clear()
614
+ async def stop(self, _trade_id):
615
+ user_wss_to_stop = []
616
+ for key in list(self.user_wss.keys()): # NOSONAR
617
+ if _trade_id in key:
618
+ user_wss_to_stop.append((key, self.user_wss.pop(key)))
619
+
620
+ # Stop each UserWSS instance and clean up
621
+ await asyncio.gather(*[ws.stop() for _, ws in user_wss_to_stop])
622
+
623
+ # Remove related tasks
624
+ to_cancel = set()
625
+ for task in self.tasks_wss:
626
+ if _trade_id in task.get_name():
627
+ to_cancel.add(task)
628
+
629
+ self.tasks_wss.difference_update(to_cancel)
630
+ await tasks_cancel(to_cancel, _logger=self.logger)
@@ -18,11 +18,11 @@ requires-python = ">=3.10"
18
18
 
19
19
  dependencies = [
20
20
  "shortuuid~=1.0.13",
21
- "platformdirs==4.3.8",
21
+ "platformdirs==4.5.0",
22
22
  "toml~=0.10.2",
23
23
  "websockets==15.0.1",
24
- "ujson~=5.10.0",
25
- "cryptography~=45.0.6"
24
+ "ujson~=5.11.0",
25
+ "cryptography~=46.0.3"
26
26
  ]
27
27
 
28
28
  [tool.flit.module]
@@ -0,0 +1,6 @@
1
+ shortuuid~=1.0.13
2
+ platformdirs==4.5.0
3
+ toml~=0.10.2
4
+ websockets==15.0.1
5
+ ujson~=5.11.0
6
+ cryptography~=46.0.3
@@ -1,6 +0,0 @@
1
- shortuuid~=1.0.13
2
- platformdirs==4.3.8
3
- toml~=0.10.2
4
- websockets==15.0.1
5
- ujson~=5.10.0
6
- cryptography~=45.0.6
File without changes
File without changes