diff --git a/Cargo.lock b/Cargo.lock index 853f625f4..6b8b9bc53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1030,7 +1030,7 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "fbuild-bench-fastled-examples" -version = "2.5.20" +version = "2.5.21" dependencies = [ "fbuild-core", "fbuild-library-select", @@ -1044,7 +1044,7 @@ dependencies = [ [[package]] name = "fbuild-build" -version = "2.5.20" +version = "2.5.21" dependencies = [ "async-trait", "blake3", @@ -1081,7 +1081,7 @@ dependencies = [ [[package]] name = "fbuild-build-arm" -version = "2.5.20" +version = "2.5.21" dependencies = [ "async-trait", "blake3", @@ -1115,7 +1115,7 @@ dependencies = [ [[package]] name = "fbuild-build-engine" -version = "2.5.20" +version = "2.5.21" dependencies = [ "async-trait", "blake3", @@ -1148,7 +1148,7 @@ dependencies = [ [[package]] name = "fbuild-build-esp" -version = "2.5.20" +version = "2.5.21" dependencies = [ "async-trait", "blake3", @@ -1182,7 +1182,7 @@ dependencies = [ [[package]] name = "fbuild-build-mcu" -version = "2.5.20" +version = "2.5.21" dependencies = [ "async-trait", "blake3", @@ -1216,7 +1216,7 @@ dependencies = [ [[package]] name = "fbuild-cli" -version = "2.5.20" +version = "2.5.21" dependencies = [ "blake3", "clap", @@ -1248,7 +1248,7 @@ dependencies = [ [[package]] name = "fbuild-config" -version = "2.5.20" +version = "2.5.21" dependencies = [ "fbuild-core", "fbuild-paths", @@ -1263,7 +1263,7 @@ dependencies = [ [[package]] name = "fbuild-core" -version = "2.5.20" +version = "2.5.21" dependencies = [ "async-trait", "fs2", @@ -1291,7 +1291,7 @@ dependencies = [ [[package]] name = "fbuild-daemon" -version = "2.5.20" +version = "2.5.21" dependencies = [ "async-trait", "axum 0.7.9", @@ -1328,7 +1328,7 @@ dependencies = [ [[package]] name = "fbuild-deploy" -version = "2.5.20" +version = "2.5.21" dependencies = [ "async-trait", "espflash", @@ -1356,7 +1356,7 @@ dependencies = [ [[package]] name = "fbuild-header-scan" -version = "2.5.20" +version = "2.5.21" dependencies = [ "criterion", "fbuild-paths", @@ -1367,7 +1367,7 @@ dependencies = [ [[package]] name = "fbuild-library" -version = "2.5.20" +version = "2.5.21" dependencies = [ "async-trait", "axum 0.7.9", @@ -1397,7 +1397,7 @@ dependencies = [ [[package]] name = "fbuild-library-select" -version = "2.5.20" +version = "2.5.21" dependencies = [ "bincode", "blake3", @@ -1418,7 +1418,7 @@ dependencies = [ [[package]] name = "fbuild-packages" -version = "2.5.20" +version = "2.5.21" dependencies = [ "async-trait", "axum 0.7.9", @@ -1450,7 +1450,7 @@ dependencies = [ [[package]] name = "fbuild-packages-fetch" -version = "2.5.20" +version = "2.5.21" dependencies = [ "async-trait", "axum 0.7.9", @@ -1479,7 +1479,7 @@ dependencies = [ [[package]] name = "fbuild-paths" -version = "2.5.20" +version = "2.5.21" dependencies = [ "blake3", "fbuild-core", @@ -1491,7 +1491,7 @@ dependencies = [ [[package]] name = "fbuild-python" -version = "2.5.20" +version = "2.5.21" dependencies = [ "base64", "fbuild-core", @@ -1513,7 +1513,7 @@ dependencies = [ [[package]] name = "fbuild-serial" -version = "2.5.20" +version = "2.5.21" dependencies = [ "async-trait", "base64", @@ -1536,7 +1536,7 @@ dependencies = [ [[package]] name = "fbuild-test-support" -version = "2.5.20" +version = "2.5.21" dependencies = [ "fbuild-config", "fbuild-core", @@ -1556,7 +1556,7 @@ dependencies = [ [[package]] name = "fbuild-toolchain" -version = "2.5.20" +version = "2.5.21" dependencies = [ "async-trait", "axum 0.7.9", diff --git a/Cargo.toml b/Cargo.toml index c28ccff5a..a7c341b61 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,7 +77,7 @@ exclude = [ libraries = [{ path = "dylints/*" }] [workspace.package] -version = "2.5.20" +version = "2.5.21" edition = "2021" rust-version = "1.95.0" license = "AGPL-3.0-only" diff --git a/crates/fbuild-python/README.md b/crates/fbuild-python/README.md index b7270b1a1..086f7603a 100644 --- a/crates/fbuild-python/README.md +++ b/crates/fbuild-python/README.md @@ -13,6 +13,11 @@ do not require a target Python installation or import library. - `Daemon` -- Static methods for daemon lifecycle: `ensure_running()`, `stop()`, `status()` - `DaemonConnection` -- Python context manager for build/deploy/monitor operations via the daemon's HTTP API - `connect_daemon()` -- Factory function matching `from fbuild import connect_daemon` +- `find_firmware()` -- Non-mutating canonical artifact lookup backed by `fbuild_paths::BuildLayout` + +The structured `build_result()` and `deploy_result()` dictionaries include +`output_file` and `output_dir` from the daemon response in addition to status +and captured streams. ## Architecture diff --git a/crates/fbuild-python/src/README.md b/crates/fbuild-python/src/README.md index d4f2abcbe..a7f2a4921 100644 --- a/crates/fbuild-python/src/README.md +++ b/crates/fbuild-python/src/README.md @@ -2,4 +2,4 @@ ## Modules -- **`lib.rs`** -- Crate root; defines `SerialMonitor` (WebSocket-based serial I/O), `Daemon` (lifecycle management), `DaemonConnection` (build/deploy/monitor operations), and `connect_daemon()` factory; registers the `_native` PyO3 module +- **`lib.rs`** -- Crate root; registers the `_native` PyO3 module and standalone factories/helpers including `connect_daemon()` and canonical `find_firmware()` artifact discovery diff --git a/crates/fbuild-python/src/async_daemon_connection.rs b/crates/fbuild-python/src/async_daemon_connection.rs index 0c216d55a..83c562244 100644 --- a/crates/fbuild-python/src/async_daemon_connection.rs +++ b/crates/fbuild-python/src/async_daemon_connection.rs @@ -128,7 +128,8 @@ impl AsyncDaemonConnection { /// Async counterpart to `DaemonConnection::build_result`. Returns the /// full structured outcome dict (`success`, `message`, `exit_code`, - /// `stdout`, `stderr`) — matches the sync surface exactly. + /// `output_file`, `output_dir`, `stdout`, `stderr`) — matches the sync + /// surface exactly. #[pyo3(signature = (clean=false, verbose=false, timeout=1800.0))] fn build_result<'py>( &self, diff --git a/crates/fbuild-python/src/daemon_connection.rs b/crates/fbuild-python/src/daemon_connection.rs index 99b9f2986..6e4551080 100644 --- a/crates/fbuild-python/src/daemon_connection.rs +++ b/crates/fbuild-python/src/daemon_connection.rs @@ -71,8 +71,9 @@ impl DaemonConnection { } /// Same as `build()` but returns a dict with structured result fields: - /// `success`, `message`, `exit_code`, `stdout`, `stderr`. Callers that - /// need to branch on failure mode can inspect the dict instead of + /// `success`, `message`, `exit_code`, `output_file`, `output_dir`, + /// `stdout`, `stderr`. Callers that need to branch on failure mode or + /// consume the produced artifact can inspect the dict instead of /// swallowing a bare bool. See FastLED/fbuild#18. #[pyo3(signature = (clean=false, verbose=false, timeout=1800.0))] fn build_result<'py>( diff --git a/crates/fbuild-python/src/lib.rs b/crates/fbuild-python/src/lib.rs index ed7282d43..9436509bd 100644 --- a/crates/fbuild-python/src/lib.rs +++ b/crates/fbuild-python/src/lib.rs @@ -28,6 +28,7 @@ #![allow(clippy::useless_conversion)] use pyo3::prelude::*; +use std::path::Path; mod async_daemon_connection; mod async_serial_monitor; @@ -57,6 +58,25 @@ fn connect_daemon_async(project_dir: String, environment: String) -> AsyncDaemon AsyncDaemonConnection::new(project_dir, environment) } +/// Locate a built firmware artifact using fbuild's canonical layout rules. +/// +/// This is intentionally a non-mutating filesystem query. It lets Python +/// consumers keep a streaming CLI build/deploy while delegating artifact +/// discovery to the same `BuildLayout` implementation used by fbuild itself. +#[pyfunction(signature = (project_dir, environment, firmware_name=None))] +fn find_firmware( + project_dir: String, + environment: String, + firmware_name: Option, +) -> Option { + fbuild_paths::find_firmware( + Path::new(&project_dir), + &environment, + firmware_name.as_deref(), + ) + .map(|path| path.to_string_lossy().into_owned()) +} + /// The version string exposed to Python as `fbuild.__version__`. /// /// Sourced from `CARGO_PKG_VERSION` at compile time so it always tracks the @@ -77,6 +97,7 @@ fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_function(wrap_pyfunction!(connect_daemon, m)?)?; m.add_function(wrap_pyfunction!(connect_daemon_async, m)?)?; + m.add_function(wrap_pyfunction!(find_firmware, m)?)?; Ok(()) } @@ -144,6 +165,8 @@ mod tests { "success": false, "message": "build failed", "exit_code": 2, + "output_file": "/tmp/build/release/firmware.bin", + "output_dir": "/tmp/export", "stdout": "compile log", "stderr": "error: missing header", }); @@ -151,6 +174,11 @@ mod tests { assert!(!outcome.success); assert_eq!(outcome.message.as_deref(), Some("build failed")); assert_eq!(outcome.exit_code, Some(2)); + assert_eq!( + outcome.output_file.as_deref(), + Some("/tmp/build/release/firmware.bin") + ); + assert_eq!(outcome.output_dir.as_deref(), Some("/tmp/export")); assert_eq!(outcome.stdout.as_deref(), Some("compile log")); assert_eq!(outcome.stderr.as_deref(), Some("error: missing header")); } @@ -169,10 +197,38 @@ mod tests { assert!(outcome.success); assert_eq!(outcome.message.as_deref(), Some("done")); assert_eq!(outcome.exit_code, None); + assert_eq!(outcome.output_file, None); + assert_eq!(outcome.output_dir, None); assert_eq!(outcome.stdout, None); assert_eq!(outcome.stderr, None); } + /// Python consumers that keep the streaming CLI deploy path still need a + /// structured way to locate the exact artifact using fbuild's canonical + /// `BuildLayout` rules. FastLED's staged project basename equals its env, + /// so this also guards the collapsed `.fbuild/build/release` layout. + #[test] + fn find_firmware_locates_collapsed_project_layout() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("rp2350w"); + let firmware = fbuild_paths::get_project_build_root(&project) + .join("release") + .join("firmware.bin"); + std::fs::create_dir_all(firmware.parent().unwrap()).unwrap(); + std::fs::write(&firmware, b"firmware").unwrap(); + + let resolved = crate::find_firmware( + project.to_string_lossy().into_owned(), + "rp2350w".to_string(), + None, + ); + + assert_eq!( + resolved.as_deref(), + Some(firmware.to_string_lossy().as_ref()) + ); + } + /// A malformed or empty response body must not panic and must default /// to a failure outcome so callers don't mistakenly treat a garbage /// response as success. diff --git a/crates/fbuild-python/src/outcome.rs b/crates/fbuild-python/src/outcome.rs index 773303900..7cf3cd59c 100644 --- a/crates/fbuild-python/src/outcome.rs +++ b/crates/fbuild-python/src/outcome.rs @@ -70,6 +70,10 @@ pub(crate) struct OperationOutcome { pub(crate) success: bool, pub(crate) message: Option, pub(crate) exit_code: Option, + /// Primary firmware artifact reported by the daemon. + pub(crate) output_file: Option, + /// Explicit artifact-export directory, when one was requested. + pub(crate) output_dir: Option, pub(crate) stdout: Option, pub(crate) stderr: Option, } @@ -82,6 +86,8 @@ pub(crate) fn outcome_to_pydict<'py>( dict.set_item("success", outcome.success)?; dict.set_item("message", outcome.message.clone())?; dict.set_item("exit_code", outcome.exit_code)?; + dict.set_item("output_file", outcome.output_file.clone())?; + dict.set_item("output_dir", outcome.output_dir.clone())?; dict.set_item("stdout", outcome.stdout.clone())?; dict.set_item("stderr", outcome.stderr.clone())?; Ok(dict) @@ -107,6 +113,14 @@ pub(crate) fn parse_outcome(body: &serde_json::Value) -> OperationOutcome { None } }), + output_file: body + .get("output_file") + .and_then(|v| v.as_str()) + .map(str::to_string), + output_dir: body + .get("output_dir") + .and_then(|v| v.as_str()) + .map(str::to_string), stdout: body .get("stdout") .and_then(|v| v.as_str()) diff --git a/docs/architecture/pyo3-bindings.md b/docs/architecture/pyo3-bindings.md index 61f598c9a..0079f0e8c 100644 --- a/docs/architecture/pyo3-bindings.md +++ b/docs/architecture/pyo3-bindings.md @@ -18,11 +18,16 @@ runtime or import library. FastLED (`~/dev/fastled`) imports these from the `fbuild` Python package: ```python -from fbuild import connect_daemon, Daemon +from fbuild import connect_daemon, Daemon, find_firmware from fbuild.api import SerialMonitor from fbuild.daemon import ensure_daemon_running, stop_daemon ``` +`find_firmware(project_dir, environment, firmware_name=None)` is a +non-mutating query backed by `fbuild_paths::find_firmware`; consumers must use +it instead of reconstructing `.fbuild/build` paths. Structured build/deploy +results also preserve the daemon's `output_file` and `output_dir` fields. + ## SerialMonitor API Must be a context manager with these methods: diff --git a/pyproject.toml b/pyproject.toml index 9cd0f26d2..ba82c2ab6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fbuild" -version = "2.5.20" +version = "2.5.21" description = "PlatformIO-compatible embedded build tool (Rust implementation)" readme = "README.md" license = "AGPL-3.0-only" diff --git a/python/README.md b/python/README.md index 7c52dfe6f..02a921b0f 100644 --- a/python/README.md +++ b/python/README.md @@ -4,7 +4,7 @@ Thin Python wrapper modules that re-export classes from the Rust `_native` exten ## Contents -- **`fbuild/`** -- Top-level package re-exporting `Daemon`, `DaemonConnection`, `connect_daemon`, and `__version__` +- **`fbuild/`** -- Top-level package re-exporting daemon clients, canonical firmware discovery, and `__version__` - **`fbuild/api/`** -- Public serial-monitoring API re-exporting `SerialMonitor` - **`fbuild/_native.{pyd,abi3.so,so,dylib}`** -- Compiled Rust PyO3 extension (platform-specific binary, gitignored — see below) diff --git a/python/fbuild/README.md b/python/fbuild/README.md index 8b0c609af..29315054d 100644 --- a/python/fbuild/README.md +++ b/python/fbuild/README.md @@ -4,6 +4,6 @@ Top-level Python package that re-exports Rust PyO3 classes, providing a drop-in ## Modules -- **`__init__.py`** -- Re-exports `Daemon`, `DaemonConnection`, `connect_daemon`, and `__version__` from `_native` +- **`__init__.py`** -- Re-exports `Daemon`, `DaemonConnection`, `connect_daemon`, canonical `find_firmware`, and `__version__` from `_native` - **`_native.{pyd,abi3.so,so,dylib}`** -- Compiled Rust extension built by the `fbuild-python` crate (not checked into the repo — build locally; see [../README.md](../README.md)) - **`api/`** -- Sub-package exposing `SerialMonitor` for serial port monitoring diff --git a/python/fbuild/__init__.py b/python/fbuild/__init__.py index 8bb87b305..77fef08fa 100644 --- a/python/fbuild/__init__.py +++ b/python/fbuild/__init__.py @@ -5,7 +5,7 @@ Usage:: - from fbuild import Daemon, DaemonConnection, connect_daemon, __version__ + from fbuild import Daemon, DaemonConnection, connect_daemon, find_firmware, __version__ """ from fbuild._native import ( # noqa: F401 @@ -16,6 +16,7 @@ __version__, connect_daemon, connect_daemon_async, + find_firmware, ) __all__ = [ @@ -26,4 +27,5 @@ "DaemonConnection", "connect_daemon", "connect_daemon_async", + "find_firmware", ] diff --git a/uv.lock b/uv.lock index 65157e058..0d8c2e54f 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.10" [[package]] name = "fbuild" -version = "2.5.19" +version = "2.5.21" source = { editable = "." } [package.dev-dependencies]