From 6825c4655093e4b70b33a48331ca19072ac149cb Mon Sep 17 00:00:00 2001 From: preview Date: Wed, 17 Jun 2026 22:01:55 +0200 Subject: [PATCH] feat(python): add non-blocking AsyncClient.connect for event-loop-safe construction 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: Claude Opus 4.8 --- CHANGELOG.md | 1 + docs-site/docs/changelog.md | 1 + sdks/python/python/thetadatadx/__init__.pyi | 59 ++++++++++++++ sdks/python/src/lib.rs | 87 ++++++++++++++++++++- 4 files changed, 147 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88540b8e6..35e616882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 `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__stream` / the C++ `_stream(..., handler)` over a contiguous span), with `option_list_contracts` remaining buffered-only on the C ABI. - An Arrow-IPC terminal on the TypeScript and C++ history results — per-collection `ToArrowIpc(rows)` (TypeScript) and `thetadatadx::_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. - 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. +- 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. - 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. - 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. - 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. diff --git a/docs-site/docs/changelog.md b/docs-site/docs/changelog.md index 88540b8e6..35e616882 100644 --- a/docs-site/docs/changelog.md +++ b/docs-site/docs/changelog.md @@ -49,6 +49,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 `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__stream` / the C++ `_stream(..., handler)` over a contiguous span), with `option_list_contracts` remaining buffered-only on the C ABI. - An Arrow-IPC terminal on the TypeScript and C++ history results — per-collection `ToArrowIpc(rows)` (TypeScript) and `thetadatadx::_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. - 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. +- 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. - 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. - 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. - 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. diff --git a/sdks/python/python/thetadatadx/__init__.pyi b/sdks/python/python/thetadatadx/__init__.pyi index 2e18f7254..7f9db48c6 100644 --- a/sdks/python/python/thetadatadx/__init__.pyi +++ b/sdks/python/python/thetadatadx/__init__.pyi @@ -31,6 +31,7 @@ from __future__ import annotations from typing import ( Any, + Awaitable, Callable, List, Literal, @@ -1297,10 +1298,41 @@ class AsyncClient: def __init__(self, creds: Credentials, config: Config) -> None: """Connect to ThetaData with ``creds`` and ``config``. + Runs the authentication and connection handshake to completion + before returning. Use this when constructing outside a running + event loop. Inside a coroutine, prefer + :meth:`connect` so the handshake does not stall the event loop. + + Args: + creds: Account credentials. + config: Connection configuration. + + Raises: + ThetaDataError: If authentication or the connection fails. + """ + ... + + @staticmethod + def connect( + creds: Credentials, + config: Config, + ) -> Awaitable[AsyncClient]: + """Connect without blocking the running event loop. + + The authentication and connection handshake resolves off the + event loop, so other coroutines keep running while the connection + is established. This is the preferred way to build an + :class:`AsyncClient` from inside a coroutine:: + + client = await AsyncClient.connect(creds, config) + Args: creds: Account credentials. config: Connection configuration. + Returns: + An awaitable resolving to a connected :class:`AsyncClient`. + Raises: ThetaDataError: If authentication or the connection fails. """ @@ -1327,6 +1359,33 @@ class AsyncClient: """ ... + @staticmethod + def connect_from_file( + path: str, + config: Optional[Config] = None, + ) -> Awaitable[AsyncClient]: + """Connect from a credentials file without blocking the event loop. + + Loads credentials from a two-line file and connects off the event + loop, defaulting to ``Config.production()`` when no ``config`` is + supplied:: + + client = await AsyncClient.connect_from_file("creds.txt") + + Args: + path: Path to a two-line credentials file. + config: Connection configuration; defaults to + ``Config.production()`` when omitted. + + Returns: + An awaitable resolving to a connected :class:`AsyncClient`. + + Raises: + ThetaDataError: If the file cannot be read or the connection + fails. + """ + ... + def __repr__(self) -> str: """Return a representation including historical and streaming state.""" ... diff --git a/sdks/python/src/lib.rs b/sdks/python/src/lib.rs index deff6947c..22aa49341 100644 --- a/sdks/python/src/lib.rs +++ b/sdks/python/src/lib.rs @@ -1788,13 +1788,19 @@ impl StreamView { /// /// async def main(): /// creds = Credentials.from_file("creds.txt") -/// client = AsyncClient(creds, Config.production()) +/// client = await AsyncClient.connect(creds, Config.production()) /// ticks = await client.stock_history_eod_async("AAPL", "20240101", "20240301") /// print(ticks.to_pandas().head()) /// /// asyncio.run(main()) /// ``` /// +/// Construct with `await AsyncClient.connect(...)` (or +/// `await AsyncClient.connect_from_file("creds.txt")`) from inside a +/// coroutine so the auth + connect handshake resolves off the event loop +/// instead of stalling it. The synchronous `AsyncClient(creds, config)` +/// constructor stays available for building outside a running loop. +/// /// Attribute access is restricted to async-suffixed methods plus a /// safelisted set of synchronous lifecycle methods that have no /// async counterpart on the wrapped surface @@ -1972,12 +1978,64 @@ struct AsyncClient { #[pymethods] impl AsyncClient { + /// Synchronous constructor that runs the auth + connect handshake to + /// completion before returning. + /// + /// Suitable for construction OUTSIDE a running event loop (module + /// import, a worker thread, a `__main__` body before `asyncio.run`). + /// Inside a coroutine, prefer ``await AsyncClient.connect(...)`` so + /// the handshake does not stall the event loop. #[new] fn new(py: Python<'_>, creds: &Credentials, config: &Config) -> PyResult { let client = Py::new(py, Client::new(py, creds, config)?)?; Ok(Self { inner: client }) } + /// Awaitable constructor that yields a connected ``AsyncClient`` + /// without stalling the running event loop. + /// + /// The auth round-trip and gRPC channel setup resolve off the event + /// loop, so other coroutines keep running while the connection is + /// established. This is the preferred way to build an ``AsyncClient`` + /// from inside a coroutine:: + /// + /// client = await AsyncClient.connect(creds, config) + /// + /// The synchronous ``AsyncClient(creds, config)`` constructor remains + /// available for construction outside a running loop. + #[staticmethod] + fn connect<'py>( + py: Python<'py>, + creds: &Credentials, + config: &Config, + ) -> PyResult> { + // Snapshot the config + credentials under the GIL before handing + // the connect future to the runtime. `connect()` takes ownership + // of the `DirectConfig`, and the outer `Config` handle may still + // be mutated Python-side after this call returns its awaitable. + let direct_config = { + let guard = config.inner.lock().unwrap_or_else(|e| e.into_inner()); + guard.clone() + }; + // Seed the process-global runtime from this client's runtime + // config before the awaitable resolves, so `worker_threads` takes + // effect when the first client in the process connects. + runtime_from_config(&direct_config.runtime); + let inner_creds = creds.inner.clone(); + spawn_awaitable( + py, + async move { thetadatadx::Client::connect(&inner_creds, direct_config).await }, + |py, client| { + let wrapped = Client { + client: std::sync::Arc::new(client), + callback: Arc::new(Mutex::new(None)), + }; + let inner = Py::new(py, wrapped)?; + Ok(Py::new(py, Self { inner })?.into_any()) + }, + ) + } + /// Convenience constructor: `AsyncClient.from_file("creds.txt")`. /// Accepts an optional `config` kwarg defaulting to /// `Config.production()` for non-production environment tests. @@ -1997,6 +2055,33 @@ impl AsyncClient { Ok(Self { inner: client }) } + /// Awaitable file constructor that yields a connected ``AsyncClient`` + /// without stalling the running event loop. + /// + /// Loads credentials from a two-line file (line 1 = email, line 2 = + /// password) and connects off the event loop, defaulting to the + /// production endpoint when no ``config`` is supplied:: + /// + /// client = await AsyncClient.connect_from_file("creds.txt") + #[staticmethod] + #[pyo3(signature = (path, config=None))] + fn connect_from_file<'py>( + py: Python<'py>, + path: &str, + config: Option<&Config>, + ) -> PyResult> { + let creds = Credentials::from_file(path)?; + let owned_default; + let cfg = match config { + Some(c) => c, + None => { + owned_default = Config::production(); + &owned_default + } + }; + Self::connect(py, &creds, cfg) + } + /// Forward attribute access to the wrapped `Client`. /// Async-suffixed methods plus the safelisted lifecycle / streaming /// methods are reachable; everything else raises `AttributeError`