Skip to content

Commit b605400

Browse files
WS: structured subscribe helpers (generic param builder) (#179)
* feat(ws): structured subscribe helpers via generic param builder Add build_param() that assembles a wire subscribe param from keyword tokens named after the generated WS_CHANNELS[path]['param'] format string. Generic (registry-driven), zero per-channel code: new channels get structured helpers for free. Token->kwarg rule: all-upper tokens lower-cased (SYMBOL->symbol), others verbatim (exchange, tokenId, srcQuote). Handles @/: separators and the optional [@...] both-or-neither group. Wire into Subscription/MarketSubscription subscribe+unsubscribe; raw positional path unchanged, mixing raw+tokens rejected. * test(ws): cover build_param + structured subscribe Unit-test build_param for every format (forex SYMBOL, SYMBOL@exchange, ticker with/without optional currency@conversionBase, premium 7 tokens), the SYMBOL->symbol lowercase rule, and error cases (missing required, unknown token, partial optional group, None channel, mixing raw+tokens). Round-trip: structured == raw wire string. Integration: structured ws.ticker.subscribe(...) sends BTC-USDT@binance over a fake conn.
1 parent 00ddc3e commit b605400

2 files changed

Lines changed: 290 additions & 13 deletions

File tree

datamaxi/aio/ws.py

Lines changed: 142 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,14 @@
1010
from datamaxi.aio.ws import AsyncDatamaxiWS
1111
1212
async with AsyncDatamaxiWS(api_key="...") as ws:
13+
# raw wire param ...
1314
async for msg in ws.ticker.subscribe("BTC-USDT@binance", market="spot"):
1415
print(msg["s"], msg["p"])
1516
16-
async for oi in ws.open_interest.subscribe("BTC-USDT@binance"):
17+
# ... or structured tokens named after the channel's param_format
18+
async for oi in ws.open_interest.subscribe(
19+
symbol="BTC-USDT", exchange="binance"
20+
):
1721
...
1822
1923
Channels (accessors driven by the generated ``WS_CHANNELS`` registry):
@@ -217,6 +221,112 @@ def _require_channel(path: str) -> str:
217221
return path
218222

219223

224+
def _token_kwarg(token: str) -> str:
225+
"""Map a raw format token to its kwarg name.
226+
227+
Rule: lower-case a token only when it is entirely upper-case
228+
(``SYMBOL`` -> ``symbol``); every other token is used verbatim
229+
(``exchange``, ``tokenId``, ``srcQuote`` ...).
230+
"""
231+
return token.lower() if token.isupper() else token
232+
233+
234+
def build_param(param_format: Optional[str], **tokens: str) -> str:
235+
"""Assemble a wire subscribe param from named tokens per a channel's format.
236+
237+
``param_format`` is the generated ``WS_CHANNELS[path]["param"]`` string, e.g.
238+
``"SYMBOL@exchange"`` or ``"src:tgt:tokenId:srcQuote:tgtQuote:srcMkt:tgtMkt"``.
239+
Keyword names ARE the raw format tokens, with the single :func:`_token_kwarg`
240+
rule (only an all-upper token is lower-cased, so ``SYMBOL`` -> ``symbol``;
241+
``exchange``/``tokenId``/``srcQuote`` stay verbatim).
242+
243+
Grammar handled:
244+
245+
* ``None`` -> the channel takes no params; structured kwargs are rejected.
246+
* a single token (``SYMBOL``, forex) -> no separator.
247+
* ``@``- or ``:``-separated tokens; the separator is inferred from the format.
248+
* a trailing ``[...]`` group is optional and both-or-neither: supply every
249+
token in it or none.
250+
251+
Raises ``ValueError`` for an unknown token, a missing required token, or a
252+
partially-supplied optional group; the message echoes ``param_format``.
253+
254+
Examples::
255+
256+
build_param("SYMBOL", symbol="USD-KRW") # "USD-KRW"
257+
build_param("SYMBOL@exchange", symbol="BTC-USDT", exchange="binance")
258+
# -> "BTC-USDT@binance"
259+
build_param(
260+
"src:tgt:tokenId:srcQuote:tgtQuote:srcMkt:tgtMkt",
261+
src="binance", tgt="upbit", tokenId="bitcoin",
262+
srcQuote="USDT", tgtQuote="KRW", srcMkt="spot", tgtMkt="spot",
263+
) # -> "binance:upbit:bitcoin:USDT:KRW:spot:spot"
264+
"""
265+
if param_format is None:
266+
raise ValueError(
267+
"this channel takes no subscribe params; call subscribe() without "
268+
"keyword tokens"
269+
)
270+
271+
sep = "@" if "@" in param_format else ":" if ":" in param_format else ""
272+
required_fmt, _, optional_fmt = param_format.partition("[")
273+
optional_fmt = optional_fmt.rstrip("]")
274+
275+
def _split(section: str) -> List[str]:
276+
section = section.strip(sep)
277+
if not section:
278+
return []
279+
return section.split(sep) if sep else [section]
280+
281+
required_kw = [_token_kwarg(t) for t in _split(required_fmt)]
282+
optional_kw = [_token_kwarg(t) for t in _split(optional_fmt)]
283+
valid = required_kw + optional_kw
284+
285+
unknown = [k for k in tokens if k not in valid]
286+
if unknown:
287+
raise ValueError(
288+
f"unknown subscribe token(s) {unknown} for format {param_format!r}; "
289+
f"valid tokens: {valid}"
290+
)
291+
292+
missing = [k for k in required_kw if k not in tokens]
293+
if missing:
294+
raise ValueError(
295+
f"missing required subscribe token(s) {missing} for format "
296+
f"{param_format!r}"
297+
)
298+
299+
supplied_optional = [k for k in optional_kw if k in tokens]
300+
if supplied_optional and len(supplied_optional) != len(optional_kw):
301+
raise ValueError(
302+
f"optional token group {optional_kw} is both-or-neither for format "
303+
f"{param_format!r}; got only {supplied_optional}"
304+
)
305+
306+
values = [str(tokens[k]) for k in required_kw + supplied_optional]
307+
return sep.join(values)
308+
309+
310+
def _resolve_params(
311+
param_format: Optional[str],
312+
params: tuple,
313+
tokens: Dict[str, str],
314+
) -> List[str]:
315+
"""Resolve a subscribe/unsubscribe call to a list of wire param strings.
316+
317+
Raw positional ``params`` pass through unchanged (the backward-compatible
318+
path). Keyword ``tokens`` build exactly one param via :func:`build_param`.
319+
Mixing both is rejected.
320+
"""
321+
if params and tokens:
322+
raise ValueError(
323+
"pass either raw positional params or keyword tokens, not both"
324+
)
325+
if tokens:
326+
return [build_param(param_format, **tokens)]
327+
return list(params)
328+
329+
220330
class Subscription:
221331
"""A single-path subscribable channel (``ws.forex``, ``ws.premium``, ...).
222332
@@ -232,16 +342,27 @@ def __init__(self, client: "AsyncDatamaxiWS", path: str):
232342
def param_format(self) -> Optional[str]:
233343
return WS_CHANNELS[self._path].get("param")
234344

235-
async def subscribe(self, *params: str) -> AsyncIterator[Dict[str, Any]]:
236-
"""SUBSCRIBE to ``params``; return an async iterator over the channel."""
345+
async def subscribe(
346+
self, *params: str, **tokens: str
347+
) -> AsyncIterator[Dict[str, Any]]:
348+
"""SUBSCRIBE and return an async iterator over the channel.
349+
350+
Two forms: pass raw wire ``params`` positionally
351+
(``subscribe("BTC-USDT@binance")``), or pass structured keyword
352+
``tokens`` named after this channel's :attr:`param_format` to build one
353+
param (``subscribe(symbol="BTC-USDT", exchange="binance")``). See
354+
:func:`build_param`. The two forms are mutually exclusive.
355+
"""
356+
resolved = _resolve_params(self.param_format, params, tokens)
237357
conn = await self._client._conn(self._path)
238358
stream = conn.stream() # register the queue before SUBSCRIBE (no missed msgs)
239-
await conn.subscribe(list(params))
359+
await conn.subscribe(resolved)
240360
return stream
241361

242-
async def unsubscribe(self, *params: str) -> None:
362+
async def unsubscribe(self, *params: str, **tokens: str) -> None:
363+
resolved = _resolve_params(self.param_format, params, tokens)
243364
conn = await self._client._conn(self._path)
244-
await conn.unsubscribe(list(params))
365+
await conn.unsubscribe(resolved)
245366

246367

247368
class MarketSubscription:
@@ -259,16 +380,25 @@ def _path(self, market: str) -> str:
259380
return path
260381

261382
async def subscribe(
262-
self, *params: str, market: str = "spot"
383+
self, *params: str, market: str = "spot", **tokens: str
263384
) -> AsyncIterator[Dict[str, Any]]:
264-
conn = await self._client._conn(self._path(market))
385+
"""SUBSCRIBE on ``market``; ``params`` or structured ``tokens`` (see
386+
:meth:`Subscription.subscribe`). ``market`` is a control kwarg, not a
387+
param token."""
388+
path = self._path(market)
389+
resolved = _resolve_params(WS_CHANNELS[path].get("param"), params, tokens)
390+
conn = await self._client._conn(path)
265391
stream = conn.stream()
266-
await conn.subscribe(list(params))
392+
await conn.subscribe(resolved)
267393
return stream
268394

269-
async def unsubscribe(self, *params: str, market: str = "spot") -> None:
270-
conn = await self._client._conn(self._path(market))
271-
await conn.unsubscribe(list(params))
395+
async def unsubscribe(
396+
self, *params: str, market: str = "spot", **tokens: str
397+
) -> None:
398+
path = self._path(market)
399+
resolved = _resolve_params(WS_CHANNELS[path].get("param"), params, tokens)
400+
conn = await self._client._conn(path)
401+
await conn.unsubscribe(resolved)
272402

273403

274404
class Feed:

tests/test_ws.py

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
websockets = pytest.importorskip("websockets")
1515

1616
import datamaxi._ws_models as _ws_models # noqa: E402
17-
from datamaxi.aio.ws import AsyncDatamaxiWS # noqa: E402
17+
from datamaxi.aio.ws import AsyncDatamaxiWS, build_param # noqa: E402
1818
from datamaxi._ws_endpoints import WS_CHANNELS, WS_BASE_PATH # noqa: E402
1919
from datamaxi._ws_models import TickerMessage # noqa: E402
2020

@@ -281,3 +281,150 @@ async def run():
281281
assert len(seen) >= 2
282282
assert seen[0] == ["USD-KRW"]
283283
assert seen[1] == ["USD-KRW"] # `_open` replays sorted(self._active)
284+
285+
286+
# --- structured subscribe helpers: build_param unit tests ---
287+
288+
289+
def test_build_param_forex_single_token():
290+
# SYMBOL: no separator, one token; and the SYMBOL->symbol lowercase rule.
291+
assert build_param("SYMBOL", symbol="USD-KRW") == "USD-KRW"
292+
293+
294+
def test_build_param_symbol_exchange():
295+
assert (
296+
build_param("SYMBOL@exchange", symbol="BTC-USDT", exchange="binance")
297+
== "BTC-USDT@binance"
298+
)
299+
300+
301+
def test_build_param_ticker_without_optional_group():
302+
fmt = "SYMBOL@exchange[@currency@conversionBase]"
303+
assert build_param(fmt, symbol="BTC-USDT", exchange="binance") == "BTC-USDT@binance"
304+
305+
306+
def test_build_param_ticker_with_optional_group():
307+
fmt = "SYMBOL@exchange[@currency@conversionBase]"
308+
assert (
309+
build_param(
310+
fmt,
311+
symbol="BTC-USDT",
312+
exchange="binance",
313+
currency="KRW",
314+
conversionBase="USDT",
315+
)
316+
== "BTC-USDT@binance@KRW@USDT"
317+
)
318+
319+
320+
def test_build_param_premium_all_seven_tokens():
321+
fmt = "src:tgt:tokenId:srcQuote:tgtQuote:srcMkt:tgtMkt"
322+
assert (
323+
build_param(
324+
fmt,
325+
src="binance",
326+
tgt="upbit",
327+
tokenId="bitcoin",
328+
srcQuote="USDT",
329+
tgtQuote="KRW",
330+
srcMkt="spot",
331+
tgtMkt="spot",
332+
)
333+
== "binance:upbit:bitcoin:USDT:KRW:spot:spot"
334+
)
335+
336+
337+
def test_build_param_verbatim_token_not_lowercased():
338+
# tokenId is mixed-case, so it stays verbatim (not lowercased).
339+
with pytest.raises(ValueError):
340+
build_param(
341+
"src:tgt:tokenId:srcQuote:tgtQuote:srcMkt:tgtMkt",
342+
src="a",
343+
tgt="b",
344+
tokenid="c", # wrong: lowercased key
345+
srcQuote="d",
346+
tgtQuote="e",
347+
srcMkt="f",
348+
tgtMkt="g",
349+
)
350+
351+
352+
def test_build_param_none_channel_rejects_tokens():
353+
with pytest.raises(ValueError):
354+
build_param(None, symbol="X")
355+
356+
357+
def test_build_param_missing_required_token():
358+
with pytest.raises(ValueError) as ei:
359+
build_param("SYMBOL@exchange", symbol="BTC-USDT")
360+
assert "exchange" in str(ei.value)
361+
362+
363+
def test_build_param_unknown_token():
364+
with pytest.raises(ValueError) as ei:
365+
build_param("SYMBOL@exchange", symbol="BTC-USDT", exchange="binance", bogus="x")
366+
assert "bogus" in str(ei.value)
367+
368+
369+
def test_build_param_partial_optional_group_rejected():
370+
fmt = "SYMBOL@exchange[@currency@conversionBase]"
371+
with pytest.raises(ValueError):
372+
build_param(fmt, symbol="BTC-USDT", exchange="binance", currency="KRW")
373+
374+
375+
def test_build_param_roundtrip_matches_raw_ticker():
376+
fmt = WS_CHANNELS["/ticker/spot"]["param"]
377+
assert build_param(fmt, symbol="BTC-USDT", exchange="binance") == "BTC-USDT@binance"
378+
379+
380+
def test_build_param_roundtrip_matches_raw_premium():
381+
fmt = WS_CHANNELS["/premium"]["param"]
382+
assert (
383+
build_param(
384+
fmt,
385+
src="binance",
386+
tgt="upbit",
387+
tokenId="bitcoin",
388+
srcQuote="USDT",
389+
tgtQuote="KRW",
390+
srcMkt="spot",
391+
tgtMkt="spot",
392+
)
393+
== "binance:upbit:bitcoin:USDT:KRW:spot:spot"
394+
)
395+
396+
397+
def test_ws_structured_subscribe_produces_expected_wire_param():
398+
# A structured subscribe(...) call sends the same wire param as the raw form.
399+
async def handler(conn):
400+
async for raw in conn:
401+
m = json.loads(raw)
402+
if m.get("method") == "SUBSCRIBE":
403+
await conn.send(json.dumps({"result": m["params"], "id": m["id"]}))
404+
for p in m["params"]:
405+
sym, exch = p.split("@")[0], p.split("@")[1]
406+
await conn.send(
407+
json.dumps({"s": sym, "e": exch, "p": 1.0, "d": 1, "_param": p})
408+
)
409+
410+
async def run():
411+
async with _serve(handler) as server:
412+
async with AsyncDatamaxiWS(
413+
api_key="k", ws_url=f"ws://localhost:{_port(server)}"
414+
) as ws:
415+
stream = await ws.ticker.subscribe(
416+
symbol="BTC-USDT", exchange="binance", market="spot"
417+
)
418+
return await _first(stream)
419+
420+
msg = _run(run())
421+
assert msg["_param"] == "BTC-USDT@binance"
422+
423+
424+
def test_ws_structured_subscribe_mixing_raw_and_tokens_raises():
425+
async def run():
426+
async with AsyncDatamaxiWS(api_key="k", ws_url="ws://localhost:1") as ws:
427+
await ws.forex.subscribe("USD-KRW", symbol="USD-KRW")
428+
429+
with pytest.raises(ValueError):
430+
_run(run())

0 commit comments

Comments
 (0)