pipsocket 0.1.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.
- pipsocket/__init__.py +68 -0
- pipsocket/_core.py +712 -0
- pipsocket/_generated/__init__.py +1 -0
- pipsocket/_generated/operations.py +35 -0
- pipsocket/_generated/tables.py +95 -0
- pipsocket/_generated/types.py +542 -0
- pipsocket/_http.py +235 -0
- pipsocket/_stream_core.py +916 -0
- pipsocket/_version.py +1 -0
- pipsocket/aio.py +702 -0
- pipsocket/client.py +680 -0
- pipsocket/errors.py +275 -0
- pipsocket/models.py +151 -0
- pipsocket/py.typed +0 -0
- pipsocket/stream.py +713 -0
- pipsocket/types.py +48 -0
- pipsocket-0.1.0.dist-info/METADATA +358 -0
- pipsocket-0.1.0.dist-info/RECORD +20 -0
- pipsocket-0.1.0.dist-info/WHEEL +4 -0
- pipsocket-0.1.0.dist-info/licenses/LICENSE +21 -0
pipsocket/stream.py
ADDED
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
"""The stream helper's shells: they do the I/O for the state machine in _stream_core.
|
|
2
|
+
|
|
3
|
+
One engine, on asyncio and websockets, runs the machine: it opens and reads the socket, keeps
|
|
4
|
+
the timers, makes the REST reads and queues events for the caller. `AsyncStream` runs it in the
|
|
5
|
+
caller's event loop; `Stream` runs it on a background thread with its own loop, so a synchronous
|
|
6
|
+
caller can take events at its own pace while the socket is still read promptly (pings and call
|
|
7
|
+
answers need that).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import contextlib
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import threading
|
|
17
|
+
from collections import OrderedDict, deque
|
|
18
|
+
from collections.abc import Awaitable, Callable, Iterable
|
|
19
|
+
from types import TracebackType
|
|
20
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
21
|
+
|
|
22
|
+
from websockets.asyncio.client import ClientConnection, connect
|
|
23
|
+
from websockets.exceptions import ConnectionClosed, InvalidStatus, InvalidURI
|
|
24
|
+
|
|
25
|
+
from . import _stream_core as core
|
|
26
|
+
from ._core import refusal_from_body, segment
|
|
27
|
+
from ._http import USER_AGENT, Options
|
|
28
|
+
from .errors import (
|
|
29
|
+
AuthenticationError,
|
|
30
|
+
NotFound,
|
|
31
|
+
PipsocketError,
|
|
32
|
+
StreamError,
|
|
33
|
+
StreamHandshakeError,
|
|
34
|
+
)
|
|
35
|
+
from .models import FINAL_STATES
|
|
36
|
+
|
|
37
|
+
__all__ = ["AsyncStream", "Stream"]
|
|
38
|
+
|
|
39
|
+
# Events the caller hasn't taken yet. When this many wait, the queue is emptied and the stream
|
|
40
|
+
# resyncs (reason "overflow").
|
|
41
|
+
DEFAULT_QUEUE_SIZE = 1000
|
|
42
|
+
# How long one upgrade may take.
|
|
43
|
+
OPEN_TIMEOUT = 10.0
|
|
44
|
+
|
|
45
|
+
Event = dict[str, Any]
|
|
46
|
+
Reader = Callable[[core.Read], Awaitable[Any]]
|
|
47
|
+
Watcher = Callable[[Event], None]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _End:
|
|
51
|
+
"""Marks the end of the events: closed by the caller, or stopped with an error."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, error: PipsocketError | None) -> None:
|
|
54
|
+
self.error = error
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _Crashed:
|
|
58
|
+
"""A spawned task's unexpected failure, queued for the engine's loop to end the stream."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, error: Exception) -> None:
|
|
61
|
+
self.error = error
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Sink(Protocol):
|
|
65
|
+
"""Where the engine puts events for the caller."""
|
|
66
|
+
|
|
67
|
+
def put(self, item: Event | _End) -> list[Event]:
|
|
68
|
+
"""Queue an item. If the queue was full it is emptied, and everything in it, with this
|
|
69
|
+
item, is returned: the events the caller will never get."""
|
|
70
|
+
...
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Settled:
|
|
74
|
+
"""Recorded requests the stream has seen settle, and the wait() calls watching for them.
|
|
75
|
+
Shared with the caller's thread, hence the lock."""
|
|
76
|
+
|
|
77
|
+
REMEMBERED = 1000
|
|
78
|
+
|
|
79
|
+
def __init__(self) -> None:
|
|
80
|
+
self.lock = threading.Lock()
|
|
81
|
+
self.finals: OrderedDict[str, Event] = OrderedDict()
|
|
82
|
+
self.watchers: dict[str, set[Watcher]] = {}
|
|
83
|
+
|
|
84
|
+
def saw(self, body: Event) -> None:
|
|
85
|
+
request_id = body.get("request")
|
|
86
|
+
if not isinstance(request_id, str):
|
|
87
|
+
return
|
|
88
|
+
with self.lock:
|
|
89
|
+
if body.get("state") in FINAL_STATES:
|
|
90
|
+
self.finals[request_id] = body
|
|
91
|
+
self.finals.move_to_end(request_id)
|
|
92
|
+
while len(self.finals) > self.REMEMBERED:
|
|
93
|
+
self.finals.popitem(last=False)
|
|
94
|
+
watchers = list(self.watchers.get(request_id, ()))
|
|
95
|
+
for watcher in watchers:
|
|
96
|
+
watcher(body)
|
|
97
|
+
|
|
98
|
+
def final(self, request_id: str) -> Event | None:
|
|
99
|
+
with self.lock:
|
|
100
|
+
return self.finals.get(request_id)
|
|
101
|
+
|
|
102
|
+
def watch(self, request_id: str, watcher: Watcher) -> bool:
|
|
103
|
+
"""Add a watcher, and give it at once a settlement already seen (registering and
|
|
104
|
+
reading the cache under one lock, so none slips between). True if it is the first
|
|
105
|
+
watcher for this id."""
|
|
106
|
+
with self.lock:
|
|
107
|
+
watchers = self.watchers.setdefault(request_id, set())
|
|
108
|
+
first = not watchers
|
|
109
|
+
watchers.add(watcher)
|
|
110
|
+
known = self.finals.get(request_id)
|
|
111
|
+
if known is not None:
|
|
112
|
+
watcher(known)
|
|
113
|
+
return first
|
|
114
|
+
|
|
115
|
+
def unwatch(self, request_id: str, watcher: Watcher) -> bool:
|
|
116
|
+
"""Remove a watcher. True if it was the last for this id."""
|
|
117
|
+
with self.lock:
|
|
118
|
+
watchers = self.watchers.get(request_id, set())
|
|
119
|
+
watchers.discard(watcher)
|
|
120
|
+
if watchers:
|
|
121
|
+
return False
|
|
122
|
+
self.watchers.pop(request_id, None)
|
|
123
|
+
return True
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if TYPE_CHECKING:
|
|
127
|
+
_LoggerAdapter = logging.LoggerAdapter[logging.Logger]
|
|
128
|
+
else: # not subscriptable at run time before Python 3.11
|
|
129
|
+
_LoggerAdapter = logging.LoggerAdapter
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class _Redacted(_LoggerAdapter):
|
|
133
|
+
"""websockets' logger, with the API key taken out of everything it logs (it logs the
|
|
134
|
+
upgrade's headers at debug level)."""
|
|
135
|
+
|
|
136
|
+
def __init__(self, logger: logging.Logger, secret: str) -> None:
|
|
137
|
+
super().__init__(logger, {})
|
|
138
|
+
self.secret = secret
|
|
139
|
+
|
|
140
|
+
def log(self, level: int, msg: object, *args: object, **kwargs: Any) -> None:
|
|
141
|
+
if self.isEnabledFor(level):
|
|
142
|
+
msg = self.redact(msg)
|
|
143
|
+
args = tuple(self.redact(arg) for arg in args)
|
|
144
|
+
super().log(level, msg, *args, **kwargs)
|
|
145
|
+
|
|
146
|
+
def redact(self, value: object) -> object:
|
|
147
|
+
if isinstance(value, (str, bytes)) and self.secret in str(value):
|
|
148
|
+
return str(value).replace(self.secret, "[api key]")
|
|
149
|
+
return value
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class Engine:
|
|
153
|
+
"""Runs the machine: feeds it inputs and carries out its actions. One per stream."""
|
|
154
|
+
|
|
155
|
+
def __init__(
|
|
156
|
+
self,
|
|
157
|
+
*,
|
|
158
|
+
url: str,
|
|
159
|
+
api_key: str,
|
|
160
|
+
machine: core.StreamMachine,
|
|
161
|
+
reader: Reader,
|
|
162
|
+
sink: Sink,
|
|
163
|
+
settled: Settled,
|
|
164
|
+
) -> None:
|
|
165
|
+
self.url = url
|
|
166
|
+
self.api_key = api_key
|
|
167
|
+
self.machine = machine
|
|
168
|
+
self.reader = reader
|
|
169
|
+
self.sink = sink
|
|
170
|
+
self.settled = settled
|
|
171
|
+
self.inputs: asyncio.Queue[core.Input | _Crashed] = asyncio.Queue()
|
|
172
|
+
self.socket: ClientConnection | None = None
|
|
173
|
+
self.socket_generation = 0
|
|
174
|
+
self.tasks: set[asyncio.Task[Any]] = set()
|
|
175
|
+
# One handle per timer name: starting a timer replaces (cancels) the one before.
|
|
176
|
+
self.timers: dict[str, asyncio.TimerHandle] = {}
|
|
177
|
+
self.dropped: list[Event] | None = None
|
|
178
|
+
self.finished = False
|
|
179
|
+
self.logger = _Redacted(logging.getLogger("websockets.client"), api_key)
|
|
180
|
+
|
|
181
|
+
def feed(self, event: core.Input) -> None:
|
|
182
|
+
self.inputs.put_nowait(event)
|
|
183
|
+
|
|
184
|
+
async def run(self) -> None:
|
|
185
|
+
self.feed(core.Start())
|
|
186
|
+
try:
|
|
187
|
+
next_event: core.Input | None = None
|
|
188
|
+
while not self.finished:
|
|
189
|
+
event = next_event or await self.inputs.get()
|
|
190
|
+
next_event = None
|
|
191
|
+
if isinstance(event, _Crashed):
|
|
192
|
+
raise event.error
|
|
193
|
+
self.dropped = None
|
|
194
|
+
for action in self.machine.handle(event):
|
|
195
|
+
await self.do(action)
|
|
196
|
+
if self.dropped is not None:
|
|
197
|
+
# The queue overflowed: the rest of this batch is dropped with it, and the
|
|
198
|
+
# machine hears of it before any other input, so no event reaches the
|
|
199
|
+
# caller (or moves the checkpoint) ahead of the resync that replaces them.
|
|
200
|
+
next_event = core.Overflowed(tuple(self.dropped))
|
|
201
|
+
if isinstance(event, core.Close):
|
|
202
|
+
self.finish(None)
|
|
203
|
+
except Exception as error:
|
|
204
|
+
# A failure of the SDK's own: end the stream with it, so the caller hears of it.
|
|
205
|
+
self.finish(unexpected(error))
|
|
206
|
+
finally:
|
|
207
|
+
await self.shut()
|
|
208
|
+
|
|
209
|
+
def finish(self, error: PipsocketError | None) -> None:
|
|
210
|
+
if not self.finished:
|
|
211
|
+
self.finished = True
|
|
212
|
+
self.sink.put(_End(error))
|
|
213
|
+
|
|
214
|
+
async def shut(self) -> None:
|
|
215
|
+
for handle in self.timers.values():
|
|
216
|
+
handle.cancel()
|
|
217
|
+
for task in list(self.tasks):
|
|
218
|
+
task.cancel()
|
|
219
|
+
await self.drop_socket()
|
|
220
|
+
|
|
221
|
+
def spawn(self, work: Awaitable[Any]) -> None:
|
|
222
|
+
task = asyncio.ensure_future(work)
|
|
223
|
+
self.tasks.add(task)
|
|
224
|
+
task.add_done_callback(self.supervise)
|
|
225
|
+
|
|
226
|
+
def supervise(self, task: asyncio.Task[Any]) -> None:
|
|
227
|
+
"""A spawned task (opening, listening, a read) that fails unexpectedly ends the stream:
|
|
228
|
+
its own expected failures are already inputs to the machine."""
|
|
229
|
+
self.tasks.discard(task)
|
|
230
|
+
if task.cancelled():
|
|
231
|
+
return
|
|
232
|
+
error = task.exception()
|
|
233
|
+
if isinstance(error, Exception):
|
|
234
|
+
self.inputs.put_nowait(_Crashed(error))
|
|
235
|
+
|
|
236
|
+
async def do(self, action: core.Action) -> None:
|
|
237
|
+
match action:
|
|
238
|
+
case core.Connect():
|
|
239
|
+
self.socket_generation += 1
|
|
240
|
+
self.spawn(self.open(self.socket_generation))
|
|
241
|
+
case core.Disconnect():
|
|
242
|
+
await self.drop_socket()
|
|
243
|
+
case core.Send(text=text):
|
|
244
|
+
if self.socket is not None:
|
|
245
|
+
with contextlib.suppress(ConnectionClosed):
|
|
246
|
+
await self.socket.send(text)
|
|
247
|
+
case core.StartTimer(name=name, seconds=seconds, generation=generation):
|
|
248
|
+
previous = self.timers.get(name)
|
|
249
|
+
if previous is not None:
|
|
250
|
+
previous.cancel()
|
|
251
|
+
timer = core.Timer(name, generation)
|
|
252
|
+
self.timers[name] = asyncio.get_running_loop().call_later(seconds, self.feed, timer)
|
|
253
|
+
case core.Read():
|
|
254
|
+
self.spawn(self.read(action))
|
|
255
|
+
case core.Emit(event=event):
|
|
256
|
+
if self.dropped is not None:
|
|
257
|
+
self.dropped.append(event)
|
|
258
|
+
else:
|
|
259
|
+
dropped = self.sink.put(event)
|
|
260
|
+
if dropped:
|
|
261
|
+
self.dropped = dropped
|
|
262
|
+
case core.Notify(body=body):
|
|
263
|
+
self.settled.saw(body)
|
|
264
|
+
case core.Fail(error=error):
|
|
265
|
+
self.finish(error)
|
|
266
|
+
|
|
267
|
+
async def open(self, generation: int) -> None:
|
|
268
|
+
try:
|
|
269
|
+
socket = await connect(
|
|
270
|
+
self.url,
|
|
271
|
+
additional_headers={"Authorization": f"Bearer {self.api_key}"},
|
|
272
|
+
user_agent_header=USER_AGENT,
|
|
273
|
+
ping_interval=None,
|
|
274
|
+
open_timeout=OPEN_TIMEOUT,
|
|
275
|
+
logger=self.logger,
|
|
276
|
+
)
|
|
277
|
+
except InvalidStatus as refused:
|
|
278
|
+
status = refused.response.status_code
|
|
279
|
+
self.feed(core.Refused(status, handshake_error(status, bytes(refused.response.body))))
|
|
280
|
+
return
|
|
281
|
+
except InvalidURI as error:
|
|
282
|
+
self.feed(core.Refused(0, StreamHandshakeError(str(error), status=None)))
|
|
283
|
+
return
|
|
284
|
+
except (OSError, TimeoutError, ConnectionClosed, Exception) as error:
|
|
285
|
+
self.feed(core.Unreachable(error))
|
|
286
|
+
return
|
|
287
|
+
if generation != self.socket_generation or self.finished:
|
|
288
|
+
await socket.close()
|
|
289
|
+
return
|
|
290
|
+
self.socket = socket
|
|
291
|
+
self.feed(core.Opened())
|
|
292
|
+
self.spawn(self.listen(socket, generation))
|
|
293
|
+
|
|
294
|
+
async def listen(self, socket: ClientConnection, generation: int) -> None:
|
|
295
|
+
try:
|
|
296
|
+
async for raw in socket:
|
|
297
|
+
text = raw if isinstance(raw, str) else raw.decode("utf-8", "replace")
|
|
298
|
+
if generation != self.socket_generation:
|
|
299
|
+
return
|
|
300
|
+
self.feed(parse(text))
|
|
301
|
+
except ConnectionClosed:
|
|
302
|
+
pass
|
|
303
|
+
if generation == self.socket_generation:
|
|
304
|
+
self.feed(core.Closed())
|
|
305
|
+
|
|
306
|
+
async def drop_socket(self) -> None:
|
|
307
|
+
socket, self.socket = self.socket, None
|
|
308
|
+
self.socket_generation += 1
|
|
309
|
+
if socket is not None:
|
|
310
|
+
with contextlib.suppress(Exception):
|
|
311
|
+
await socket.close()
|
|
312
|
+
|
|
313
|
+
async def read(self, read: core.Read) -> None:
|
|
314
|
+
try:
|
|
315
|
+
value = await self.reader(read)
|
|
316
|
+
except PipsocketError as error:
|
|
317
|
+
self.feed(core.Answered(read, error=error))
|
|
318
|
+
except Exception as error:
|
|
319
|
+
failure = StreamError(f"A read the stream made failed: {error}")
|
|
320
|
+
failure.__cause__ = error
|
|
321
|
+
self.feed(core.Answered(read, error=failure))
|
|
322
|
+
else:
|
|
323
|
+
self.feed(core.Answered(read, value=value))
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def unexpected(error: Exception) -> StreamError:
|
|
327
|
+
failure = StreamError(f"The stream stopped unexpectedly: {error!r}")
|
|
328
|
+
failure.__cause__ = error
|
|
329
|
+
return failure
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def parse(text: str) -> core.Input:
|
|
333
|
+
try:
|
|
334
|
+
body = json.loads(text)
|
|
335
|
+
except ValueError:
|
|
336
|
+
return core.Text(text)
|
|
337
|
+
if isinstance(body, dict):
|
|
338
|
+
return core.Message(body) # pyright: ignore[reportUnknownArgumentType]
|
|
339
|
+
return core.Text(text)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def handshake_error(status: int, raw: bytes) -> PipsocketError:
|
|
343
|
+
"""The error for a refused upgrade. A 401 or 404 is the API's refusal, as on REST (the key
|
|
344
|
+
and account checks are the same); anything else is StreamHandshakeError with the status."""
|
|
345
|
+
try:
|
|
346
|
+
body = json.loads(raw)
|
|
347
|
+
except (ValueError, RecursionError):
|
|
348
|
+
body = None
|
|
349
|
+
if status in (401, 404):
|
|
350
|
+
if isinstance(body, dict) and isinstance(body.get("error"), str): # pyright: ignore[reportUnknownMemberType]
|
|
351
|
+
return refusal_from_body(status, body, raw)
|
|
352
|
+
kind = AuthenticationError if status == 401 else NotFound
|
|
353
|
+
message = f"The stream's upgrade was refused ({status})."
|
|
354
|
+
return kind(message, status=status, code=None, body=body, raw=raw)
|
|
355
|
+
return StreamHandshakeError(f"The stream's upgrade was refused ({status}).", status=status)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def stream_url(options: Options, account: str) -> str:
|
|
359
|
+
return f"{options.stream_url}/v1/accounts/{segment(account)}/stream"
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def new_machine(
|
|
363
|
+
options: Options, ticks: Iterable[str], snapshot: bool, queue_size: int
|
|
364
|
+
) -> core.StreamMachine:
|
|
365
|
+
if isinstance(ticks, str):
|
|
366
|
+
raise ValueError("ticks is a list of symbols, not one string.")
|
|
367
|
+
if queue_size < 1:
|
|
368
|
+
raise ValueError("queue_size is 1 or more.")
|
|
369
|
+
return core.StreamMachine(
|
|
370
|
+
ticks=ticks, read_state=snapshot, queue_limit=queue_size, random=options.random
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def sync_reader(account: Any) -> Callable[[core.Read], Any]:
|
|
375
|
+
"""The REST reads the machine asks for, made with a sync account handle."""
|
|
376
|
+
|
|
377
|
+
def read(request: core.Read) -> Any:
|
|
378
|
+
if request.kind == "request":
|
|
379
|
+
return account.requests.get(request.request_id).raw
|
|
380
|
+
return read_with(account, request)
|
|
381
|
+
|
|
382
|
+
return read
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def async_reader(account: Any) -> Reader:
|
|
386
|
+
"""The REST reads the machine asks for, made with an async account handle."""
|
|
387
|
+
|
|
388
|
+
async def read(request: core.Read) -> Any:
|
|
389
|
+
if request.kind == "request":
|
|
390
|
+
return (await account.requests.get(request.request_id)).raw
|
|
391
|
+
return await read_with(account, request)
|
|
392
|
+
|
|
393
|
+
return read
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def read_with(account: Any, request: core.Read) -> Any:
|
|
397
|
+
"""Makes a snapshot or history read on the handle; an async handle returns an awaitable."""
|
|
398
|
+
if request.kind == "account":
|
|
399
|
+
return account.get()
|
|
400
|
+
if request.kind == "positions":
|
|
401
|
+
return account.positions.list()
|
|
402
|
+
if request.kind == "orders":
|
|
403
|
+
return account.orders.list()
|
|
404
|
+
if request.kind == "deals":
|
|
405
|
+
return account.history.deals(request.from_, request.to)
|
|
406
|
+
if request.kind == "history_orders":
|
|
407
|
+
return account.history.orders(request.from_, request.to)
|
|
408
|
+
raise ValueError(f"Unknown read: {request.kind}")
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# The async shell.
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
class _AsyncSink:
|
|
415
|
+
def __init__(self, limit: int) -> None:
|
|
416
|
+
self.items: deque[Event | _End] = deque()
|
|
417
|
+
self.limit = limit
|
|
418
|
+
self.closed = False
|
|
419
|
+
self.ready = asyncio.Event()
|
|
420
|
+
|
|
421
|
+
def put(self, item: Event | _End) -> list[Event]:
|
|
422
|
+
if self.closed:
|
|
423
|
+
return [] # the caller closed the stream: nothing more is delivered
|
|
424
|
+
if not isinstance(item, _End) and len(self.items) >= self.limit:
|
|
425
|
+
dropped = [event for event in self.items if not isinstance(event, _End)] + [item]
|
|
426
|
+
self.items.clear()
|
|
427
|
+
return dropped
|
|
428
|
+
self.items.append(item)
|
|
429
|
+
self.ready.set()
|
|
430
|
+
return []
|
|
431
|
+
|
|
432
|
+
def close(self) -> None:
|
|
433
|
+
"""The caller closed the stream: nothing still queued, or put later, is delivered."""
|
|
434
|
+
self.closed = True
|
|
435
|
+
self.items.clear()
|
|
436
|
+
self.items.append(_End(None))
|
|
437
|
+
self.ready.set()
|
|
438
|
+
|
|
439
|
+
async def get(self) -> Event | _End:
|
|
440
|
+
while not self.items:
|
|
441
|
+
self.ready.clear()
|
|
442
|
+
await self.ready.wait()
|
|
443
|
+
return self.items.popleft()
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
class AsyncStream:
|
|
447
|
+
"""The account's events, as `async with acct.stream(ticks=[...]) as events`, then
|
|
448
|
+
`async for event in events`. Events are the API's messages as they are, plus `resync`."""
|
|
449
|
+
|
|
450
|
+
def __init__(
|
|
451
|
+
self,
|
|
452
|
+
*,
|
|
453
|
+
url: str,
|
|
454
|
+
options: Options,
|
|
455
|
+
machine: core.StreamMachine,
|
|
456
|
+
reader: Reader,
|
|
457
|
+
streams: list[Any],
|
|
458
|
+
) -> None:
|
|
459
|
+
self._sink = _AsyncSink(machine.queue_limit)
|
|
460
|
+
self._settled = Settled()
|
|
461
|
+
self._engine = Engine(
|
|
462
|
+
url=url,
|
|
463
|
+
api_key=options.api_key,
|
|
464
|
+
machine=machine,
|
|
465
|
+
reader=reader,
|
|
466
|
+
sink=self._sink,
|
|
467
|
+
settled=self._settled,
|
|
468
|
+
)
|
|
469
|
+
self._streams = streams
|
|
470
|
+
self._task: asyncio.Task[None] | None = None
|
|
471
|
+
self._ended: _End | None = None
|
|
472
|
+
|
|
473
|
+
async def __aenter__(self) -> AsyncStream:
|
|
474
|
+
self._task = asyncio.ensure_future(self._engine.run())
|
|
475
|
+
self._streams.append(self)
|
|
476
|
+
return self
|
|
477
|
+
|
|
478
|
+
async def __aexit__(
|
|
479
|
+
self,
|
|
480
|
+
exc_type: type[BaseException] | None,
|
|
481
|
+
exc: BaseException | None,
|
|
482
|
+
traceback: TracebackType | None,
|
|
483
|
+
) -> None:
|
|
484
|
+
try:
|
|
485
|
+
await self.close()
|
|
486
|
+
except Exception:
|
|
487
|
+
if exc is None:
|
|
488
|
+
raise
|
|
489
|
+
# An exception is already unwinding the caller's block: it isn't replaced.
|
|
490
|
+
|
|
491
|
+
def __aiter__(self) -> AsyncStream:
|
|
492
|
+
return self
|
|
493
|
+
|
|
494
|
+
async def __anext__(self) -> Event:
|
|
495
|
+
if self._ended is not None:
|
|
496
|
+
return self._end()
|
|
497
|
+
item = await self._sink.get()
|
|
498
|
+
if isinstance(item, _End):
|
|
499
|
+
self._ended = item
|
|
500
|
+
return self._end()
|
|
501
|
+
return item
|
|
502
|
+
|
|
503
|
+
def _end(self) -> Event:
|
|
504
|
+
assert self._ended is not None
|
|
505
|
+
if self._ended.error is not None:
|
|
506
|
+
raise self._ended.error
|
|
507
|
+
raise StopAsyncIteration
|
|
508
|
+
|
|
509
|
+
async def set_ticks(self, symbols: Iterable[str]) -> None:
|
|
510
|
+
"""Ask for ticks of these symbols (the whole set, up to 100; [] stops them)."""
|
|
511
|
+
if isinstance(symbols, str):
|
|
512
|
+
raise ValueError("symbols is a list of symbols, not one string.")
|
|
513
|
+
self._engine.feed(core.SetTicks(tuple(symbols)))
|
|
514
|
+
|
|
515
|
+
async def close(self) -> None:
|
|
516
|
+
"""Close the stream. Iteration ends at once: events still queued are not delivered.
|
|
517
|
+
Waiting wait() calls carry on polling over REST."""
|
|
518
|
+
if self in self._streams:
|
|
519
|
+
self._streams.remove(self)
|
|
520
|
+
if self._ended is None:
|
|
521
|
+
self._sink.close()
|
|
522
|
+
if self._task is None:
|
|
523
|
+
return
|
|
524
|
+
self._engine.feed(core.Close())
|
|
525
|
+
# Shielded: if the caller is cancelled meanwhile, that cancellation is the caller's to
|
|
526
|
+
# see, and the engine, already told to close, finishes shutting down on its own.
|
|
527
|
+
await asyncio.shield(self._task)
|
|
528
|
+
|
|
529
|
+
def _feed(self, event: core.Input) -> None:
|
|
530
|
+
self._engine.feed(event)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
# The sync shell.
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
class _ThreadSink:
|
|
537
|
+
def __init__(self, limit: int) -> None:
|
|
538
|
+
self.items: deque[Event | _End] = deque()
|
|
539
|
+
self.limit = limit
|
|
540
|
+
self.closed = False
|
|
541
|
+
self.ready = threading.Condition()
|
|
542
|
+
|
|
543
|
+
def put(self, item: Event | _End) -> list[Event]:
|
|
544
|
+
with self.ready:
|
|
545
|
+
if self.closed:
|
|
546
|
+
return [] # the caller closed the stream: nothing more is delivered
|
|
547
|
+
if not isinstance(item, _End) and len(self.items) >= self.limit:
|
|
548
|
+
dropped = [event for event in self.items if not isinstance(event, _End)] + [item]
|
|
549
|
+
self.items.clear()
|
|
550
|
+
return dropped
|
|
551
|
+
self.items.append(item)
|
|
552
|
+
self.ready.notify_all()
|
|
553
|
+
return []
|
|
554
|
+
|
|
555
|
+
def close(self) -> None:
|
|
556
|
+
"""The caller closed the stream: nothing still queued, or put later, is delivered."""
|
|
557
|
+
with self.ready:
|
|
558
|
+
self.closed = True
|
|
559
|
+
self.items.clear()
|
|
560
|
+
self.items.append(_End(None))
|
|
561
|
+
self.ready.notify_all()
|
|
562
|
+
|
|
563
|
+
def get(self) -> Event | _End:
|
|
564
|
+
with self.ready:
|
|
565
|
+
while not self.items:
|
|
566
|
+
self.ready.wait()
|
|
567
|
+
return self.items.popleft()
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
class Stream:
|
|
571
|
+
"""The account's events, as `with acct.stream(ticks=[...]) as events`, then
|
|
572
|
+
`for event in events`. The socket is read on a background thread."""
|
|
573
|
+
|
|
574
|
+
def __init__(
|
|
575
|
+
self,
|
|
576
|
+
*,
|
|
577
|
+
url: str,
|
|
578
|
+
options: Options,
|
|
579
|
+
machine: core.StreamMachine,
|
|
580
|
+
reader: Callable[[core.Read], Any],
|
|
581
|
+
streams: list[Any],
|
|
582
|
+
) -> None:
|
|
583
|
+
self._sink = _ThreadSink(machine.queue_limit)
|
|
584
|
+
self._settled = Settled()
|
|
585
|
+
self._url = url
|
|
586
|
+
self._options = options
|
|
587
|
+
self._machine = machine
|
|
588
|
+
self._reader = reader
|
|
589
|
+
self._streams = streams
|
|
590
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
591
|
+
self._engine: Engine | None = None
|
|
592
|
+
self._thread: threading.Thread | None = None
|
|
593
|
+
self._ended: _End | None = None
|
|
594
|
+
|
|
595
|
+
def __enter__(self) -> Stream:
|
|
596
|
+
started = threading.Event()
|
|
597
|
+
self._thread = threading.Thread(target=self._run, args=(started,), daemon=True)
|
|
598
|
+
self._thread.start()
|
|
599
|
+
started.wait()
|
|
600
|
+
self._streams.append(self)
|
|
601
|
+
return self
|
|
602
|
+
|
|
603
|
+
def __exit__(
|
|
604
|
+
self,
|
|
605
|
+
exc_type: type[BaseException] | None,
|
|
606
|
+
exc: BaseException | None,
|
|
607
|
+
traceback: TracebackType | None,
|
|
608
|
+
) -> None:
|
|
609
|
+
self.close()
|
|
610
|
+
|
|
611
|
+
def _run(self, started: threading.Event) -> None:
|
|
612
|
+
try:
|
|
613
|
+
loop = asyncio.new_event_loop()
|
|
614
|
+
except Exception as error: # out of file descriptors, say
|
|
615
|
+
self._sink.put(_End(unexpected(error)))
|
|
616
|
+
started.set()
|
|
617
|
+
return
|
|
618
|
+
self._loop = loop
|
|
619
|
+
|
|
620
|
+
async def read(request: core.Read) -> Any:
|
|
621
|
+
return await asyncio.to_thread(self._reader, request)
|
|
622
|
+
|
|
623
|
+
async def main() -> None:
|
|
624
|
+
self._engine = Engine(
|
|
625
|
+
url=self._url,
|
|
626
|
+
api_key=self._options.api_key,
|
|
627
|
+
machine=self._machine,
|
|
628
|
+
reader=read,
|
|
629
|
+
sink=self._sink,
|
|
630
|
+
settled=self._settled,
|
|
631
|
+
)
|
|
632
|
+
started.set()
|
|
633
|
+
await self._engine.run()
|
|
634
|
+
|
|
635
|
+
try:
|
|
636
|
+
loop.run_until_complete(main())
|
|
637
|
+
except Exception as error:
|
|
638
|
+
# The engine ends itself on its own failures; this is anything around it.
|
|
639
|
+
self._sink.put(_End(unexpected(error)))
|
|
640
|
+
finally:
|
|
641
|
+
started.set()
|
|
642
|
+
loop.close()
|
|
643
|
+
|
|
644
|
+
def _feed(self, event: core.Input) -> None:
|
|
645
|
+
loop, engine = self._loop, self._engine
|
|
646
|
+
if loop is None or engine is None or loop.is_closed():
|
|
647
|
+
return
|
|
648
|
+
with contextlib.suppress(RuntimeError):
|
|
649
|
+
loop.call_soon_threadsafe(engine.feed, event)
|
|
650
|
+
|
|
651
|
+
def __iter__(self) -> Stream:
|
|
652
|
+
return self
|
|
653
|
+
|
|
654
|
+
def __next__(self) -> Event:
|
|
655
|
+
if self._ended is None:
|
|
656
|
+
item = self._sink.get()
|
|
657
|
+
if isinstance(item, _End):
|
|
658
|
+
self._ended = item
|
|
659
|
+
else:
|
|
660
|
+
return item
|
|
661
|
+
if self._ended.error is not None:
|
|
662
|
+
raise self._ended.error
|
|
663
|
+
raise StopIteration
|
|
664
|
+
|
|
665
|
+
def set_ticks(self, symbols: Iterable[str]) -> None:
|
|
666
|
+
"""Ask for ticks of these symbols (the whole set, up to 100; [] stops them)."""
|
|
667
|
+
if isinstance(symbols, str):
|
|
668
|
+
raise ValueError("symbols is a list of symbols, not one string.")
|
|
669
|
+
self._feed(core.SetTicks(tuple(symbols)))
|
|
670
|
+
|
|
671
|
+
def close(self) -> None:
|
|
672
|
+
"""Close the stream. Iteration ends at once, from any thread (a `for` loop blocked
|
|
673
|
+
waiting for an event wakes and stops): events still queued are not delivered. Waiting
|
|
674
|
+
wait() calls carry on polling over REST."""
|
|
675
|
+
if self in self._streams:
|
|
676
|
+
self._streams.remove(self)
|
|
677
|
+
if self._ended is None:
|
|
678
|
+
self._sink.close()
|
|
679
|
+
self._feed(core.Close())
|
|
680
|
+
if self._thread is not None and self._thread is not threading.current_thread():
|
|
681
|
+
self._thread.join()
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
# What requests.wait() and the trade calls use from the streams open on their account handle.
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def watch(stream: Stream | AsyncStream, request_id: str, watcher: Watcher) -> Callable[[], None]:
|
|
688
|
+
"""Call `watcher` with each recorded-request body the stream sees for this id (on the
|
|
689
|
+
stream's thread, for a sync stream), and look the id up on every resync. Returns the undo."""
|
|
690
|
+
if stream._settled.watch(request_id, watcher): # pyright: ignore[reportPrivateUsage]
|
|
691
|
+
stream._feed(core.Watch(request_id)) # pyright: ignore[reportPrivateUsage]
|
|
692
|
+
|
|
693
|
+
def unwatch() -> None:
|
|
694
|
+
if stream._settled.unwatch(request_id, watcher): # pyright: ignore[reportPrivateUsage]
|
|
695
|
+
stream._feed(core.Unwatch(request_id)) # pyright: ignore[reportPrivateUsage]
|
|
696
|
+
|
|
697
|
+
return unwatch
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def settled(streams: Iterable[Stream | AsyncStream], request_id: str) -> Event | None:
|
|
701
|
+
"""The request in a final state, if an open stream has seen it settle."""
|
|
702
|
+
for stream in list(streams):
|
|
703
|
+
body = stream._settled.final(request_id) # pyright: ignore[reportPrivateUsage]
|
|
704
|
+
if body is not None:
|
|
705
|
+
return body
|
|
706
|
+
return None
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def report(streams: Iterable[Stream | AsyncStream], error: PipsocketError) -> None:
|
|
710
|
+
"""A read outside the streams failed: a 401 or a gone account stops them too."""
|
|
711
|
+
if core.fatal(error):
|
|
712
|
+
for stream in list(streams):
|
|
713
|
+
stream._feed(core.Fatal(error)) # pyright: ignore[reportPrivateUsage]
|