Skip to content

Commit 76e2a7d

Browse files
userFRMclaude
andauthored
feat(python): add non-blocking AsyncClient.connect for event-loop-safe construction (#879)
AsyncClient previously only offered the synchronous AsyncClient(creds, config) constructor and AsyncClient.from_file(path). Both run the authentication round-trip and gRPC channel setup to completion before returning, so calling them from inside a coroutine stalls the running event loop for the whole handshake. That is the blocking-call-on-the-loop anti-pattern an async client must avoid. This adds two awaitable constructors that resolve the handshake off the event loop and yield a connected AsyncClient: await AsyncClient.connect(creds, config) and await AsyncClient.connect_from_file(path, config=None). They are wired through the same future_into_py awaitable bridge the existing *_async historical methods use, so other coroutines keep running while the connection is established. The synchronous constructors stay available for construction outside a running loop, and the class example now steers async callers to await AsyncClient.connect(...). The .pyi stub declares both as staticmethods returning Awaitable[AsyncClient] with docstrings; stubtest passes against the runtime. The cross-binding parity gate is clean: the existing AsyncClient connect row holds and the new methods enroll without weakening any check. Co-authored-by: preview <noreply@anthropic.com>
1 parent 262784d commit 76e2a7d

4 files changed

Lines changed: 147 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4949
- TypeScript reaches cross-binding parity with the other SDKs on three surfaces: the offline Greeks calculator (`allGreeks(...)` returning a typed object with the 23 Greek fields, and `impliedVolatility(...)` returning the `(iv, iv_error)` pair); historical-result streaming via `<endpoint>Stream(...)` that delivers typed row chunks through a thread-safe callback so peak memory tracks one chunk rather than the full result; and the `deriveOhlcvc` config toggle. The C ABI and C++ also gain historical-result streaming through a tick-chunk callback (`thetadatadx_<endpoint>_stream` / the C++ `<endpoint>_stream(..., handler)` over a contiguous span), with `option_list_contracts` remaining buffered-only on the C ABI.
5050
- An Arrow-IPC terminal on the TypeScript and C++ history results — per-collection `<tick>ToArrowIpc(rows)` (TypeScript) and `thetadatadx::<collection>_to_arrow_ipc(rows)` (C++) emit the same Arrow IPC stream bytes as the existing flat-file terminal; an empty result is a valid zero-row stream carrying the schema.
5151
- A `from_file` client-construction convenience across the bindings: the Python unified `Client.from_file(path, config=None)`, the C-ABI `thetadatadx_*_connect_from_file(path, config)` trio, and the C++ `from_file(path, config = Config::production())` statics, all defaulting to the production configuration so a credentials file is the only required input.
52+
- Python `AsyncClient` gains awaitable constructors `await AsyncClient.connect(creds, config)` and `await AsyncClient.connect_from_file(path, config=None)` so async callers can establish a connection from inside a coroutine without the authentication handshake stalling the running event loop. The synchronous `AsyncClient(creds, config)` and `AsyncClient.from_file(...)` constructors stay available for construction outside a running loop.
5253
- TypeScript precomputed epoch-instant fields on every tick that carries a `date` plus a milliseconds-of-day column (`createdTimestampMs`, `lastTradeTimestampMs`, `timestampMs`, `underlyingTimestampMs`, `quoteTimestampMs`), one-for-one with the Python `*_timestamp_ms` properties and resolved through the same DST-aware core conversion.
5354
- TypeScript `Subscription` exposes the `contract` and `secType` getters Python already had, and `toString()` rendering is available on the TypeScript `ContractRef`, `Subscription`, and `SecType` values; C++ gains `operator<<` and a `thetadatadx::str(...)` rendering for the same fluent value types.
5455
- Trade flag-word accessors are generated into every binding (previously Rust-only on `TradeTick`): `is_cancelled`, `regular_trading_hours`, `is_seller`, `trade_condition_no_last`, `price_condition_set_last`, and `is_incremental_volume` (Python computed properties, TypeScript precomputed boolean fields, C++ free functions). The C ABI also gains `thetadatadx_contract_strike_dollars`, the dollar-valued counterpart of the existing C++ `thetadatadx::strike(...)` accessor.

docs-site/docs/changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4949
- TypeScript reaches cross-binding parity with the other SDKs on three surfaces: the offline Greeks calculator (`allGreeks(...)` returning a typed object with the 23 Greek fields, and `impliedVolatility(...)` returning the `(iv, iv_error)` pair); historical-result streaming via `<endpoint>Stream(...)` that delivers typed row chunks through a thread-safe callback so peak memory tracks one chunk rather than the full result; and the `deriveOhlcvc` config toggle. The C ABI and C++ also gain historical-result streaming through a tick-chunk callback (`thetadatadx_<endpoint>_stream` / the C++ `<endpoint>_stream(..., handler)` over a contiguous span), with `option_list_contracts` remaining buffered-only on the C ABI.
5050
- An Arrow-IPC terminal on the TypeScript and C++ history results — per-collection `<tick>ToArrowIpc(rows)` (TypeScript) and `thetadatadx::<collection>_to_arrow_ipc(rows)` (C++) emit the same Arrow IPC stream bytes as the existing flat-file terminal; an empty result is a valid zero-row stream carrying the schema.
5151
- A `from_file` client-construction convenience across the bindings: the Python unified `Client.from_file(path, config=None)`, the C-ABI `thetadatadx_*_connect_from_file(path, config)` trio, and the C++ `from_file(path, config = Config::production())` statics, all defaulting to the production configuration so a credentials file is the only required input.
52+
- Python `AsyncClient` gains awaitable constructors `await AsyncClient.connect(creds, config)` and `await AsyncClient.connect_from_file(path, config=None)` so async callers can establish a connection from inside a coroutine without the authentication handshake stalling the running event loop. The synchronous `AsyncClient(creds, config)` and `AsyncClient.from_file(...)` constructors stay available for construction outside a running loop.
5253
- TypeScript precomputed epoch-instant fields on every tick that carries a `date` plus a milliseconds-of-day column (`createdTimestampMs`, `lastTradeTimestampMs`, `timestampMs`, `underlyingTimestampMs`, `quoteTimestampMs`), one-for-one with the Python `*_timestamp_ms` properties and resolved through the same DST-aware core conversion.
5354
- TypeScript `Subscription` exposes the `contract` and `secType` getters Python already had, and `toString()` rendering is available on the TypeScript `ContractRef`, `Subscription`, and `SecType` values; C++ gains `operator<<` and a `thetadatadx::str(...)` rendering for the same fluent value types.
5455
- Trade flag-word accessors are generated into every binding (previously Rust-only on `TradeTick`): `is_cancelled`, `regular_trading_hours`, `is_seller`, `trade_condition_no_last`, `price_condition_set_last`, and `is_incremental_volume` (Python computed properties, TypeScript precomputed boolean fields, C++ free functions). The C ABI also gains `thetadatadx_contract_strike_dollars`, the dollar-valued counterpart of the existing C++ `thetadatadx::strike(...)` accessor.

sdks/python/python/thetadatadx/__init__.pyi

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ from __future__ import annotations
3131

3232
from typing import (
3333
Any,
34+
Awaitable,
3435
Callable,
3536
List,
3637
Literal,
@@ -1297,10 +1298,41 @@ class AsyncClient:
12971298
def __init__(self, creds: Credentials, config: Config) -> None:
12981299
"""Connect to ThetaData with ``creds`` and ``config``.
12991300
1301+
Runs the authentication and connection handshake to completion
1302+
before returning. Use this when constructing outside a running
1303+
event loop. Inside a coroutine, prefer
1304+
:meth:`connect` so the handshake does not stall the event loop.
1305+
1306+
Args:
1307+
creds: Account credentials.
1308+
config: Connection configuration.
1309+
1310+
Raises:
1311+
ThetaDataError: If authentication or the connection fails.
1312+
"""
1313+
...
1314+
1315+
@staticmethod
1316+
def connect(
1317+
creds: Credentials,
1318+
config: Config,
1319+
) -> Awaitable[AsyncClient]:
1320+
"""Connect without blocking the running event loop.
1321+
1322+
The authentication and connection handshake resolves off the
1323+
event loop, so other coroutines keep running while the connection
1324+
is established. This is the preferred way to build an
1325+
:class:`AsyncClient` from inside a coroutine::
1326+
1327+
client = await AsyncClient.connect(creds, config)
1328+
13001329
Args:
13011330
creds: Account credentials.
13021331
config: Connection configuration.
13031332
1333+
Returns:
1334+
An awaitable resolving to a connected :class:`AsyncClient`.
1335+
13041336
Raises:
13051337
ThetaDataError: If authentication or the connection fails.
13061338
"""
@@ -1327,6 +1359,33 @@ class AsyncClient:
13271359
"""
13281360
...
13291361

1362+
@staticmethod
1363+
def connect_from_file(
1364+
path: str,
1365+
config: Optional[Config] = None,
1366+
) -> Awaitable[AsyncClient]:
1367+
"""Connect from a credentials file without blocking the event loop.
1368+
1369+
Loads credentials from a two-line file and connects off the event
1370+
loop, defaulting to ``Config.production()`` when no ``config`` is
1371+
supplied::
1372+
1373+
client = await AsyncClient.connect_from_file("creds.txt")
1374+
1375+
Args:
1376+
path: Path to a two-line credentials file.
1377+
config: Connection configuration; defaults to
1378+
``Config.production()`` when omitted.
1379+
1380+
Returns:
1381+
An awaitable resolving to a connected :class:`AsyncClient`.
1382+
1383+
Raises:
1384+
ThetaDataError: If the file cannot be read or the connection
1385+
fails.
1386+
"""
1387+
...
1388+
13301389
def __repr__(self) -> str:
13311390
"""Return a representation including historical and streaming state."""
13321391
...

sdks/python/src/lib.rs

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1788,13 +1788,19 @@ impl StreamView {
17881788
///
17891789
/// async def main():
17901790
/// creds = Credentials.from_file("creds.txt")
1791-
/// client = AsyncClient(creds, Config.production())
1791+
/// client = await AsyncClient.connect(creds, Config.production())
17921792
/// ticks = await client.stock_history_eod_async("AAPL", "20240101", "20240301")
17931793
/// print(ticks.to_pandas().head())
17941794
///
17951795
/// asyncio.run(main())
17961796
/// ```
17971797
///
1798+
/// Construct with `await AsyncClient.connect(...)` (or
1799+
/// `await AsyncClient.connect_from_file("creds.txt")`) from inside a
1800+
/// coroutine so the auth + connect handshake resolves off the event loop
1801+
/// instead of stalling it. The synchronous `AsyncClient(creds, config)`
1802+
/// constructor stays available for building outside a running loop.
1803+
///
17981804
/// Attribute access is restricted to async-suffixed methods plus a
17991805
/// safelisted set of synchronous lifecycle methods that have no
18001806
/// async counterpart on the wrapped surface
@@ -1972,12 +1978,64 @@ struct AsyncClient {
19721978

19731979
#[pymethods]
19741980
impl AsyncClient {
1981+
/// Synchronous constructor that runs the auth + connect handshake to
1982+
/// completion before returning.
1983+
///
1984+
/// Suitable for construction OUTSIDE a running event loop (module
1985+
/// import, a worker thread, a `__main__` body before `asyncio.run`).
1986+
/// Inside a coroutine, prefer ``await AsyncClient.connect(...)`` so
1987+
/// the handshake does not stall the event loop.
19751988
#[new]
19761989
fn new(py: Python<'_>, creds: &Credentials, config: &Config) -> PyResult<Self> {
19771990
let client = Py::new(py, Client::new(py, creds, config)?)?;
19781991
Ok(Self { inner: client })
19791992
}
19801993

1994+
/// Awaitable constructor that yields a connected ``AsyncClient``
1995+
/// without stalling the running event loop.
1996+
///
1997+
/// The auth round-trip and gRPC channel setup resolve off the event
1998+
/// loop, so other coroutines keep running while the connection is
1999+
/// established. This is the preferred way to build an ``AsyncClient``
2000+
/// from inside a coroutine::
2001+
///
2002+
/// client = await AsyncClient.connect(creds, config)
2003+
///
2004+
/// The synchronous ``AsyncClient(creds, config)`` constructor remains
2005+
/// available for construction outside a running loop.
2006+
#[staticmethod]
2007+
fn connect<'py>(
2008+
py: Python<'py>,
2009+
creds: &Credentials,
2010+
config: &Config,
2011+
) -> PyResult<Bound<'py, PyAny>> {
2012+
// Snapshot the config + credentials under the GIL before handing
2013+
// the connect future to the runtime. `connect()` takes ownership
2014+
// of the `DirectConfig`, and the outer `Config` handle may still
2015+
// be mutated Python-side after this call returns its awaitable.
2016+
let direct_config = {
2017+
let guard = config.inner.lock().unwrap_or_else(|e| e.into_inner());
2018+
guard.clone()
2019+
};
2020+
// Seed the process-global runtime from this client's runtime
2021+
// config before the awaitable resolves, so `worker_threads` takes
2022+
// effect when the first client in the process connects.
2023+
runtime_from_config(&direct_config.runtime);
2024+
let inner_creds = creds.inner.clone();
2025+
spawn_awaitable(
2026+
py,
2027+
async move { thetadatadx::Client::connect(&inner_creds, direct_config).await },
2028+
|py, client| {
2029+
let wrapped = Client {
2030+
client: std::sync::Arc::new(client),
2031+
callback: Arc::new(Mutex::new(None)),
2032+
};
2033+
let inner = Py::new(py, wrapped)?;
2034+
Ok(Py::new(py, Self { inner })?.into_any())
2035+
},
2036+
)
2037+
}
2038+
19812039
/// Convenience constructor: `AsyncClient.from_file("creds.txt")`.
19822040
/// Accepts an optional `config` kwarg defaulting to
19832041
/// `Config.production()` for non-production environment tests.
@@ -1997,6 +2055,33 @@ impl AsyncClient {
19972055
Ok(Self { inner: client })
19982056
}
19992057

2058+
/// Awaitable file constructor that yields a connected ``AsyncClient``
2059+
/// without stalling the running event loop.
2060+
///
2061+
/// Loads credentials from a two-line file (line 1 = email, line 2 =
2062+
/// password) and connects off the event loop, defaulting to the
2063+
/// production endpoint when no ``config`` is supplied::
2064+
///
2065+
/// client = await AsyncClient.connect_from_file("creds.txt")
2066+
#[staticmethod]
2067+
#[pyo3(signature = (path, config=None))]
2068+
fn connect_from_file<'py>(
2069+
py: Python<'py>,
2070+
path: &str,
2071+
config: Option<&Config>,
2072+
) -> PyResult<Bound<'py, PyAny>> {
2073+
let creds = Credentials::from_file(path)?;
2074+
let owned_default;
2075+
let cfg = match config {
2076+
Some(c) => c,
2077+
None => {
2078+
owned_default = Config::production();
2079+
&owned_default
2080+
}
2081+
};
2082+
Self::connect(py, &creds, cfg)
2083+
}
2084+
20002085
/// Forward attribute access to the wrapped `Client`.
20012086
/// Async-suffixed methods plus the safelisted lifecycle / streaming
20022087
/// methods are reachable; everything else raises `AttributeError`

0 commit comments

Comments
 (0)