Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 21 additions & 21 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions crates/fbuild-python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion crates/fbuild-python/src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion crates/fbuild-python/src/async_daemon_connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions crates/fbuild-python/src/daemon_connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>(
Expand Down
56 changes: 56 additions & 0 deletions crates/fbuild-python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
#![allow(clippy::useless_conversion)]

use pyo3::prelude::*;
use std::path::Path;

mod async_daemon_connection;
mod async_serial_monitor;
Expand Down Expand Up @@ -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<String>,
) -> Option<String> {
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
Expand All @@ -77,6 +97,7 @@ fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<AsyncDaemonConnection>()?;
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(())
}

Expand Down Expand Up @@ -144,13 +165,20 @@ 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",
});
let outcome = parse_outcome(&body);
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"));
}
Expand All @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions crates/fbuild-python/src/outcome.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ pub(crate) struct OperationOutcome {
pub(crate) success: bool,
pub(crate) message: Option<String>,
pub(crate) exit_code: Option<i32>,
/// Primary firmware artifact reported by the daemon.
pub(crate) output_file: Option<String>,
/// Explicit artifact-export directory, when one was requested.
pub(crate) output_dir: Option<String>,
pub(crate) stdout: Option<String>,
pub(crate) stderr: Option<String>,
}
Expand All @@ -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)
Expand All @@ -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())
Expand Down
7 changes: 6 additions & 1 deletion docs/architecture/pyo3-bindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading