From 8dbdd03e0cc1b0801244ae60d17ff502a9f9de34 Mon Sep 17 00:00:00 2001 From: zackees Date: Fri, 17 Apr 2026 15:32:54 -0700 Subject: [PATCH] feat(python): add AsyncDaemon with async status() (partial #65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next incremental step on #65 (native async API). Adds AsyncDaemon as a counterpart to the sync Daemon class, mirroring the AsyncSerialMonitor pattern from PR #73. - AsyncDaemon is purely additive — the sync Daemon class keeps all its methods and behavior. - Exposes async status() as the first method. Like reset_device on AsyncSerialMonitor, status is stateless HTTP so it ports without needing the cross-await state refactor. Future work on #65: async ensure_running(), async stop(), and then the WebSocket-backed AsyncSerialMonitor methods once the shared state is Send + Sync across await points. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/fbuild-python/src/lib.rs | 58 +++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/crates/fbuild-python/src/lib.rs b/crates/fbuild-python/src/lib.rs index cb396112..423b9973 100644 --- a/crates/fbuild-python/src/lib.rs +++ b/crates/fbuild-python/src/lib.rs @@ -1065,6 +1065,63 @@ impl AsyncSerialMonitor { } } +/// Python-visible AsyncDaemon class. +/// +/// Native async counterpart to `Daemon`. Follows the same additive +/// pattern as `AsyncSerialMonitor` (Issue #65): the sync `Daemon` class +/// stays unchanged, and this one exposes async methods so callers under +/// an asyncio event loop can `await` them directly. +/// +/// ```python +/// import asyncio +/// from fbuild._native import AsyncDaemon +/// +/// async def main(): +/// info = await AsyncDaemon.status() +/// +/// asyncio.run(main()) +/// ``` +#[pyclass] +struct AsyncDaemon; + +#[pymethods] +impl AsyncDaemon { + /// Asynchronously fetch `/api/daemon/info` from the daemon. Returns + /// a JSON-deserialized Python object on success, or raises a + /// ConnectionError/RuntimeError. + #[staticmethod] + fn status(py: Python<'_>) -> PyResult> { + let url = format!("{}/api/daemon/info", fbuild_paths::get_daemon_url()); + + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let resp = reqwest::Client::new() + .get(&url) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await + .map_err(|e| { + pyo3::exceptions::PyConnectionError::new_err(format!( + "failed to connect to daemon: {}", + e + )) + })?; + + let text = resp.text().await.map_err(|e| { + pyo3::exceptions::PyRuntimeError::new_err(format!( + "failed to read daemon response: {}", + e + )) + })?; + + Python::with_gil(|py| { + let json_module = py.import_bound("json")?; + let parsed = json_module.call_method1("loads", (text,))?; + Ok(parsed.unbind()) + }) + }) + } +} + /// The fbuild Python module (imported as fbuild._native). #[pymodule] fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { @@ -1072,6 +1129,7 @@ fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_function(wrap_pyfunction!(connect_daemon, m)?)?; Ok(())