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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,13 @@
notably the CLI's `except RuntimeError`, which turns this into a clean
`exit 1` rather than a traceback. New code should catch
`UnknownBackendError`.
- **Runner arguments can now override defaults via CLI, Environment, or Config.**
The `flash` command now resolves extra tool arguments following a strict
precedence chain. CLI passthrough (`--`) overrides the
`EBUILD_FLASH_RUNNER_ARGS` environment variable, which in turn overrides the
`runner_args` list in the `flash:` section of `build.yaml`. This enables
developers to instantly customize underlying tools (like OpenOCD or ESPTool)
without requiring new native `ebuild` flags (`ebuild/cli/commands.py`).

## [3.0.1] - 2026-05-16

Expand Down
122 changes: 117 additions & 5 deletions ebuild/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@
import glob
import os
import re
import shlex
import shutil
import subprocess
import threading
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING
from typing import Any, Callable, Dict, List, Optional, Tuple, TYPE_CHECKING

if TYPE_CHECKING:
from ebuild.eos_ai.eos_hw_analyzer import HardwareProfile
Expand Down Expand Up @@ -51,6 +52,80 @@
# Canonical recipe search path discovery
_find_recipe_dirs = find_recipe_dirs

# yaml key for the runner args config
_RUNNER_ARGS_CONFIG_KEY = "runner_args"


def _resolve_runner_args(
cli_args: tuple,
env_key: str,
cfg_loader: "Callable[[], ProjectConfig]",
config_section: str,
config_key: str,
log: "Logger",
clear_args: bool = False,
) -> list[str]:
"""Resolve tool arguments following a precedence chain: CLI > Environment > Configuration File.

This function short-circuits to avoid unnecessary config file reads if CLI or environment
arguments are present.

Args:
cli_args: Tuple of unparsed CLI arguments captured by Click.
env_key: The environment variable name to check for arguments.
cfg_loader: A callable that returns the parsed ProjectConfig.
config_section: The section name within the parsed ProjectConfig (e.g. 'flash_config').
config_key: The key within the section mapping that holds the arguments list.
log: Logger instance for recording debug or warning messages.
clear_args: If true, explicitly clear the arguments, ignoring config and environment.

Returns:
A list of string arguments to pass to the underlying tool. Returns empty list if none found.
"""
if clear_args:
log.debug("Runner args explicitly cleared via flag")
return []

# CLI args (highest precedence)
if cli_args:
args_list = list(cli_args)
log.debug(f"Loaded runner args from CLI: {args_list}")
return args_list

# Environment variable
env_args = os.environ.get(env_key)
if env_args and env_args.strip():
args_list = shlex.split(env_args.strip())
log.debug(f"Loaded runner args from environment ({env_key}): {args_list}")
return args_list

# Config file
try:
cfg = cfg_loader()
section = getattr(cfg, config_section, {})
if not isinstance(section, dict):
section = {}

config_args = section.get(config_key, [])
if isinstance(config_args, list):
args_list = [str(a) for a in config_args]
if args_list:
log.debug(f"Loaded runner args from config: {args_list}")
return args_list
elif isinstance(config_args, str):
args_list = shlex.split(config_args)
if args_list:
log.debug(f"Loaded runner args from config: {args_list}")
return args_list
else:
log.warning(f"Invalid format for {config_key} in config file: must be a list or string")
return []
except FileNotFoundError:
pass # config is optional
except Exception as e:
log.warning(f"Failed to load config: {e}")

return []


