Skip to content

Commit 0eb8d54

Browse files
authored
Merge pull request #422 from binance/release_common_v3.0.0
2 parents 77853db + e2082f1 commit 0eb8d54

File tree

8 files changed

+678
-128
lines changed

8 files changed

+678
-128
lines changed

common/CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Changelog
22

3+
## 3.0.0 - 2025-09-05
4+
5+
### Added (2)
6+
7+
- Support automatic session re-logon on reconncetions/renewals when session is already logged on (`Session re-logon` option on `WebSocketAPIBase`).
8+
- Added the `api_key` parameter to include `apiKey` in WebsocketAPI request parameters.
9+
10+
### Changed (2)
11+
12+
- Fixed return type mismatch by returning the raw value.
13+
- Updated `WebsocketAPI` user data return value to match its type.
14+
315
## 2.0.0 - 2025-08-22
416

517
### Changed (3)

common/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "binance-common"
3-
version = "2.0.0"
3+
version = "3.0.0"
44
description = "Binance Common Types and Utilities for Binance Connectors"
55
authors = ["Binance"]
66
license = "MIT"

common/src/binance_common/configuration.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ class ConfigurationWebSocketAPI:
9494
- WebSocket Pool
9595
- Time Unit
9696
- Proxy support
97+
- Session re-logon
9798
"""
9899

99100
def __init__(
@@ -111,6 +112,7 @@ def __init__(
111112
pool_size: int = 2,
112113
time_unit: TimeUnit = None,
113114
https_agent: Optional[ssl.SSLContext] = None,
115+
session_re_logon: Optional[bool] = True,
114116
):
115117
"""
116118
Initialize the API configuration.
@@ -129,6 +131,7 @@ def __init__(
129131
pool_size (int): Number of WebSocket connections in pool (default: 2).
130132
time_unit (Optional[TimeUnit]): Time unit for time-based responses (default: None).
131133
https_agent (Optional[ssl.SSLContext]): Custom HTTPS Agent (default: None).
134+
session_re_logon (Optional[bool]): Enable session re-logon (default: True).
132135
"""
133136

134137
self.api_key = api_key
@@ -145,6 +148,7 @@ def __init__(
145148
self.time_unit = time_unit
146149
self.https_agent = https_agent
147150
self.user_agent = ""
151+
self.session_re_logon = session_re_logon
148152

149153

150154
class ConfigurationWebSocketStreams:

common/src/binance_common/models.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from typing import List, Optional, Callable, TypeVar, Generic
22
from pydantic import BaseModel
33

4+
from binance_common.signature import Signers
5+
46
T = TypeVar("T")
57
T_Response = TypeVar("T_Response")
68
T_Stream = TypeVar("T_Stream")
@@ -107,3 +109,31 @@ def __init__(
107109
):
108110
self.response = response
109111
self.stream = stream
112+
113+
114+
class WebsocketApiOptions(Generic[T]):
115+
"""Options for configuring WebSocket API connections.
116+
117+
:param api_key: A boolean flag indicating whether to include the API key in the request.
118+
:param is_signed: A boolean flag indicating whether the request should be signed.
119+
:param skip_auth: A boolean flag indicating whether to skip authentication.
120+
:param signer: An optional Signers instance for signing requests.
121+
"""
122+
123+
def __init__(
124+
self,
125+
api_key: bool = False,
126+
is_signed: bool = True,
127+
skip_auth: bool = True,
128+
signer: Optional[Signers] = None,
129+
):
130+
self.signer = signer
131+
self.api_key = api_key
132+
self.is_signed = is_signed
133+
self.skip_auth = skip_auth
134+
135+
class WebsocketApiUserDataEndpoints(BaseModel):
136+
"""Represents the WebSocket user data endpoints."""
137+
138+
user_data_stream_subscribe: str
139+
user_data_stream_logout: str

common/src/binance_common/utils.py

Lines changed: 160 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import copy
12
import hashlib
23
import hmac
4+
import importlib
35
import json
46
import re
57
import requests
@@ -9,11 +11,12 @@
911
import uuid
1012

1113
from base64 import b64encode
14+
from collections import OrderedDict
1215
from Crypto.Hash import SHA256
1316
from Crypto.Signature.pkcs1_15 import PKCS115_SigScheme
1417
from enum import Enum
1518
from pydantic import BaseModel
16-
from typing import Dict, List, Optional, Type, TypeVar, Union
19+
from typing import Dict, List, Optional, Type, TypeVar, Union, get_args
1720
from urllib.parse import urlencode
1821
from urllib3.util.ssl_ import create_urllib3_context
1922

@@ -30,7 +33,12 @@
3033
TooManyRequestsError,
3134
UnauthorizedError,
3235
)
33-
from binance_common.models import ApiResponse, RateLimit, WebsocketApiRateLimit
36+
from binance_common.models import (
37+
ApiResponse,
38+
RateLimit,
39+
WebsocketApiOptions,
40+
WebsocketApiRateLimit
41+
)
3442
from binance_common.signature import Signers
3543

3644

@@ -346,8 +354,14 @@ def send_request(
346354
else:
347355
data_function = lambda: response_model.model_validate(parsed)
348356

357+
try:
358+
data_function()
359+
final_data_function = data_function
360+
except Exception:
361+
final_data_function = lambda: parsed
362+
349363
return ApiResponse[T](
350-
data_function=data_function,
364+
data_function=final_data_function,
351365
status=response.status_code,
352366
headers=response.headers,
353367
rate_limits=parse_rate_limit_headers(response.headers),
@@ -506,6 +520,7 @@ def ws_streams_placeholder(stream: str, params: dict = {}) -> str:
506520
stream,
507521
)
508522

523+
509524
def parse_ws_rate_limit_headers(
510525
headers: List[Dict[str, Union[str, int]]],
511526
) -> List[WebsocketApiRateLimit]:
@@ -522,6 +537,7 @@ def parse_ws_rate_limit_headers(
522537
rate_limits.append(WebsocketApiRateLimit(**header))
523538
return rate_limits
524539

540+
525541
def normalize_query_values(parsed, expected_types=None):
526542
"""Normalizes the values in a parsed query dictionary.
527543
@@ -563,3 +579,144 @@ def convert(val, expected_type=None):
563579
normalized[k] = converted[0] if len(converted) == 1 else converted
564580

565581
return normalized
582+
583+
584+
def ws_api_payload(config, payload: Dict, websocket_options: WebsocketApiOptions):
585+
"""Generate payload for websocket API
586+
587+
Args:
588+
config: The API configuration.
589+
payload (Dict): The payload to send.
590+
websocket_options (WebsocketApiOptions): The WebSocket API options.
591+
592+
Returns:
593+
Dict: The generated payload for the WebSocket API.
594+
"""
595+
596+
payload = copy.copy(payload)
597+
598+
if websocket_options.api_key and websocket_options.skip_auth is False:
599+
payload["params"]["apiKey"] = config.api_key
600+
601+
payload["id"] = payload["id"] if "id" in payload else get_uuid()
602+
603+
payload["params"] = {
604+
snake_to_camel(k): json.dumps(make_serializable(v), separators=(",", ":"))
605+
if isinstance(v, (list, dict))
606+
else make_serializable(v)
607+
for k, v in payload["params"].items()
608+
}
609+
610+
if websocket_options.is_signed:
611+
if websocket_options.skip_auth is True:
612+
payload["params"]["timestamp"] = get_timestamp()
613+
else:
614+
payload["params"] = websocket_api_signature(config, payload["params"], websocket_options.signer)
615+
616+
return payload
617+
618+
619+
def websocket_api_signature(config, payload: Optional[Dict] = {}, signer: Signers = None) -> dict:
620+
"""Generate signature for websocket API
621+
622+
Args:
623+
payload (Optional[Dict]): Payload.
624+
signer (Signers): Signer for the payload.
625+
Returns:
626+
dict: The generated signature for the WebSocket API.
627+
"""
628+
629+
payload["apiKey"] = config.api_key
630+
payload["timestamp"] = get_timestamp()
631+
parameters = OrderedDict(sorted(payload.items()))
632+
parameters["signature"] = get_signature(
633+
config, urlencode(parameters), signer
634+
)
635+
return parameters
636+
637+
638+
def get_validator_field_map(response_model_cls: Type[BaseModel]) -> dict[str, str]:
639+
"""Map User Data response schema class name to oneof_schema_N_validator field dynamically
640+
641+
Args:
642+
response_model_cls (Type[BaseModel]): The response model class.
643+
644+
Returns:
645+
dict[str, str]: The mapping of field names to validator field names.
646+
"""
647+
648+
field_map = {}
649+
annotations = getattr(response_model_cls, "__annotations__", {})
650+
651+
for field_name, annotation in annotations.items():
652+
if field_name.startswith("oneof_schema_") and field_name.endswith("_validator"):
653+
if getattr(annotation, "__origin__", None) is Union:
654+
type_ = next((arg for arg in get_args(annotation) if arg is not type(None)), None)
655+
else:
656+
type_ = annotation
657+
if isinstance(type_, str):
658+
type_ = re.sub(r'^Optional\[(.*)\]$', r'\1', type_)
659+
660+
if type_:
661+
field_map[type_] = field_name
662+
663+
return field_map
664+
665+
666+
def resolve_model_from_event(response_model_cls: Type[BaseModel], event_name: str) -> Type[BaseModel]:
667+
"""Resolve the correct model class for the websocket event dynamically.
668+
669+
Args:
670+
response_model_cls (Type[BaseModel]): The response model class.
671+
event_name (str): The name of the event.
672+
673+
Returns:
674+
Type[BaseModel]: The resolved model class or None.
675+
"""
676+
677+
if not event_name:
678+
return None
679+
680+
one_of_schemas_field = response_model_cls.__pydantic_fields__.get("one_of_schemas")
681+
if not one_of_schemas_field:
682+
return None
683+
684+
one_of_schemas = list(one_of_schemas_field.default)
685+
event_to_class_map = {name[0].lower() + name[1:]: name for name in one_of_schemas}
686+
687+
class_name = event_to_class_map.get(event_name)
688+
if not class_name:
689+
return None
690+
691+
module = importlib.import_module(response_model_cls.__module__)
692+
return getattr(module, class_name, None)
693+
694+
695+
def parse_user_event(payload: dict, response_model_cls: Type[BaseModel]) -> BaseModel:
696+
"""Parse user event to response model.
697+
698+
Args:
699+
payload (dict): The payload dictionary containing event data.
700+
response_model_cls (Type[BaseModel]): The response model class.
701+
702+
Returns:
703+
BaseModel: The parsed response model instance.
704+
"""
705+
706+
event_name = payload.get("e")
707+
model_cls = resolve_model_from_event(response_model_cls, event_name)
708+
709+
if not model_cls:
710+
return response_model_cls(actual_instance=payload)
711+
712+
try:
713+
instance = model_cls.model_validate(payload)
714+
validator_field_map = get_validator_field_map(response_model_cls)
715+
kwargs = {"actual_instance": instance}
716+
if validator_field := validator_field_map.get(model_cls.__name__):
717+
kwargs[validator_field] = instance
718+
return response_model_cls(**kwargs)
719+
720+
except Exception as e:
721+
print(f"Failed to parse {event_name}: {e}")
722+
return response_model_cls(actual_instance=payload)

0 commit comments

Comments
 (0)