Skip to content

Commit dc24afb

Browse files
authored
add proxies (#216)
1 parent c862105 commit dc24afb

File tree

8 files changed

+159
-84
lines changed

8 files changed

+159
-84
lines changed

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ dependencies = [
1818
"httpx>=0.27.2",
1919
"pandas-market-calendars>=4.4.1",
2020
"pydantic>=2.9.2",
21-
"websockets>=14.1,<15",
21+
"websockets>=15",
2222
]
2323
dynamic = ["version"]
2424

@@ -37,6 +37,7 @@ dev-dependencies = [
3737
"sphinx-rtd-theme>=3.0.2",
3838
"enum-tools[sphinx]>=0.12.0",
3939
"autodoc-pydantic>=2.2.0",
40+
"proxy-py>=2.4.9",
4041
]
4142

4243
[tool.setuptools.package-data]

tastytrade/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
BACKTEST_URL = "https://backtester.vast.tastyworks.com"
55
CERT_URL = "https://api.cert.tastyworks.com"
66
VAST_URL = "https://vast.tastyworks.com"
7-
VERSION = "9.9"
7+
VERSION = "9.10"
88

99
__version__ = VERSION
1010

tastytrade/session.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,9 @@ class Session:
280280
user's device
281281
:param dxfeed_tos_compliant:
282282
whether to use the dxfeed TOS-compliant API endpoint for the streamer
283+
:param proxy:
284+
if provided, all requests will be made through this proxy, as well as
285+
web socket connections for streamers.
283286
"""
284287

285288
def __init__(
@@ -291,6 +294,7 @@ def __init__(
291294
is_test: bool = False,
292295
two_factor_authentication: Optional[str] = None,
293296
dxfeed_tos_compliant: bool = False,
297+
proxy: Optional[str] = None,
294298
):
295299
body = {"login": login, "remember-me": remember_me}
296300
if password is not None:
@@ -303,14 +307,16 @@ def __init__(
303307
)
304308
#: Whether this is a cert or real session
305309
self.is_test = is_test
310+
#: Proxy URL to use for requests and web sockets
311+
self.proxy = proxy
306312
# The headers to use for API requests
307313
headers = {
308314
"Accept": "application/json",
309315
"Content-Type": "application/json",
310316
}
311317
#: httpx client for sync requests
312318
self.sync_client = Client(
313-
base_url=(CERT_URL if is_test else API_URL), headers=headers
319+
base_url=(CERT_URL if is_test else API_URL), headers=headers, proxy=proxy
314320
)
315321
if two_factor_authentication is not None:
316322
response = self.sync_client.post(
@@ -330,7 +336,9 @@ def __init__(
330336
self.sync_client.headers.update({"Authorization": self.session_token})
331337
#: httpx client for async requests
332338
self.async_client = AsyncClient(
333-
base_url=self.sync_client.base_url, headers=self.sync_client.headers.copy()
339+
base_url=self.sync_client.base_url,
340+
headers=self.sync_client.headers.copy(),
341+
proxy=proxy,
334342
)
335343

336344
# Pull streamer tokens and urls
@@ -345,6 +353,12 @@ def __init__(
345353
#: URL for dxfeed websocket
346354
self.dxlink_url = data["dxlink-url"]
347355

356+
def __enter__(self):
357+
return self
358+
359+
def __exit__(self, *exc):
360+
self.destroy()
361+
348362
async def _a_get(self, url, **kwargs) -> dict[str, Any]:
349363
response = await self.async_client.get(url, timeout=30, **kwargs)
350364
return validate_and_parse(response)
@@ -468,6 +482,8 @@ def deserialize(cls, serialized: str) -> Self:
468482
"Content-Type": "application/json",
469483
"Authorization": self.session_token,
470484
}
471-
self.sync_client = Client(base_url=base_url, headers=headers)
472-
self.async_client = AsyncClient(base_url=base_url, headers=headers)
485+
self.sync_client = Client(base_url=base_url, headers=headers, proxy=self.proxy)
486+
self.async_client = AsyncClient(
487+
base_url=base_url, headers=headers, proxy=self.proxy
488+
)
473489
return self

tastytrade/streamer.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,8 @@ def __init__(
209209
self.reconnect_fn = reconnect_fn
210210
#: Variable number of arguments to pass to the reconnect function
211211
self.reconnect_args = reconnect_args
212+
#: The proxy URL, if any, associated with the session
213+
self.proxy = session.proxy
212214

213215
self._queues: dict[str, Queue] = defaultdict(Queue)
214216
self._websocket: Optional[ClientConnection] = None
@@ -251,7 +253,9 @@ async def _connect(self) -> None:
251253
"""
252254
headers = {"Authorization": f"Bearer {self.token}"}
253255
reconnecting = False
254-
async for websocket in connect(self.base_url, additional_headers=headers):
256+
async for websocket in connect(
257+
self.base_url, additional_headers=headers, proxy=self.proxy
258+
):
255259
self._websocket = websocket
256260
self._heartbeat_task = asyncio.create_task(self._heartbeat())
257261
logger.debug("Websocket connection established.")
@@ -413,6 +417,8 @@ def __init__(
413417
self.reconnect_fn = reconnect_fn
414418
#: Variable number of arguments to pass to the reconnect function
415419
self.reconnect_args = reconnect_args
420+
#: The proxy URL, if any, associated with the session
421+
self.proxy = session.proxy
416422

417423
self._authenticated = False
418424
self._wss_url = session.dxlink_url
@@ -456,7 +462,9 @@ async def _connect(self) -> None:
456462
authorization token provided during initialization.
457463
"""
458464
reconnecting = False
459-
async for websocket in connect(self._wss_url, ssl=self._ssl_context):
465+
async for websocket in connect(
466+
self._wss_url, ssl=self._ssl_context, proxy=self.proxy
467+
):
460468
self._websocket = websocket
461469
await self._setup_connection()
462470
try:

tests/conftest.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ def credentials() -> tuple[str, str]:
2525
async def session(
2626
credentials: tuple[str, str], aiolib: str
2727
) -> AsyncGenerator[Session, None]:
28-
session = Session(*credentials)
29-
yield session
30-
session.destroy()
28+
with Session(*credentials) as session:
29+
yield session
30+
31+
32+
@fixture(scope="class")
33+
def inject_credentials(request, credentials: tuple[str, str]):
34+
request.cls.credentials = credentials

tests/test_session.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import pytest
2+
from proxy import TestCase
3+
14
from tastytrade import Session
25

36

@@ -31,3 +34,15 @@ def test_serialize_deserialize(session: Session):
3134
data = session.serialize()
3235
obj = Session.deserialize(data)
3336
assert set(obj.__dict__.keys()) == set(session.__dict__.keys())
37+
38+
39+
@pytest.mark.usefixtures("inject_credentials")
40+
class TestProxy(TestCase):
41+
def test_session_with_proxy(self):
42+
assert self.PROXY is not None
43+
session = Session(
44+
*self.credentials, # type: ignore
45+
proxy=f"http://127.0.0.1:{self.PROXY.flags.port}",
46+
)
47+
assert session.validate()
48+
session.destroy()

tests/test_streamer.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import asyncio
22
from datetime import datetime, timedelta
3+
from unittest import IsolatedAsyncioTestCase
4+
5+
import pytest
6+
from proxy import TestCase
37

48
from tastytrade import Account, AlertStreamer, DXLinkStreamer, Session
59
from tastytrade.dxfeed import Candle, Quote, Trade
@@ -65,3 +69,18 @@ async def test_dxlink_streamer_reconnect(session: Session):
6569
trade = await streamer.get_event(Trade)
6670
assert trade.event_symbol == "SPX"
6771
await streamer.close()
72+
73+
74+
@pytest.mark.usefixtures("inject_credentials")
75+
class TestProxy(TestCase, IsolatedAsyncioTestCase):
76+
@pytest.mark.asyncio
77+
async def test_streamer_with_proxy(self):
78+
assert self.PROXY is not None
79+
with Session(
80+
*self.credentials, # type: ignore
81+
proxy=f"http://127.0.0.1:{self.PROXY.flags.port}",
82+
) as session:
83+
assert session.validate()
84+
async with DXLinkStreamer(session) as streamer:
85+
await streamer.subscribe(Quote, ["SPY"])
86+
_ = await streamer.get_event(Quote)

0 commit comments

Comments
 (0)