def _install_packages(
Expand Down Expand Up @@ -1572,21 +1647,42 @@ def firmware(log: Logger, config_path: str, build_dir: str, rtos: str, board: st
raise SystemExit(1)


@cli.command()
@cli.command(context_settings=dict(ignore_unknown_options=True))
@click.argument("image", type=click.Path(exists=True))
@click.option("--tool", default="openocd",
type=click.Choice(["openocd", "pyocd", "nrfjprog", "esptool", "stflash"]),
help="Flash tool to use.")
@click.option("--target", default="stm32f4", help="Target MCU/board.")
@click.option("--address", default="0x08000000", help="Flash base address (hex).")
@click.option("--reset-after", is_flag=True, default=False, help="Reset target after flashing.")
@click.option(
"--reset-after", is_flag=True, default=False, help="Reset target after flashing."
)
@click.option(
"--config",
"config_path",
default="build.yaml",
type=click.Path(),
help="Path to build config.",
)
@click.option(
"--no-runner-args",
is_flag=True,
default=False,
help="Clear runner args (overrides config and environment).",
)
@click.argument("cli_args", nargs=-1, type=click.UNPROCESSED)
@click.pass_obj
def flash(log: Logger, image: str, tool: str, target: str, address: str,
reset_after: bool) -> None:
reset_after: bool, config_path: str, no_runner_args: bool, cli_args: tuple) -> None:
"""Flash a firmware image to the target device.

Supports OpenOCD, pyOCD, nrfjprog, esptool, and st-flash.

Underlying tool arguments (runner args) can be set by, in order of precedence,
extra args on the CLI (unrecognized options will be passed as runner args),
by setting the EBUILD_FLASH_RUNNER_ARGS environment variable,
or by defining a `runner_args` list in the `flash` section of build.yaml.

Examples:

ebuild flash firmware.bin --tool openocd --target stm32f4
Expand All @@ -1596,6 +1692,8 @@ def flash(log: Logger, image: str, tool: str, target: str, address: str,
ebuild flash firmware.bin --tool esptool --address 0x10000

ebuild flash firmware.bin --tool pyocd --target nrf52840 --reset-after

ebuild flash firmware.bin --tool esptool -- --port /dev/ttyUSB0
"""
log.header("ebuild — Flash")

Expand All @@ -1605,10 +1703,24 @@ def flash(log: Logger, image: str, tool: str, target: str, address: str,
image_path = Path(image)
addr = int(address, 0)

runner_args = _resolve_runner_args(
cli_args,
"EBUILD_FLASH_RUNNER_ARGS",
lambda: load_config(config_path),
"flash_config",
_RUNNER_ARGS_CONFIG_KEY,
log,
clear_args=no_runner_args,
)

log.step(f"Flashing {image_path.name} to {target} via {tool}...")
log.info(f" Address: {hex(addr)}")
if runner_args:
log.info(f" Runner args: {' '.join(runner_args)}")

do_flash(image_path, tool=tool, target=target, address=addr)
do_flash(
image_path, tool=tool, target=target, address=addr, extra_args=runner_args
)
log.success(f"Flash complete: {image_path.name}")

if reset_after:
Expand Down
9 changes: 9 additions & 0 deletions ebuild/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ class ProjectConfig:
backend: str = "auto"
backend_config: Dict[str, Any] = field(default_factory=dict)
system_config: Dict[str, Any] = field(default_factory=dict)
flash_config: Dict[str, Any] = field(default_factory=dict)

def get_target(self, name: str) -> Optional[TargetConfig]:
for t in self.targets:
Expand Down Expand Up @@ -260,6 +261,13 @@ def load_config(config_path: str | Path) -> ProjectConfig:
raise ConfigError("'system' must be a mapping.")
system_config = dict(system_config)

flash_config = raw.get("flash", {})
if flash_config is None:
flash_config = {}
if not isinstance(flash_config, dict):
raise ConfigError("'flash' must be a mapping.")
flash_config = dict(flash_config)

# For cmake/make/meson builds, pull defines from config
if raw.get("cmake") and isinstance(raw["cmake"], dict):
backend_config.update(raw["cmake"])
Expand Down Expand Up @@ -345,4 +353,5 @@ def load_config(config_path: str | Path) -> ProjectConfig:
# every consumer saw an empty mapping and the whole [system]
# section was silently inert.
system_config=system_config,
flash_config=flash_config,
)
16 changes: 9 additions & 7 deletions ebuild/firmware/flash.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ class FlashError(Exception):
"openocd": ["openocd", "-f", "interface/stlink.cfg"],
"pyocd": ["pyocd", "flash"],
"nrfjprog": ["nrfjprog", "--program"],
"esptool": ["esptool.py", "--chip", "esp32", "write_flash"],
"stflash": ["st-flash", "write"],
"esptool": ["esptool.py", "--chip", "esp32"],
"stflash": ["st-flash"],
}


Expand Down Expand Up @@ -53,6 +53,7 @@ def flash(

if tool == "openocd":
cmd.extend(["-f", f"target/{target}.cfg"])
cmd.extend(extra_args or [])
image_str = str(image_path)
if image_str.count("{") != image_str.count("}"):
raise FlashError(
Expand All @@ -68,15 +69,16 @@ def flash(
cmd.extend(["-c", f"program {{{image_path}}} {hex(address)} verify reset exit"])
elif tool == "pyocd":
cmd.extend([str(image_path), "--target", target, "--base-address", hex(address)])
cmd.extend(extra_args or [])
elif tool == "nrfjprog":
cmd.extend([str(image_path), "--sectorerase", "--verify"])
cmd.extend(extra_args or [])
elif tool == "esptool":
cmd.extend([hex(address), str(image_path)])
cmd.extend(extra_args or [])
cmd.extend(["write_flash", hex(address), str(image_path)])
elif tool == "stflash":
cmd.extend([str(image_path), hex(address)])

if extra_args:
cmd.extend(extra_args)
cmd.extend(extra_args or [])
cmd.extend(["write", str(image_path), hex(address)])

result = subprocess.run(cmd, capture_output=True)
if result.returncode != 0:
Expand Down
Empty file added tests/unit/cli/__init__.py
Empty file.
77 changes: 77 additions & 0 deletions tests/unit/cli/test_commands_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 EoS Project

import pytest
from ebuild.cli.commands import _resolve_runner_args
from ebuild.core.config import load_config

class DummyLogger:
def debug(self, msg): pass
def warning(self, msg): pass
def info(self, msg): pass
def error(self, msg): pass

def create_loader(config_path):
return lambda: load_config(config_path)

@pytest.mark.parametrize(
"config_val, env_val, cli_val, clear_args_val, expected",
[
# CLI priority short-circuits config
(None, "--env-val", ("--cli-arg",), False, ["--cli-arg"]),
# Env priority
(None, "--env-val", (), False, ["--env-val"]),
# Config priority
(["--config-arg"], None, (), False, ["--config-arg"]),
# None matches
(None, None, (), False, []),
# Valid config string parsing
("--adapter jlink", None, (), False, ["--adapter", "jlink"]),
# Invalid config warns and falls back to empty
(12345, None, (), False, []),
# Explicit clear overrides config and env
(["--config-arg"], "--env-val", (), True, []),
]
)
def test_resolve_runner_args_unit(tmp_path, monkeypatch, config_val, env_val, cli_val, clear_args_val, expected):
"""Directly unit test the _resolve_runner_args function combinations."""
env_key = "TEST_ENV_KEY"
if env_val is not None:
monkeypatch.setenv(env_key, env_val)
else:
monkeypatch.delenv(env_key, raising=False)

config_file = tmp_path / "build.yaml"
if config_val is not None:
if isinstance(config_val, list):
args_str = ", ".join(f'"{a}"' for a in config_val)
config_file.write_text(f'project:\n name: test\nsystem:\n test_args: [{args_str}]\n', encoding="utf-8")
else:
config_file.write_text(f'project:\n name: test\nsystem:\n test_args: {config_val}\n', encoding="utf-8")

res = _resolve_runner_args(
cli_args=cli_val,
env_key=env_key,
cfg_loader=create_loader(str(config_file)),
config_section="system_config",
config_key="test_args",
log=DummyLogger(),
clear_args=clear_args_val,
)
assert res == expected

def test_resolve_runner_args_null_safety():
"""Verify null safety with empty or non-dict sections."""
class FakeConfig:
system_config = None # Explicitly None to test null safety

res = _resolve_runner_args(
cli_args=(),
env_key="TEST_ENV_KEY",
cfg_loader=lambda: FakeConfig(),
config_section="system_config",
config_key="test_args",
log=DummyLogger()
)
assert res == []

Loading
Loading